Centralize local IDs and clarify async tokens

This commit is contained in:
murashit 2026-06-18 08:05:36 +09:00
parent d7889c5834
commit d3f62d8228
26 changed files with 315 additions and 276 deletions

View file

@ -2,6 +2,7 @@ import type { RequestId, ServerNotification, ServerRequest } from "../../../../a
import type { McpServerStartupStatus } from "../../../../domain/server/diagnostics";
import type { Thread } from "../../../../domain/threads/model";
import type { ThreadConversationSummary } from "../../../../domain/threads/transcript";
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
import { classifyAppServerLog } from "./app-server-logs";
import { activeTurnId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
@ -11,7 +12,6 @@ import type { ApprovalAction, PendingApproval, PendingUserInput } from "../../do
import { approvalResponse } from "../requests/approval";
import { userInputResponse } from "../requests/user-input";
import { createApprovalResultItem, createUserInputResultItem } from "../../domain/pending-requests/result-items";
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../../domain/local-id";
import { planChatNotification, type ChatNotificationEffect } from "./notification-plan";
import { routeServerRequest } from "./routing";
@ -51,7 +51,7 @@ export interface ChatInboundControllerActions {
}
export class ChatInboundController {
private readonly localItemIds: LocalChatItemIdFactory = createLocalChatItemIdFactory();
private readonly localItemIds: LocalIdSource = createLocalIdSource();
constructor(
private readonly store: ChatStateStore,

View file

@ -74,7 +74,7 @@ export interface ChatNotificationPlan {
effects: readonly ChatNotificationEffect[];
}
export type LocalItemIdFactory = (prefix: string) => string;
export type LocalItemIdProvider = (prefix: string) => string;
const EMPTY_PLAN: ChatNotificationPlan = { actions: [], effects: [] };
const MESSAGE_CONTEXT_COMPACTED = "Context compacted.";
@ -85,7 +85,7 @@ type ServerNotificationPlanner<M extends ServerNotification["method"]> = (
type ServerNotificationPlannerMap<M extends ServerNotification["method"]> = { [Method in M]: ServerNotificationPlanner<Method> };
type ServerNotificationLocalPlanner<M extends ServerNotification["method"]> = (
notification: Extract<ServerNotification, { method: M }>,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
) => ChatNotificationPlan;
type ServerNotificationLocalPlannerMap<M extends ServerNotification["method"]> = {
[Method in M]: ServerNotificationLocalPlanner<Method>;
@ -93,7 +93,7 @@ type ServerNotificationLocalPlannerMap<M extends ServerNotification["method"]> =
type ServerNotificationStatePlanner<M extends ServerNotification["method"]> = (
state: ChatState,
notification: Extract<ServerNotification, { method: M }>,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
) => ChatNotificationPlan;
type ServerNotificationStatePlannerMap<M extends ServerNotification["method"]> = {
[Method in M]: ServerNotificationStatePlanner<Method>;
@ -316,7 +316,7 @@ export const PLANNED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
export function planChatNotification(
state: ChatState,
notification: ServerNotification,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const route = routeServerNotification(notification, {
activeThreadId: state.activeThread.id,
@ -344,14 +344,18 @@ export function planChatNotification(
}
}
function planStreamUpdate(state: ChatState, notification: StreamUpdateNotification, localItemId: LocalItemIdFactory): ChatNotificationPlan {
function planStreamUpdate(
state: ChatState,
notification: StreamUpdateNotification,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
return planNotificationWithStateByMethod(state, notification, STREAM_UPDATE_PLANNERS, localItemId);
}
function planTurnLifecycle(
state: ChatState,
notification: TurnLifecycleNotification,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
return planNotificationWithStateByMethod(state, notification, TURN_LIFECYCLE_PLANNERS, localItemId);
}
@ -359,7 +363,7 @@ function planTurnLifecycle(
function planThreadLifecycle(
state: ChatState,
notification: ThreadLifecycleNotification,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
return planNotificationWithStateByMethod(state, notification, THREAD_LIFECYCLE_PLANNERS, localItemId);
}
@ -368,7 +372,7 @@ function planDiagnosticStatus(notification: DiagnosticStatusNotification): ChatN
return planNotificationByMethod(notification, DIAGNOSTIC_STATUS_PLANNERS);
}
function planUserVisibleNotice(notification: UserVisibleNoticeNotification, localItemId: LocalItemIdFactory): ChatNotificationPlan {
function planUserVisibleNotice(notification: UserVisibleNoticeNotification, localItemId: LocalItemIdProvider): ChatNotificationPlan {
return planNotificationWithLocalItemIdByMethod(notification, USER_VISIBLE_NOTICE_PLANNERS, localItemId);
}
@ -383,7 +387,7 @@ function planNotificationByMethod<M extends ServerNotification["method"]>(
function planNotificationWithLocalItemIdByMethod<M extends ServerNotification["method"]>(
notification: Extract<ServerNotification, { method: M }>,
planners: ServerNotificationLocalPlannerMap<M>,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const planner = planners[notification.method];
return planner(notification, localItemId);
@ -393,7 +397,7 @@ function planNotificationWithStateByMethod<M extends ServerNotification["method"
state: ChatState,
notification: Extract<ServerNotification, { method: M }>,
planners: ServerNotificationStatePlannerMap<M>,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const planner = planners[notification.method];
return planner(state, notification, localItemId);
@ -405,7 +409,7 @@ function plannerMethods<M extends ServerNotification["method"]>(planners: Record
function jsonNoticePlan(
notification: Extract<ServerNotification, { method: Exclude<UserVisibleNoticeNotificationMethod, "thread/compacted"> }>,
localItemId: LocalItemIdFactory,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
return systemMessagePlan({ id: localItemId("system"), text: `${notification.method}: ${jsonPreview(notification.params)}` });
}

View file

@ -4,7 +4,7 @@ import type { MessageStreamItemProvenance } from "../../domain/message-stream/pr
import { fileMentionsFromInput } from "../../domain/message-stream/format/file-mentions";
import { userMessageDisplayText } from "../../domain/message-stream/format/user-message-text";
import { attachHookRunsToTurn } from "../../domain/message-stream/updates";
import { isLocalSteerMessageClientId } from "../../domain/local-id";
import { isLocalSteerMessageClientId } from "../../domain/local-message-ids";
import type { CodexInput } from "../../../../domain/chat/input";
interface LocalUserMessageParams {

View file

@ -1,9 +1,9 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { CodexInput } from "../../../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
import { submissionStateSnapshot } from "../state/selectors";
import type { ChatStateStore } from "../state/store";
import { createLocalChatItemIdFactory } from "../../domain/local-id";
import {
acknowledgeOptimisticTurnStart,
cleanupFailedTurnStart,
@ -39,7 +39,7 @@ export interface TurnSubmissionActions {
}
export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions {
const localItemIds = createLocalChatItemIdFactory();
const localItemIds = createLocalIdSource();
return {
sendTurnText: (text, codexInputOverride, referencedThread) =>
@ -49,7 +49,7 @@ export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): Tu
async function sendTurnText(
host: TurnSubmissionActionsHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
localItemIds: LocalIdSource,
text: string,
codexInputOverride?: CodexInput,
referencedThread?: ReferencedThreadMetadata,
@ -135,7 +135,7 @@ async function sendTurnText(
async function steerCurrentTurn(
host: TurnSubmissionActionsHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
localItemIds: LocalIdSource,
client: AppServerClient,
text: string,
codexInputOverride?: CodexInput,

View file

@ -1,6 +1,7 @@
import type { ChatStateStore } from "../state/store";
import { pendingRequestFocusSignature } from "../../domain/pending-requests/signatures";
import {
approvalDetailsDisclosureId,
answersForPendingUserInput,
type ApprovalAction,
type PendingApproval,
@ -72,7 +73,7 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe
host.stateStore.dispatch({
type: "ui/disclosure-set",
bucket: "approvalDetails",
id: `${String(requestId)}:details`,
id: approvalDetailsDisclosureId(requestId),
open: expanded,
});
},

View file

@ -1,5 +1,5 @@
import type { ThreadGoal } from "../../../../domain/threads/goal";
import type { PendingRequestId } from "../../domain/pending-requests/model";
import { pendingRequestDerivedKeyPrefix, type PendingRequestId } from "../../domain/pending-requests/model";
import type { DisclosureSetAction } from "./actions";
export type ChatRenameUiState =
@ -10,7 +10,7 @@ export type ChatRenameUiState =
readonly threadId: string;
readonly draft: string;
readonly originalDraft: string;
readonly generationId: number;
readonly generationToken: number;
};
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
@ -60,7 +60,7 @@ export type UiAction =
| { type: "ui/rename-started"; threadId: string; draft: string }
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
| { type: "ui/rename-cancelled"; threadId: string }
| { type: "ui/rename-generation-started"; threadId: string; originalDraft: string; generationId: number }
| { type: "ui/rename-generation-started"; threadId: string; originalDraft: string; generationToken: number }
| { type: "ui/rename-generation-succeeded"; generatingState: ChatRenameGeneratingUiState; draft: string }
| { type: "ui/rename-generation-finished"; threadId: string; generatingState: ChatRenameGeneratingUiState }
| { type: "ui/rename-cleared" }
@ -117,7 +117,7 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
return patchObject(state, { rename: renameUiStateCancelled(state.rename, action.threadId) });
case "ui/rename-generation-started":
return patchObject(state, {
rename: renameUiGenerationStarted(state.rename, action.threadId, action.originalDraft, action.generationId),
rename: renameUiGenerationStarted(state.rename, action.threadId, action.originalDraft, action.generationToken),
});
case "ui/rename-generation-succeeded":
return patchObject(state, { rename: renameUiGenerationSucceeded(state.rename, action.generatingState, action.draft) });
@ -172,7 +172,7 @@ export function renameGenerationStillActive(
state.kind === "generating" &&
state.threadId === generatingState.threadId &&
state.originalDraft === generatingState.originalDraft &&
state.generationId === generatingState.generationId
state.generationToken === generatingState.generationToken
);
}
@ -187,8 +187,8 @@ export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
}
export function clearResolvedRequestDisclosures(state: ChatUiState, requestId: PendingRequestId): ChatUiState {
const id = String(requestId);
const approvalDetails = filterStringSet(state.disclosures.approvalDetails, (key) => !key.startsWith(`${id}:`));
const keyPrefix = pendingRequestDerivedKeyPrefix(requestId);
const approvalDetails = filterStringSet(state.disclosures.approvalDetails, (key) => !key.startsWith(keyPrefix));
if (approvalDetails === state.disclosures.approvalDetails) return state;
return patchObject(state, {
disclosures: {
@ -268,7 +268,7 @@ function renameUiGenerationStarted(
state: ChatRenameUiState,
threadId: string,
originalDraft: string,
generationId: number,
generationToken: number,
): ChatRenameUiState {
if (state.kind !== "editing" || state.threadId !== threadId) return state;
return {
@ -276,7 +276,7 @@ function renameUiGenerationStarted(
threadId,
draft: state.draft,
originalDraft,
generationId,
generationToken,
};
}

View file

@ -1,10 +1,10 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads";
import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal";
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
import type { ChatStateStore } from "../state/store";
import type { GoalMessageStreamItem } from "../../domain/message-stream/items";
import { goalChangeItem } from "../../domain/message-stream/factories/goal-items";
import { createLocalChatItemIdFactory } from "../../domain/local-id";
export interface ThreadGoalSyncHost {
stateStore: ChatStateStore;
@ -34,14 +34,14 @@ function emptyGoalObjectiveMessage(): string {
}
export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoalSyncActions {
const localItemIds = createLocalChatItemIdFactory();
const localItemIds = createLocalIdSource();
return {
syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId),
};
}
export function createGoalActions(host: GoalActionsHost): GoalActions {
const localItemIds = createLocalChatItemIdFactory();
const localItemIds = createLocalIdSource();
return {
activeGoal: () => host.stateStore.getState().activeThread.goal,
syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId),
@ -51,11 +51,7 @@ export function createGoalActions(host: GoalActionsHost): GoalActions {
};
}
async function syncThreadGoal(
host: ThreadGoalSyncHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
threadId: string,
): Promise<void> {
async function syncThreadGoal(host: ThreadGoalSyncHost, localItemIds: LocalIdSource, threadId: string): Promise<void> {
const client = host.currentClient();
if (!client) return;
try {
@ -67,7 +63,7 @@ async function syncThreadGoal(
async function setObjective(
host: GoalActionsHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
localItemIds: LocalIdSource,
threadId: string,
objective: string,
tokenBudget: number | null,
@ -90,20 +86,11 @@ async function setObjective(
return applied;
}
function setGoalStatus(
host: GoalActionsHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
threadId: string,
status: ThreadGoalStatus,
): Promise<boolean> {
function setGoalStatus(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
return setGoal(host, localItemIds, threadId, { status });
}
async function clearGoal(
host: GoalActionsHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
threadId: string,
): Promise<boolean> {
async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string): Promise<boolean> {
await host.ensureConnected();
const client = host.currentClient();
if (!client) return false;
@ -117,12 +104,7 @@ async function clearGoal(
}
}
async function setGoal(
host: GoalActionsHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
threadId: string,
params: ThreadGoalUpdate,
): Promise<boolean> {
async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
await host.ensureConnected();
const client = host.currentClient();
if (!client) return false;
@ -136,7 +118,7 @@ async function setGoal(
function applyGoalIfActive(
host: ThreadGoalSyncHost,
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
localItemIds: LocalIdSource,
threadId: string,
goal: ThreadGoal | null,
options: { reportChange: boolean },

View file

@ -38,7 +38,7 @@ export interface ThreadRenameEditorActions {
}
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
let nextRenameGenerationId = 1;
let nextRenameGenerationToken = 1;
const action = {
editState(threadId: string): RenameEditState | null {
@ -99,11 +99,11 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
type: "ui/rename-generation-started",
threadId,
originalDraft: editingState.draft,
generationId: nextRenameGenerationId,
generationToken: nextRenameGenerationToken,
});
const generatingState: ChatRenameUiState = host.stateStore.getState().ui.rename;
if (generatingState.kind !== "generating") return;
nextRenameGenerationId += 1;
nextRenameGenerationToken += 1;
try {
const title = await host.generateThreadTitle(threadId);

View file

@ -1,40 +0,0 @@
export interface LocalChatItemIdFactory {
next(prefix: string): string;
}
export interface LocalChatItemIdFactoryOptions {
nowMs?: () => number;
seed?: string;
}
let factorySequence = 0;
export function createLocalChatItemIdFactory(options: LocalChatItemIdFactoryOptions = {}): LocalChatItemIdFactory {
const nowMs = options.nowMs ?? Date.now;
const seed = sanitizeIdPart(options.seed ?? defaultIdSeed());
factorySequence += 1;
const factoryId = `${seed}-${factorySequence.toString(36)}`;
let itemSequence = 0;
return {
next(prefix: string): string {
itemSequence += 1;
return `${prefix}-${String(nowMs())}-${factoryId}-${itemSequence.toString(36)}`;
},
};
}
export function isLocalUserMessageId(id: string): boolean {
return id.startsWith("local-user-") || id.startsWith("local-steer-");
}
export function isLocalSteerMessageClientId(id: string | null | undefined): boolean {
return id?.startsWith("local-steer-") ?? false;
}
function defaultIdSeed(): string {
return Date.now().toString(36);
}
function sanitizeIdPart(value: string): string {
return value.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "local";
}

View file

@ -0,0 +1,7 @@
export function isLocalUserMessageId(id: string): boolean {
return id.startsWith("local-user-") || id.startsWith("local-steer-");
}
export function isLocalSteerMessageClientId(id: string | null | undefined): boolean {
return id?.startsWith("local-steer-") ?? false;
}

View file

@ -1,4 +1,4 @@
import { isLocalUserMessageId } from "../local-id";
import { isLocalUserMessageId } from "../local-message-ids";
import type { MessageStreamItem, MessageStreamMessageItem } from "./items";
import { upsertMessageStreamItemById } from "./updates";

View file

@ -1,5 +1,5 @@
import type { MessageStreamItem } from "../items";
import { isLocalSteerMessageClientId } from "../../local-id";
import { isLocalSteerMessageClientId } from "../../local-message-ids";
import type {
MessageStreamLifecycle,
MessageStreamMeaning,

View file

@ -129,11 +129,23 @@ export function questionDefaultAnswer(question: PendingUserInputQuestion): strin
}
export function userInputDraftKey(requestId: PendingRequestId, questionId: string): string {
return `${String(requestId)}:${questionId}`;
return pendingRequestDerivedKey(requestId, questionId);
}
export function userInputOtherDraftKey(requestId: PendingRequestId, questionId: string): string {
return `${String(requestId)}:${questionId}:other`;
return pendingRequestDerivedKey(requestId, `${questionId}:other`);
}
export function approvalDetailsDisclosureId(requestId: PendingRequestId): string {
return pendingRequestDerivedKey(requestId, "details");
}
export function pendingRequestDerivedKeyPrefix(requestId: PendingRequestId): string {
return `${String(requestId)}:`;
}
function pendingRequestDerivedKey(requestId: PendingRequestId, suffix: string): string {
return `${pendingRequestDerivedKeyPrefix(requestId)}${suffix}`;
}
export function answersForPendingUserInput(input: PendingUserInput, drafts: ReadonlyMap<string, string>): Record<string, string> {

View file

@ -62,7 +62,7 @@ import {
} from "../presentation/runtime/status";
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
import { createLocalIdSource, type LocalIdSource } from "../../../shared/id/local-id";
import type { RuntimeSnapshot } from "../application/runtime/snapshot";
import type { ChatPanelEnvironment } from "./runtime";
import { createConnectionBundle, type ChatPanelConnectionBundle, type CurrentAppServerClient } from "./connection-bundle";
@ -166,7 +166,7 @@ interface ChatPanelSurfacePresenterParts {
export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph {
const { environment, stateStore } = host;
const localItemIds = createLocalChatItemIdFactory();
const localItemIds = createLocalIdSource();
const connection = createConnectionManager(environment);
const currentClient = () => connection.currentClient();
const status = createSessionStatus(stateStore, localItemIds);
@ -985,7 +985,7 @@ function collaborationModeLabel(stateStore: ChatStateStore): string {
return formatCollaborationModeLabel(stateStore.getState().runtime.selectedCollaborationMode);
}
function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalChatItemIdFactory): ChatPanelSessionStatus {
function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalIdSource): ChatPanelSessionStatus {
return {
set: (statusText, phase) => {
dispatch(stateStore, { type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });

View file

@ -1,13 +1,14 @@
import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
import { createLocalIdSource } from "../../../shared/id/local-id";
import type { CodexChatHost } from "./runtime";
import { ChatPanelSession } from "./session";
import type { ChatSurfaceHandle } from "./surface-handle";
export class CodexChatView extends ItemView {
readonly surface: ChatSurfaceHandle;
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
private readonly viewId = createLocalIdSource().next("codex-panel");
constructor(leaf: WorkspaceLeaf, plugin: CodexChatHost) {
super(leaf);

View file

@ -137,7 +137,7 @@ function goalDetailView(item: GoalMessageStreamItem): DetailView {
item,
"codex-panel__detail-item codex-panel__detail-item--goal",
"goal",
`${item.id}:goal-details`,
messageDetailKey(item.id, "goal-details"),
goalDetails(item),
);
}
@ -147,7 +147,7 @@ function agentDetailView(item: AgentMessageStreamItem): DetailView {
item,
"codex-panel__detail-item codex-panel__agent-activity",
"agent",
`${item.id}:agent-details`,
messageDetailKey(item.id, "agent-details"),
agentDetailSections(item),
agentSummaryText(item),
);
@ -158,7 +158,7 @@ function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStre
item,
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
item.toolName ?? item.kind,
`${item.id}:details`,
messageDetailKey(item.id, "details"),
[...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
genericToolSummary(item, workspaceRoot),
);
@ -168,7 +168,7 @@ function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"auto-review",
`${item.id}:review-details`,
messageDetailKey(item.id, "review-details"),
"codex-panel__message--review-result codex-panel__detail-item--review",
);
}
@ -177,7 +177,7 @@ function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"approval",
`${item.id}:approval-details`,
messageDetailKey(item.id, "approval-details"),
"codex-panel__message--approval-result codex-panel__detail-item--approval",
);
}
@ -187,12 +187,16 @@ function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | nul
item,
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
detailLabel(item),
`${item.id}:details`,
messageDetailKey(item.id, "details"),
genericDetailSections(item, workspaceRoot),
genericDetailSummary(item, workspaceRoot),
);
}
function messageDetailKey(itemId: string, suffix: string): string {
return `${itemId}:${suffix}`;
}
function resultDetailView(
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
label: string,

View file

@ -88,7 +88,7 @@ export function messageStreamLayoutBlocks(
const groupItems = groupedActivities.get(turnId) ?? [];
blocks.push({
type: "activityGroup",
id: `turn-${turnId}-activity`,
id: turnActivityGroupId(turnId),
turnId,
summary: "Work details",
items: groupItems,
@ -121,13 +121,21 @@ function isEmptyCompletedReasoningItem(item: MessageStreamItem): boolean {
function steeringActivityGroupItem(classification: MessageStreamSemanticClassification): MessageStreamActivityGroupItem {
return {
type: "steering",
id: `steer-activity-${classification.item.id}`,
id: steerActivityGroupId(classification.item.id),
label: STEERING_ACTIVITY_LABEL,
text: textForMessageStreamItem(classification.item),
sourceItemId: classification.item.sourceItemId ?? classification.item.id,
};
}
function turnActivityGroupId(turnId: string): string {
return `turn-${turnId}-activity`;
}
function steerActivityGroupId(itemId: string): string {
return `steer-activity-${itemId}`;
}
function isCompletedTurnDetailItem(classification: MessageStreamSemanticClassification, turnOutcomeIdByTurn: Map<string, string>): boolean {
const turnId = classification.item.turnId;
if (!turnId || messageStreamIsTurnInitiator(classification) || messageStreamIsTurnSteer(classification)) return false;

View file

@ -123,8 +123,7 @@ export function ComposerShell({
});
const sendMode = composerSendMode(busy, canInterrupt, draft);
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
const selectedSuggestionId =
suggestions.length > 0 ? `${viewId}-composer-suggestion-${String(normalizedSelectedSuggestionIndex)}` : undefined;
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
return (
<div className="codex-panel__composer">
@ -136,7 +135,7 @@ export function ComposerShell({
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestions.length > 0 ? "true" : "false"}
aria-controls={`${viewId}-composer-suggestions`}
aria-controls={composerSuggestionsListId(viewId)}
aria-activedescendant={selectedSuggestionId}
value={draft}
onInput={(event) => {
@ -527,13 +526,13 @@ function ComposerSuggestions({
<div
ref={containerRef}
className="codex-panel__composer-suggestions"
id={`${viewId}-composer-suggestions`}
id={composerSuggestionsListId(viewId)}
role="listbox"
hidden={suggestions.length === 0}
>
{suggestions.map((suggestion, index) => {
const selected = index === selectedIndex;
const optionId = `${viewId}-composer-suggestion-${String(index)}`;
const optionId = composerSuggestionOptionId(viewId, index);
return (
<div
key={optionId}
@ -559,3 +558,11 @@ function ComposerSuggestions({
</div>
);
}
function composerSuggestionsListId(viewId: string): string {
return `${viewId}-composer-suggestions`;
}
function composerSuggestionOptionId(viewId: string, index: number): string {
return `${viewId}-composer-suggestion-${String(index)}`;
}

View file

@ -1,6 +1,7 @@
import type { ComponentChild as UiNode } from "preact";
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import { approvalDetailsDisclosureId } from "../../domain/pending-requests/model";
import {
type PendingApprovalViewModel,
type PendingUserInputQuestionViewModel,
@ -130,7 +131,7 @@ function ApprovalDetails({
approvalDetails: ReadonlySet<string>;
actions: PendingRequestBlockActions;
}): UiNode {
const detailId = `${String(approval.requestId)}:details`;
const detailId = approvalDetailsDisclosureId(approval.requestId);
return (
<details
className="codex-panel__approval-details"
@ -210,7 +211,7 @@ function UserInputQuestions({
{question.options && question.options.length > 0 ? (
<>
{question.options.map((option) => {
const groupName = `codex-panel-${String(input.requestId)}-${question.id}`;
const groupName = userInputRadioGroupName(input.requestId, question.id);
return (
<label key={option.label} className="codex-panel__user-input-option">
<input
@ -232,7 +233,7 @@ function UserInputQuestions({
})}
{question.isOther ? (
<OtherUserInputOption
groupName={`codex-panel-${String(input.requestId)}-${question.id}`}
groupName={userInputRadioGroupName(input.requestId, question.id)}
current={current}
optionLabels={new Set(question.options.map((option) => option.label))}
question={question}
@ -252,6 +253,10 @@ function UserInputQuestions({
);
}
function userInputRadioGroupName(requestId: PendingUserInputViewModel["requestId"], questionId: string): string {
return `codex-panel-${String(requestId)}-${questionId}`;
}
function OtherUserInputOption({
groupName,
current,

View file

@ -40,10 +40,10 @@ export interface SettingsDynamicDataSnapshot {
export class SettingsDynamicDataController {
private settingsDataAutoLoadStarted = false;
private settingsRefreshOperationId = 0;
private modelsOperationId = 0;
private hooksOperationId = 0;
private archivedThreadsOperationId = 0;
private settingsRefreshOperationToken = 0;
private modelsOperationToken = 0;
private hooksOperationToken = 0;
private archivedThreadsOperationToken = 0;
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
private archivedThreads: Thread[] = [];
@ -80,10 +80,10 @@ export class SettingsDynamicDataController {
resetSettingsDataContext(): void {
this.settingsDataAutoLoadStarted = false;
this.settingsRefreshOperationId += 1;
this.modelsOperationId += 1;
this.hooksOperationId += 1;
this.archivedThreadsOperationId += 1;
this.settingsRefreshOperationToken += 1;
this.modelsOperationToken += 1;
this.hooksOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.settingsDataRefreshLifecycle = { kind: "idle" };
this.models = [...(this.host.appServerData.modelsSnapshot() ?? [])];
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
@ -108,28 +108,28 @@ export class SettingsDynamicDataController {
async refreshSettingsData(options: { forceModels?: boolean } = {}): Promise<void> {
this.settingsDataAutoLoadStarted = true;
const operationId = this.nextSettingsRefreshOperationId();
const modelsOperationId = this.nextModelsOperationId();
const hooksOperationId = this.nextHooksOperationId();
const archivedThreadsOperationId = this.nextArchivedThreadsOperationId();
const operationToken = this.nextSettingsRefreshOperationToken();
const modelsOperationToken = this.nextModelsOperationToken();
const hooksOperationToken = this.nextHooksOperationToken();
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "started",
operationId,
operationToken,
});
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "started",
status: "Loading models...",
operationId: modelsOperationId,
operationToken: modelsOperationToken,
});
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "started",
status: "Loading archived threads...",
operationId: archivedThreadsOperationId,
operationToken: archivedThreadsOperationToken,
});
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "started",
status: "Loading hooks...",
operationId: hooksOperationId,
operationToken: hooksOperationToken,
});
this.callbacks.display();
@ -139,16 +139,16 @@ export class SettingsDynamicDataController {
options.forceModels === false ? this.host.appServerData.fetchModels() : this.host.appServerData.refreshModels(),
this.withSettingsConnection((client) => loadSettingsCompanionData(client, this.host.vaultPath)),
] as const);
if (this.isStaleSettingsRefreshOperation(operationId)) return;
if (this.isStaleSettingsRefreshOperation(operationToken)) return;
if (this.isStaleModelsOperation(modelsOperationId)) {
if (this.isStaleModelsOperation(modelsOperationToken)) {
// A newer models operation owns this section.
} else if (modelsResult.status === "fulfilled") {
this.models = [...modelsResult.value];
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "loaded",
status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
operationId: modelsOperationId,
operationToken: modelsOperationToken,
});
} else if (isStaleAppServerSharedQueryContextError(modelsResult.reason)) {
return;
@ -157,7 +157,7 @@ export class SettingsDynamicDataController {
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${errorMessage(modelsResult.reason)}`,
operationId: modelsOperationId,
operationToken: modelsOperationToken,
});
}
@ -169,7 +169,7 @@ export class SettingsDynamicDataController {
archivedThreads: { ok: false as const, status: `Could not load archived threads: ${errorMessage(companionResult.reason)}` },
};
if (this.isStaleHooksOperation(hooksOperationId)) {
if (this.isStaleHooksOperation(hooksOperationToken)) {
// A newer hooks operation owns this section.
} else if (companion.hooks.ok) {
this.hooks = companion.hooks.data.hooks;
@ -178,67 +178,67 @@ export class SettingsDynamicDataController {
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: companion.hooks.status,
operationId: hooksOperationId,
operationToken: hooksOperationToken,
});
} else {
failedCount += 1;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: companion.hooks.status,
operationId: hooksOperationId,
operationToken: hooksOperationToken,
});
}
if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) {
if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
// A newer archived threads operation owns this section.
} else if (companion.archivedThreads.ok) {
this.archivedThreads = companion.archivedThreads.data;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: companion.archivedThreads.status,
operationId: archivedThreadsOperationId,
operationToken: archivedThreadsOperationToken,
});
} else {
failedCount += 1;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: companion.archivedThreads.status,
operationId: archivedThreadsOperationId,
operationToken: archivedThreadsOperationToken,
});
}
} catch (error) {
if (this.isStaleSettingsRefreshOperation(operationId)) return;
if (this.isStaleSettingsRefreshOperation(operationToken)) return;
failedCount = 3;
const message = errorMessage(error);
if (!this.isStaleModelsOperation(modelsOperationId)) {
if (!this.isStaleModelsOperation(modelsOperationToken)) {
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${message}`,
operationId: modelsOperationId,
operationToken: modelsOperationToken,
});
}
if (!this.isStaleHooksOperation(hooksOperationId)) {
if (!this.isStaleHooksOperation(hooksOperationToken)) {
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not load hooks: ${message}`,
operationId: hooksOperationId,
operationToken: hooksOperationToken,
});
}
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) {
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not load archived threads: ${message}`,
operationId: archivedThreadsOperationId,
operationToken: archivedThreadsOperationToken,
});
}
} finally {
const staleOperation = this.isStaleSettingsRefreshOperation(operationId);
const staleRefresh = this.isStaleSettingsRefreshOperation(operationToken);
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "completed",
failedCount,
operationId,
operationToken,
});
if (!staleOperation) {
if (!staleRefresh) {
if (failedCount > 0) {
this.callbacks.notify("Could not refresh all Codex data.");
}
@ -270,13 +270,13 @@ export class SettingsDynamicDataController {
loadingStatus: "Loading hooks...",
failureStatus: (error) => `Could not trust hook: ${errorMessage(error)}`,
failureNotice: "Could not trust Codex hook.",
operation: async (operationId) => {
operation: async (operationToken) => {
await this.withSettingsConnection((client) => trustHookItem(client, hook));
if (this.isStaleHooksOperation(operationId)) return;
if (this.isStaleHooksOperation(operationToken)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: "Trusted hook definition.",
operationId,
operationToken,
});
await this.loadHooks();
},
@ -289,13 +289,13 @@ export class SettingsDynamicDataController {
loadingStatus: "Loading hooks...",
failureStatus: (error) => `Could not update hook: ${errorMessage(error)}`,
failureNotice: "Could not update Codex hook.",
operation: async (operationId) => {
operation: async (operationToken) => {
await this.withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled));
if (this.isStaleHooksOperation(operationId)) return;
if (this.isStaleHooksOperation(operationToken)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: enabled ? "Enabled hook." : "Disabled hook.",
operationId,
operationToken,
});
await this.loadHooks();
},
@ -308,14 +308,14 @@ export class SettingsDynamicDataController {
loadingStatus: "Loading archived threads...",
failureStatus: (error) => `Could not restore archived thread: ${errorMessage(error)}`,
failureNotice: "Could not restore archived Codex thread.",
operation: async (operationId) => {
operation: async (operationToken) => {
const restoredThread = await this.withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId));
if (this.isStaleArchivedThreadsOperation(operationId)) return;
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`,
operationId,
operationToken,
});
this.host.threadCatalog.recordThreadRestored(restoredThread);
},
@ -332,14 +332,14 @@ export class SettingsDynamicDataController {
loadingStatus: "Loading archived threads...",
failureStatus: (error) => `Could not delete archived thread: ${errorMessage(error)}`,
failureNotice: "Could not delete archived Codex thread.",
operation: async (operationId) => {
operation: async (operationToken) => {
await this.withSettingsConnection((client) => client.deleteThread(threadId));
if (this.isStaleArchivedThreadsOperation(operationId)) return;
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Deleted "${title}".`,
operationId,
operationToken,
});
},
});
@ -368,16 +368,16 @@ export class SettingsDynamicDataController {
loadingStatus: "Loading hooks...",
failureStatus: (error) => `Could not load hooks: ${errorMessage(error)}`,
failureNotice: "Could not load Codex hooks.",
operation: async (operationId) => {
operation: async (operationToken) => {
const hooks = await this.withSettingsConnection((client) => loadHookData(client, this.host.vaultPath));
if (this.isStaleHooksOperation(operationId)) return;
if (this.isStaleHooksOperation(operationToken)) return;
this.hooks = hooks.hooks;
this.hookWarnings = hooks.warnings;
this.hookErrors = hooks.errors;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: hooks.status,
operationId,
operationToken,
});
},
});
@ -388,11 +388,11 @@ export class SettingsDynamicDataController {
loadingStatus: string;
failureStatus: (error: unknown) => string;
failureNotice: string;
operation: (operationId: number) => Promise<void>;
operation: (operationToken: number) => Promise<void>;
}): Promise<void> {
const operationId = options.section === "hooks" ? this.nextHooksOperationId() : this.nextArchivedThreadsOperationId();
const operationToken = options.section === "hooks" ? this.nextHooksOperationToken() : this.nextArchivedThreadsOperationToken();
const stale = (): boolean =>
options.section === "hooks" ? this.isStaleHooksOperation(operationId) : this.isStaleArchivedThreadsOperation(operationId);
options.section === "hooks" ? this.isStaleHooksOperation(operationToken) : this.isStaleArchivedThreadsOperation(operationToken);
const lifecycle = (): SettingsDynamicSectionLifecycleState =>
options.section === "hooks" ? this.hooksLifecycle : this.archivedThreadsLifecycle;
const setLifecycle = (state: SettingsDynamicSectionLifecycleState): void => {
@ -407,19 +407,19 @@ export class SettingsDynamicDataController {
transitionSettingsDynamicSectionLifecycle(lifecycle(), {
type: "started",
status: options.loadingStatus,
operationId,
operationToken,
}),
);
this.callbacks.display();
try {
await options.operation(operationId);
await options.operation(operationToken);
} catch (error) {
if (stale()) return;
setLifecycle(
transitionSettingsDynamicSectionLifecycle(lifecycle(), {
type: "failed",
status: options.failureStatus(error),
operationId,
operationToken,
}),
);
this.callbacks.notify(options.failureNotice);
@ -434,39 +434,39 @@ export class SettingsDynamicDataController {
});
}
private nextSettingsRefreshOperationId(): number {
this.settingsRefreshOperationId += 1;
return this.settingsRefreshOperationId;
private nextSettingsRefreshOperationToken(): number {
this.settingsRefreshOperationToken += 1;
return this.settingsRefreshOperationToken;
}
private nextModelsOperationId(): number {
this.modelsOperationId += 1;
return this.modelsOperationId;
private nextModelsOperationToken(): number {
this.modelsOperationToken += 1;
return this.modelsOperationToken;
}
private nextHooksOperationId(): number {
this.hooksOperationId += 1;
return this.hooksOperationId;
private nextHooksOperationToken(): number {
this.hooksOperationToken += 1;
return this.hooksOperationToken;
}
private nextArchivedThreadsOperationId(): number {
this.archivedThreadsOperationId += 1;
return this.archivedThreadsOperationId;
private nextArchivedThreadsOperationToken(): number {
this.archivedThreadsOperationToken += 1;
return this.archivedThreadsOperationToken;
}
private isStaleSettingsRefreshOperation(operationId: number): boolean {
return operationId !== this.settingsRefreshOperationId;
private isStaleSettingsRefreshOperation(operationToken: number): boolean {
return operationToken !== this.settingsRefreshOperationToken;
}
private isStaleModelsOperation(operationId: number): boolean {
return operationId !== this.modelsOperationId;
private isStaleModelsOperation(operationToken: number): boolean {
return operationToken !== this.modelsOperationToken;
}
private isStaleHooksOperation(operationId: number): boolean {
return operationId !== this.hooksOperationId;
private isStaleHooksOperation(operationToken: number): boolean {
return operationToken !== this.hooksOperationToken;
}
private isStaleArchivedThreadsOperation(operationId: number): boolean {
return operationId !== this.archivedThreadsOperationId;
private isStaleArchivedThreadsOperation(operationToken: number): boolean {
return operationToken !== this.archivedThreadsOperationToken;
}
}

View file

@ -1,22 +1,22 @@
export type SettingsDataRefreshLifecycleState =
| { kind: "idle" }
| { kind: "loading"; operationId: number }
| { kind: "completed"; failedCount: number; operationId: number };
| { kind: "loading"; operationToken: number }
| { kind: "completed"; failedCount: number; operationToken: number };
export type SettingsDataRefreshLifecycleEvent =
| { type: "started"; operationId: number }
| { type: "completed"; failedCount: number; operationId: number };
| { type: "started"; operationToken: number }
| { type: "completed"; failedCount: number; operationToken: number };
export type SettingsDynamicSectionLifecycleState =
| { kind: "idle"; status: "" }
| { kind: "loading"; status: string; operationId: number }
| { kind: "loaded"; status: string; operationId: number }
| { kind: "failed"; status: string; operationId: number };
| { kind: "loading"; status: string; operationToken: number }
| { kind: "loaded"; status: string; operationToken: number }
| { kind: "failed"; status: string; operationToken: number };
export type SettingsDynamicSectionLifecycleEvent =
| { type: "started"; status: string; operationId: number }
| { type: "loaded"; status: string; operationId: number }
| { type: "failed"; status: string; operationId: number }
| { type: "started"; status: string; operationToken: number }
| { type: "loaded"; status: string; operationToken: number }
| { type: "failed"; status: string; operationToken: number }
| { type: "reset" };
export function transitionSettingsDataRefreshLifecycle(
@ -25,11 +25,11 @@ export function transitionSettingsDataRefreshLifecycle(
): SettingsDataRefreshLifecycleState {
switch (event.type) {
case "started":
if (isStaleSettingsDataRefreshEvent(state, event.operationId)) return state;
return { kind: "loading", operationId: event.operationId };
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
return { kind: "loading", operationToken: event.operationToken };
case "completed":
if (isStaleSettingsDataRefreshEvent(state, event.operationId)) return state;
return { kind: "completed", failedCount: event.failedCount, operationId: event.operationId };
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
return { kind: "completed", failedCount: event.failedCount, operationToken: event.operationToken };
}
}
@ -43,23 +43,23 @@ export function transitionSettingsDynamicSectionLifecycle(
): SettingsDynamicSectionLifecycleState {
switch (event.type) {
case "started":
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
return { kind: "loading", status: event.status, operationId: event.operationId };
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
return { kind: "loading", status: event.status, operationToken: event.operationToken };
case "loaded":
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
return { kind: "loaded", status: event.status, operationId: event.operationId };
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
return { kind: "loaded", status: event.status, operationToken: event.operationToken };
case "failed":
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
return { kind: "failed", status: event.status, operationId: event.operationId };
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
return { kind: "failed", status: event.status, operationToken: event.operationToken };
case "reset":
return createSettingsDynamicSectionLifecycle();
}
}
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationId: number): boolean {
return "operationId" in state && state.operationId > operationId;
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationToken: number): boolean {
return "operationToken" in state && state.operationToken > operationToken;
}
function isStaleSettingsDataRefreshEvent(state: SettingsDataRefreshLifecycleState, operationId: number): boolean {
return "operationId" in state && state.operationId > operationId;
function isStaleSettingsDataRefreshEvent(state: SettingsDataRefreshLifecycleState, operationToken: number): boolean {
return "operationToken" in state && state.operationToken > operationToken;
}

33
src/shared/id/local-id.ts Normal file
View file

@ -0,0 +1,33 @@
export interface LocalIdSource {
next(prefix: string): string;
}
export interface LocalIdSourceOptions {
nowMs?: () => number;
seed?: string;
}
let sourceSequence = 0;
export function createLocalIdSource(options: LocalIdSourceOptions = {}): LocalIdSource {
const nowMs = options.nowMs ?? Date.now;
const seed = sanitizeIdPart(options.seed ?? defaultIdSeed());
sourceSequence += 1;
const sourceId = `${seed}-${sourceSequence.toString(36)}`;
let itemSequence = 0;
return {
next(prefix: string): string {
const idPrefix = sanitizeIdPart(prefix);
itemSequence += 1;
return `${idPrefix}-${String(nowMs())}-${sourceId}-${itemSequence.toString(36)}`;
},
};
}
function defaultIdSeed(): string {
return Date.now().toString(36);
}
function sanitizeIdPart(value: string): string {
return value.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "local";
}

View file

@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createLocalChatItemIdFactory,
isLocalSteerMessageClientId,
isLocalUserMessageId,
} from "../../../src/features/chat/domain/local-id";
describe("local chat item ids", () => {
it("generates monotonic IDs per factory with a stable prefix and timestamp", () => {
const ids = createLocalChatItemIdFactory({ nowMs: () => 1234, seed: "test" });
const first = ids.next("local-user");
const second = ids.next("local-user");
const third = ids.next("system");
expect(first).toMatch(/^local-user-1234-test-[a-z0-9]+-1$/);
expect(second).toMatch(/^local-user-1234-test-[a-z0-9]+-2$/);
expect(third).toMatch(/^system-1234-test-[a-z0-9]+-3$/);
});
it("keeps factories distinct when they share a timestamp and seed", () => {
const first = createLocalChatItemIdFactory({ nowMs: () => 1234, seed: "test" });
const second = createLocalChatItemIdFactory({ nowMs: () => 1234, seed: "test" });
expect(first.next("local-user")).not.toBe(second.next("local-user"));
});
it("classifies local prompt and steer message identifiers", () => {
expect(isLocalUserMessageId("local-user-1234-test-1-1")).toBe(true);
expect(isLocalUserMessageId("local-steer-1234-test-1-1")).toBe(true);
expect(isLocalUserMessageId("user-1")).toBe(false);
expect(isLocalSteerMessageClientId("local-steer-1234-test-1-1")).toBe(true);
expect(isLocalSteerMessageClientId("local-user-1234-test-1-1")).toBe(false);
});
});

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { isLocalSteerMessageClientId, isLocalUserMessageId } from "../../../src/features/chat/domain/local-message-ids";
describe("local chat message IDs", () => {
it("classifies local prompt and steer message identifiers", () => {
expect(isLocalUserMessageId("local-user-1234-test-1-1")).toBe(true);
expect(isLocalUserMessageId("local-steer-1234-test-1-1")).toBe(true);
expect(isLocalUserMessageId("user-1")).toBe(false);
expect(isLocalSteerMessageClientId("local-steer-1234-test-1-1")).toBe(true);
expect(isLocalSteerMessageClientId("local-user-1234-test-1-1")).toBe(false);
});
});

View file

@ -12,38 +12,46 @@ describe("settings lifecycle", () => {
it("tracks settings data refresh lifecycle", () => {
const idle = { kind: "idle" } as const;
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started", operationId: 1 });
expect(loading).toEqual({ kind: "loading", operationId: 1 });
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationId: 0 })).toBe(loading);
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started", operationToken: 1 });
expect(loading).toEqual({ kind: "loading", operationToken: 1 });
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 0 })).toBe(loading);
const newerLoading = transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationId: 2 });
expect(newerLoading).toEqual({ kind: "loading", operationId: 2 });
expect(transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationId: 1 })).toBe(newerLoading);
const newerLoading = transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 2 });
expect(newerLoading).toEqual({ kind: "loading", operationToken: 2 });
expect(transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 1 })).toBe(
newerLoading,
);
const completed = transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationId: 2 });
expect(completed).toEqual({ kind: "completed", failedCount: 2, operationId: 2 });
const completed = transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 2 });
expect(completed).toEqual({ kind: "completed", failedCount: 2, operationToken: 2 });
});
it("tracks dynamic section lifecycle", () => {
const idle = createSettingsDynamicSectionLifecycle();
expect(idle).toEqual({ kind: "idle", status: "" });
const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationId: 1 });
expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationId: 1 });
const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationToken: 1 });
expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationToken: 1 });
expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationId: 0 })).toBe(loading);
expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationToken: 0 })).toBe(
loading,
);
const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationId: 1 });
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationId: 1 });
const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationToken: 1 });
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationToken: 1 });
const failed = transitionSettingsDynamicSectionLifecycle(loaded, { type: "failed", status: "Could not load hooks.", operationId: 1 });
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationId: 1 });
const failed = transitionSettingsDynamicSectionLifecycle(loaded, {
type: "failed",
status: "Could not load hooks.",
operationToken: 1,
});
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationToken: 1 });
const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationId: 2 });
const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationToken: 2 });
expect(
transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "started", status: "Loading old hooks...", operationId: 1 }),
transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "started", status: "Loading old hooks...", operationToken: 1 }),
).toBe(laterLoaded);
expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationId: 1 })).toBe(
expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationToken: 1 })).toBe(
laterLoaded,
);

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { createLocalIdSource } from "../../src/shared/id/local-id";
describe("local ID source", () => {
it("generates monotonic IDs per source with a stable prefix and timestamp", () => {
const ids = createLocalIdSource({ nowMs: () => 1234, seed: "test" });
const first = ids.next("local-user");
const second = ids.next("local-user");
const third = ids.next("system");
expect(first).toMatch(/^local-user-1234-test-[a-z0-9]+-1$/);
expect(second).toMatch(/^local-user-1234-test-[a-z0-9]+-2$/);
expect(third).toMatch(/^system-1234-test-[a-z0-9]+-3$/);
});
it("keeps sources distinct when they share a timestamp and seed", () => {
const first = createLocalIdSource({ nowMs: () => 1234, seed: "test" });
const second = createLocalIdSource({ nowMs: () => 1234, seed: "test" });
expect(first.next("local-user")).not.toBe(second.next("local-user"));
});
it("sanitizes dynamic ID parts", () => {
const ids = createLocalIdSource({ nowMs: () => 1234, seed: "test seed!" });
expect(ids.next("bad prefix!")).toMatch(/^badprefix-1234-testseed-[a-z0-9]+-1$/);
});
});