diff --git a/src/features/chat/app-server/inbound/controller.ts b/src/features/chat/app-server/inbound/controller.ts index 7a25e493..35ca0d19 100644 --- a/src/features/chat/app-server/inbound/controller.ts +++ b/src/features/chat/app-server/inbound/controller.ts @@ -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, diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 7f26f6ef..679c05d7 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -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 = ( type ServerNotificationPlannerMap = { [Method in M]: ServerNotificationPlanner }; type ServerNotificationLocalPlanner = ( notification: Extract, - localItemId: LocalItemIdFactory, + localItemId: LocalItemIdProvider, ) => ChatNotificationPlan; type ServerNotificationLocalPlannerMap = { [Method in M]: ServerNotificationLocalPlanner; @@ -93,7 +93,7 @@ type ServerNotificationLocalPlannerMap = type ServerNotificationStatePlanner = ( state: ChatState, notification: Extract, - localItemId: LocalItemIdFactory, + localItemId: LocalItemIdProvider, ) => ChatNotificationPlan; type ServerNotificationStatePlannerMap = { [Method in M]: ServerNotificationStatePlanner; @@ -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( function planNotificationWithLocalItemIdByMethod( notification: Extract, planners: ServerNotificationLocalPlannerMap, - localItemId: LocalItemIdFactory, + localItemId: LocalItemIdProvider, ): ChatNotificationPlan { const planner = planners[notification.method]; return planner(notification, localItemId); @@ -393,7 +397,7 @@ function planNotificationWithStateByMethod, planners: ServerNotificationStatePlannerMap, - localItemId: LocalItemIdFactory, + localItemId: LocalItemIdProvider, ): ChatNotificationPlan { const planner = planners[notification.method]; return planner(state, notification, localItemId); @@ -405,7 +409,7 @@ function plannerMethods(planners: Record function jsonNoticePlan( notification: Extract }>, - localItemId: LocalItemIdFactory, + localItemId: LocalItemIdProvider, ): ChatNotificationPlan { return systemMessagePlan({ id: localItemId("system"), text: `${notification.method}: ${jsonPreview(notification.params)}` }); } diff --git a/src/features/chat/application/conversation/optimistic-turn-start.ts b/src/features/chat/application/conversation/optimistic-turn-start.ts index 4df8a190..7787d89b 100644 --- a/src/features/chat/application/conversation/optimistic-turn-start.ts +++ b/src/features/chat/application/conversation/optimistic-turn-start.ts @@ -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 { diff --git a/src/features/chat/application/conversation/turn-submission-actions.ts b/src/features/chat/application/conversation/turn-submission-actions.ts index 80586595..3fed2385 100644 --- a/src/features/chat/application/conversation/turn-submission-actions.ts +++ b/src/features/chat/application/conversation/turn-submission-actions.ts @@ -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, + localItemIds: LocalIdSource, text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata, @@ -135,7 +135,7 @@ async function sendTurnText( async function steerCurrentTurn( host: TurnSubmissionActionsHost, - localItemIds: ReturnType, + localItemIds: LocalIdSource, client: AppServerClient, text: string, codexInputOverride?: CodexInput, diff --git a/src/features/chat/application/pending-requests/pending-request-actions.ts b/src/features/chat/application/pending-requests/pending-request-actions.ts index 0252313a..db1ce04a 100644 --- a/src/features/chat/application/pending-requests/pending-request-actions.ts +++ b/src/features/chat/application/pending-requests/pending-request-actions.ts @@ -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, }); }, diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index 94854b01..ecaf7f9e 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -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; @@ -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, }; } diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index da445dd3..43402ba8 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -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, - threadId: string, -): Promise { +async function syncThreadGoal(host: ThreadGoalSyncHost, localItemIds: LocalIdSource, threadId: string): Promise { const client = host.currentClient(); if (!client) return; try { @@ -67,7 +63,7 @@ async function syncThreadGoal( async function setObjective( host: GoalActionsHost, - localItemIds: ReturnType, + localItemIds: LocalIdSource, threadId: string, objective: string, tokenBudget: number | null, @@ -90,20 +86,11 @@ async function setObjective( return applied; } -function setGoalStatus( - host: GoalActionsHost, - localItemIds: ReturnType, - threadId: string, - status: ThreadGoalStatus, -): Promise { +function setGoalStatus(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, status: ThreadGoalStatus): Promise { return setGoal(host, localItemIds, threadId, { status }); } -async function clearGoal( - host: GoalActionsHost, - localItemIds: ReturnType, - threadId: string, -): Promise { +async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string): Promise { 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, - threadId: string, - params: ThreadGoalUpdate, -): Promise { +async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, params: ThreadGoalUpdate): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return false; @@ -136,7 +118,7 @@ async function setGoal( function applyGoalIfActive( host: ThreadGoalSyncHost, - localItemIds: ReturnType, + localItemIds: LocalIdSource, threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }, diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index a8adb5c6..d2ddf212 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -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); diff --git a/src/features/chat/domain/local-id.ts b/src/features/chat/domain/local-id.ts deleted file mode 100644 index 752dbe22..00000000 --- a/src/features/chat/domain/local-id.ts +++ /dev/null @@ -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"; -} diff --git a/src/features/chat/domain/local-message-ids.ts b/src/features/chat/domain/local-message-ids.ts new file mode 100644 index 00000000..2f677a9a --- /dev/null +++ b/src/features/chat/domain/local-message-ids.ts @@ -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; +} diff --git a/src/features/chat/domain/message-stream/completed-turn-reconciliation.ts b/src/features/chat/domain/message-stream/completed-turn-reconciliation.ts index b7c236b8..a35dab82 100644 --- a/src/features/chat/domain/message-stream/completed-turn-reconciliation.ts +++ b/src/features/chat/domain/message-stream/completed-turn-reconciliation.ts @@ -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"; diff --git a/src/features/chat/domain/message-stream/semantics/classify.ts b/src/features/chat/domain/message-stream/semantics/classify.ts index acfdb2db..780639d0 100644 --- a/src/features/chat/domain/message-stream/semantics/classify.ts +++ b/src/features/chat/domain/message-stream/semantics/classify.ts @@ -1,5 +1,5 @@ import type { MessageStreamItem } from "../items"; -import { isLocalSteerMessageClientId } from "../../local-id"; +import { isLocalSteerMessageClientId } from "../../local-message-ids"; import type { MessageStreamLifecycle, MessageStreamMeaning, diff --git a/src/features/chat/domain/pending-requests/model.ts b/src/features/chat/domain/pending-requests/model.ts index bae663cd..57033d58 100644 --- a/src/features/chat/domain/pending-requests/model.ts +++ b/src/features/chat/domain/pending-requests/model.ts @@ -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): Record { diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 9bf16548..fd5c36eb 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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 } : {}) }); diff --git a/src/features/chat/host/view.ts b/src/features/chat/host/view.ts index 849a3896..c464ad62 100644 --- a/src/features/chat/host/view.ts +++ b/src/features/chat/host/view.ts @@ -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); diff --git a/src/features/chat/presentation/message-stream/detail-view.ts b/src/features/chat/presentation/message-stream/detail-view.ts index 397d0661..003776f9 100644 --- a/src/features/chat/presentation/message-stream/detail-view.ts +++ b/src/features/chat/presentation/message-stream/detail-view.ts @@ -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, diff --git a/src/features/chat/presentation/message-stream/layout.ts b/src/features/chat/presentation/message-stream/layout.ts index af26d156..f567d197 100644 --- a/src/features/chat/presentation/message-stream/layout.ts +++ b/src/features/chat/presentation/message-stream/layout.ts @@ -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): boolean { const turnId = classification.item.turnId; if (!turnId || messageStreamIsTurnInitiator(classification) || messageStreamIsTurnSteer(classification)) return false; diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index b9b11d96..526c644d 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -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 (
@@ -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({