diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 6bef1ebf..b3866867 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -18,7 +18,7 @@ import type { TurnTranscriptSummary } from "../../../../domain/threads/transcrip import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; import type { LocalIdSource } from "../../application/local-id-source"; -import type { ChatAction, ChatState } from "../../application/state/root-reducer"; +import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; import { activeTurnId } from "../../application/turns/turn-state"; import { @@ -120,7 +120,7 @@ function handleNotification( function handleServerRequest(context: ChatInboundHandlerContext, request: ServerRequest): void { const current = state(context); - const route = routeServerRequest(request, { activeThreadId: current.activeThread.id, activeTurnId: activeTurnId(current) }); + const route = routeServerRequest(request, { activeThreadId: activeThreadId(current), activeTurnId: activeTurnId(current) }); switch (route.kind) { case "approval": dispatch(context, { type: "request/approval-queued", approval: route.approval }); diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index bccccf9e..be52a5f7 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -5,7 +5,7 @@ import { normalizeExplicitThreadName } from "../../../../domain/threads/model"; import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; import { activeThreadSettingsAppliedAction } from "../../application/state/actions"; -import type { ChatAction, ChatState } from "../../application/state/root-reducer"; +import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer"; import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan"; import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items"; import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing"; @@ -40,7 +40,7 @@ export function planChatNotification( localItemId: LocalItemIdProvider, ): ChatNotificationPlan { const route = routeServerNotification(notification, { - activeThreadId: state.activeThread.id, + activeThreadId: activeThreadId(state), activeTurnId: activeTurnIdForState(state), }); switch (route.kind) { @@ -73,7 +73,7 @@ function runtimeEventsPlan( } function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] { - if (state.activeThread.lifetime?.kind === "ephemeral") return []; + if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return []; switch (outcome.type) { case "turn-started": return [ @@ -153,7 +153,7 @@ function planThreadLifecycle( }, }); case "thread/settings/updated": - if (state.activeThread.id !== notification.params.threadId) return EMPTY_PLAN; + if (activeThreadId(state) !== notification.params.threadId) return EMPTY_PLAN; return actionPlan(activeThreadSettingsAppliedAction(notification.params.threadSettings)); case "thread/goal/updated": return threadGoalPlan(state, notification.params.threadId, notification.params.goal, localItemId); @@ -176,7 +176,7 @@ function threadStartedPlan( event: { type: "thread-started", thread }, }, ]; - if (!state.activeThread.id || state.activeThread.id === notification.params.thread.id) { + if (activeThreadId(state) === notification.params.thread.id) { return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects }; } return { actions: [], effects }; @@ -188,9 +188,10 @@ function threadGoalPlan( goal: Extract["params"]["goal"] | null, localItemId: LocalItemIdProvider, ): ChatNotificationPlan { - if (state.activeThread.id !== threadId) return EMPTY_PLAN; + const activeThread = activeThreadState(state); + if (!activeThread || activeThread.id !== threadId) return EMPTY_PLAN; const actions: ChatAction[] = [{ type: "active-thread/goal-set", goal }]; - const item = goalChangeItem(localItemId("goal"), state.activeThread.goal, goal); + const item = goalChangeItem(localItemId("goal"), activeThread.goal, goal); if (item) actions.push({ type: "thread-stream/item-upserted", item }); return { actions, effects: [] }; } diff --git a/src/features/chat/application/connection/reconnect-actions.ts b/src/features/chat/application/connection/reconnect-actions.ts index 25437ef5..7fc68b5d 100644 --- a/src/features/chat/application/connection/reconnect-actions.ts +++ b/src/features/chat/application/connection/reconnect-actions.ts @@ -1,4 +1,4 @@ -import type { ChatConnectionPhase } from "../state/root-reducer"; +import { activeThreadId, type ChatConnectionPhase } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; const STATUS_RECONNECTING = "Reconnecting..."; @@ -20,7 +20,7 @@ export async function reconnectPanel( host: ChatReconnectActionsHost, target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null, ): Promise { - const threadId = target ? target.resumeThreadId : host.stateStore.getState().activeThread.id; + const threadId = target ? target.resumeThreadId : activeThreadId(host.stateStore.getState()); const isCurrent = target?.isCurrent ?? (() => true); host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); host.invalidateConnectionWork(); @@ -36,7 +36,7 @@ export async function reconnectPanel( try { await host.resumeThread(threadId); if (!isCurrent()) return false; - return host.stateStore.getState().activeThread.id === threadId; + return activeThreadId(host.stateStore.getState()) === threadId; } catch (error) { host.addSystemMessage(error instanceof Error ? error.message : String(error)); return false; diff --git a/src/features/chat/application/connection/server-diagnostics-actions.ts b/src/features/chat/application/connection/server-diagnostics-actions.ts index db6d5131..84ed1831 100644 --- a/src/features/chat/application/connection/server-diagnostics-actions.ts +++ b/src/features/chat/application/connection/server-diagnostics-actions.ts @@ -6,6 +6,7 @@ import { replaceMcpServerStatusDiagnostics, } from "../../../../domain/server/diagnostics"; import type { SharedServerMetadata } from "../../../../domain/server/metadata"; +import { activeThreadId } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import type { ServerDiagnosticsTransport } from "./metadata-transport"; @@ -45,7 +46,7 @@ async function refreshServerDiagnostics( ): Promise { const initialDiagnostics = currentPanelDiagnostics(host); const state = host.stateStore.getState(); - const activeThreadId = state.activeThread.id; + const threadId = activeThreadId(state); const metadataSnapshot = host.appServerMetadataSnapshot(); const cachedSkills = options.forceResourceProbes === true ? undefined : (metadataSnapshot?.availableSkills ?? state.connection.availableSkills); @@ -54,7 +55,7 @@ async function refreshServerDiagnostics( ? undefined : (metadataSnapshot?.serverDiagnostics.probes.skills ?? state.connection.serverDiagnostics.probes.skills); const request = { - threadId: activeThreadId, + threadId, initialDiagnostics, forceResourceProbes: options.forceResourceProbes === true, appServerMetadataSnapshot: options.appServerMetadataSnapshot === true, @@ -62,7 +63,7 @@ async function refreshServerDiagnostics( ...(cachedSkillsProbe !== undefined ? { cachedSkillsProbe } : {}), }; const snapshot = await host.diagnosticsTransport.readServerDiagnostics(request); - if (!snapshot || !isCurrent() || host.stateStore.getState().activeThread.id !== activeThreadId) return false; + if (!snapshot || !isCurrent() || activeThreadId(host.stateStore.getState()) !== threadId) return false; let diagnostics = currentPanelDiagnostics(host); for (const probe of snapshot.resourceProbes) { diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index cdb6db78..b67bfc95 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -9,7 +9,7 @@ import { pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch, type PendingRuntimeSettingsPatch, } from "../../domain/runtime/thread-settings-patch"; -import type { ChatAction, ChatState } from "../state/root-reducer"; +import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import type { RuntimeSettingsTransport } from "./settings-transport"; @@ -82,7 +82,7 @@ async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Pro } async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise { - const threadId = state(host).activeThread.id; + const threadId = activeThreadId(state(host)); if (!threadId) return { ok: true, collaborationModeApplied: true }; const { update, collaborationModeWarning } = pendingRuntimeSettingsPatch(host); @@ -94,14 +94,14 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) { return { ok: false, collaborationModeApplied: false }; } - if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false }; + if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false }; if (!runtimeSettingsPatchStillPending(currentPendingRuntimeSettingsPatch(host), update)) { return { ok: false, collaborationModeApplied: false }; } dispatch(host, { type: "runtime/pending-thread-settings-committed", update }); return { ok: true, collaborationModeApplied }; } catch (error) { - if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false }; + if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false }; host.addSystemMessage(error instanceof Error ? error.message : String(error)); return { ok: false, collaborationModeApplied: false }; } @@ -158,13 +158,13 @@ async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): } function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean { - if (state(host).activeThread.lifetime?.kind !== "ephemeral") return false; + if (activeThreadState(state(host))?.lifetime?.kind !== "ephemeral") return false; host.addSystemMessage("Permission changes are unavailable in side chats."); return true; } function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean { - if (state(host).activeThread.provenance?.kind !== "subagent") return false; + if (activeThreadState(state(host))?.provenance?.kind !== "subagent") return false; host.addSystemMessage("Thread settings are unavailable in agent threads."); return true; } diff --git a/src/features/chat/application/runtime/snapshot.ts b/src/features/chat/application/runtime/snapshot.ts index 02ae3b32..4b55d3f7 100644 --- a/src/features/chat/application/runtime/snapshot.ts +++ b/src/features/chat/application/runtime/snapshot.ts @@ -1,12 +1,13 @@ +import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { activeThreadRuntimeState, pendingRuntimeIntentState } from "../../domain/runtime/state"; import type { ThreadStreamItem } from "../../domain/thread-stream/items"; -import type { ChatState } from "../state/root-reducer"; +import { activeThreadState, type ChatState } from "../state/root-reducer"; import { threadStreamItems } from "../state/thread-stream"; interface RuntimeSnapshotInput { runtimeConfig: ChatState["connection"]["runtimeConfig"]; - activeThread: Pick; + activeThread: { id: string | null; tokenUsage: ThreadTokenUsage | null }; runtime: ChatState["runtime"]; rateLimit: ChatState["connection"]["rateLimit"]; hasThreadTurns: boolean; @@ -33,9 +34,10 @@ export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): Runti } export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot { + const activeThread = activeThreadState(state); return runtimeSnapshotForChatSlices({ runtimeConfig: state.connection.runtimeConfig, - activeThread: state.activeThread, + activeThread: { id: activeThread?.id ?? null, tokenUsage: activeThread?.tokenUsage ?? null }, runtime: state.runtime, rateLimit: state.connection.rateLimit, hasThreadTurns: threadStreamItemsHaveThreadTurns(threadStreamItems(state.threadStream)), diff --git a/src/features/chat/application/state/pending-submission.ts b/src/features/chat/application/state/pending-submission.ts index a2241a3c..81ff2d6a 100644 --- a/src/features/chat/application/state/pending-submission.ts +++ b/src/features/chat/application/state/pending-submission.ts @@ -18,17 +18,17 @@ export type PendingSubmissionAction = export function pendingSubmissionMatches( state: { readonly pendingSubmission: ChatPendingSubmissionState | null; - readonly activeThread: { readonly id: string | null }; + readonly activeThreadId: string | null; }, submissionId: string, ): boolean { - return state.pendingSubmission?.id === submissionId && state.pendingSubmission.targetThreadId === state.activeThread.id; + return state.pendingSubmission?.id === submissionId && state.pendingSubmission.targetThreadId === state.activeThreadId; } export function cancellablePendingSubmissionMatches( state: { readonly pendingSubmission: ChatPendingSubmissionState | null; - readonly activeThread: { readonly id: string | null }; + readonly activeThreadId: string | null; }, submissionId: string, ): boolean { diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index fd6098b7..304c8f30 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -106,7 +106,7 @@ interface ChatThreadListState { } export interface ChatActiveThreadState { - readonly id: string | null; + readonly id: string; readonly title?: string | null; readonly cwd: string | null; readonly goal: ThreadGoal | null; @@ -115,9 +115,10 @@ export interface ChatActiveThreadState { readonly provenance: Thread["provenance"] | null; } -export type ChatPanelRestorationState = - | { readonly kind: "none" } - | { readonly kind: "thread"; readonly threadId: string; readonly fallbackTitle: string | null }; +type ChatPanelThreadState = + | { readonly kind: "empty" } + | { readonly kind: "awaiting-resume"; readonly threadId: string; readonly fallbackTitle: string | null } + | { readonly kind: "active"; readonly thread: ChatActiveThreadState }; type ActiveThreadLifetime = | { readonly kind: "persistent" } @@ -133,8 +134,7 @@ interface ChatComposerState { interface ChatStateShape { connection: ChatConnectionState; threadList: ChatThreadListState; - activeThread: ChatActiveThreadState; - restoration: ChatPanelRestorationState; + panelThread: ChatPanelThreadState; runtime: ChatRuntimeState; turn: ChatTurnState; threadStream: ChatThreadStreamState; @@ -265,8 +265,7 @@ export function createChatState(): ChatState { return { connection: initialConnectionState(), threadList: initialThreadListState(), - activeThread: initialActiveThreadState(), - restoration: initialPanelRestorationState(), + panelThread: initialPanelThreadState(), runtime: initialChatRuntimeState(), turn: initialTurnState(), threadStream: initialThreadStreamState(), @@ -277,6 +276,22 @@ export function createChatState(): ChatState { }; } +export function panelThreadId(state: ChatState): string | null { + return state.panelThread.kind === "awaiting-resume" ? state.panelThread.threadId : activeThreadId(state); +} + +export function activeThreadId(state: ChatState): string | null { + return activeThreadState(state)?.id ?? null; +} + +export function activeThreadState(state: ChatState): DeepReadonly | null { + return state.panelThread.kind === "active" ? state.panelThread.thread : null; +} + +export function awaitingResumeThreadState(state: ChatState): Extract | null { + return state.panelThread.kind === "awaiting-resume" ? state.panelThread : null; +} + export function chatReducer(state: ChatState, action: ChatAction): ChatState { switch (action.type) { case "connection/scoped-cleared": @@ -398,16 +413,18 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr ...state.threadList, listedThreads: action.listedThreads ?? state.threadList.listedThreads, }, - activeThread: { - id: action.thread.id, - title: (action.thread.name ?? action.thread.preview) || null, - cwd: action.cwd, - goal: null, - tokenUsage: null, - lifetime: action.lifetime ?? { kind: "persistent" }, - provenance: action.thread.provenance, + panelThread: { + kind: "active", + thread: { + id: action.thread.id, + title: (action.thread.name ?? action.thread.preview) || null, + cwd: action.cwd, + goal: null, + tokenUsage: null, + lifetime: action.lifetime ?? { kind: "persistent" }, + provenance: action.thread.provenance, + }, }, - restoration: initialPanelRestorationState(), runtime: { ...runtimeBase, active: { @@ -438,10 +455,12 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr } function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: ActiveThreadSettingsAppliedAction): ChatState { + const activeThread = activeThreadState(state); + if (!activeThread) return state; return patchChatState(state, { - activeThread: { - ...state.activeThread, - cwd: action.cwd, + panelThread: { + kind: "active", + thread: { ...activeThread, cwd: action.cwd }, }, runtime: { ...state.runtime, @@ -468,12 +487,11 @@ function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: A } function reduceActiveThreadGoalSetTransition(state: ChatState, goal: ThreadGoal | null): ChatState { + const activeThread = activeThreadState(state); + if (!activeThread) return state; return patchChatState(state, { - activeThread: { - ...state.activeThread, - goal, - }, - ui: maybeClearGoalObjectiveExpansion(state.ui, state.activeThread.goal, goal), + panelThread: { kind: "active", thread: { ...activeThread, goal } }, + ui: maybeClearGoalObjectiveExpansion(state.ui, activeThread.goal, goal), }); } @@ -481,13 +499,13 @@ function reduceRestoredThreadAppliedTransition(state: ChatState, threadId: strin const cleared = clearThreadScopedState(state); return patchChatState(cleared, { connection: { ...cleared.connection, statusText: "Thread ready to resume." }, - restoration: { kind: "thread", threadId, fallbackTitle }, + panelThread: createAwaitingResumeThreadState(threadId, fallbackTitle), }); } function reduceRestoredThreadRenamedTransition(state: ChatState, threadId: string, name: string | null): ChatState { - if (state.restoration.kind !== "thread" || state.restoration.threadId !== threadId) return state; - return patchChatState(state, { restoration: { ...state.restoration, fallbackTitle: name } }); + if (state.panelThread.kind !== "awaiting-resume" || state.panelThread.threadId !== threadId) return state; + return patchChatState(state, { panelThread: { ...state.panelThread, fallbackTitle: name } }); } function reduceViewStateClearedTransition(state: ChatState): ChatState { @@ -497,9 +515,12 @@ function reduceViewStateClearedTransition(state: ChatState): ChatState { function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction): ChatState { const lifecycle = transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "started", turnId: action.turnId }); + if (lifecycle === state.turn.lifecycle) return state; + const activeThread = + activeThreadState(state) ?? (state.turn.lifecycle.kind === "starting" ? createActiveThreadState(action.threadId) : null); + if (!activeThread || activeThread.id !== action.threadId) return state; return patchChatState(state, { - activeThread: { ...state.activeThread, id: action.threadId }, - restoration: initialPanelRestorationState(), + panelThread: { kind: "active", thread: activeThread }, turn: { lifecycle }, connection: { ...state.connection, statusText: STATUS_TURN_RUNNING }, threadStream: action.items @@ -595,8 +616,7 @@ function clearTurnScopedState(state: ChatState): ChatState { function clearThreadScopedState(state: ChatState): ChatState { return clearTurnScopedState( patchChatState(state, { - activeThread: initialActiveThreadState(), - restoration: initialPanelRestorationState(), + panelThread: initialPanelThreadState(), runtime: initialChatRuntimeState(), threadStream: initialThreadStreamState(), pendingSubmission: null, @@ -608,7 +628,7 @@ function clearThreadScopedState(state: ChatState): ChatState { function clearConnectionScopedState(state: ChatState): ChatState { return patchChatState(clearTurnScopedState(state), { - activeThread: initialActiveThreadState(), + panelThread: state.panelThread.kind === "awaiting-resume" ? state.panelThread : initialPanelThreadState(), runtime: initialChatRuntimeState(), connection: { ...state.connection, @@ -642,8 +662,7 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState return patchChatState(state, { connection: reduceConnectionSlice(state.connection, action), threadList: reduceThreadListSlice(state.threadList, action), - activeThread: reduceActiveThreadSlice(state.activeThread, action), - restoration: state.restoration, + panelThread: reducePanelThreadSlice(state.panelThread, action), runtime: reduceRuntimeSlice(state.runtime, action), turn: state.turn, pendingSubmission: state.pendingSubmission, @@ -679,12 +698,13 @@ function reduceThreadListSlice(state: ChatThreadListState, action: ChatSliceActi return patchObject(state, { listedThreads: action.threads }); } -function reduceActiveThreadSlice(state: ChatActiveThreadState, action: ChatSliceAction): ChatActiveThreadState { +function reducePanelThreadSlice(state: ChatPanelThreadState, action: ChatSliceAction): ChatPanelThreadState { + if (state.kind !== "active") return state; switch (action.type) { case "active-thread/cwd-set": - return patchObject(state, { cwd: action.cwd }); + return patchObject(state, { thread: patchObject(state.thread, { cwd: action.cwd }) }); case "active-thread/token-usage-set": - return patchObject(state, { tokenUsage: action.tokenUsage }); + return patchObject(state, { thread: patchObject(state.thread, { tokenUsage: action.tokenUsage }) }); default: return state; } @@ -769,9 +789,22 @@ function initialThreadListState(): ChatThreadListState { return { listedThreads: [] }; } -function initialActiveThreadState(): ChatActiveThreadState { +function initialPanelThreadState(): ChatPanelThreadState { + return { kind: "empty" }; +} + +function createAwaitingResumeThreadState(threadId: string, fallbackTitle: string | null): ChatPanelThreadState { return { - id: null, + kind: "awaiting-resume", + threadId, + fallbackTitle, + }; +} + +function createActiveThreadState(id: string): ChatActiveThreadState { + return { + id, + title: null, cwd: null, goal: null, tokenUsage: null, @@ -780,10 +813,6 @@ function initialActiveThreadState(): ChatActiveThreadState { }; } -function initialPanelRestorationState(): ChatPanelRestorationState { - return { kind: "none" }; -} - function initialTurnState(): ChatTurnState { return initialChatTurnState(); } diff --git a/src/features/chat/application/state/store.ts b/src/features/chat/application/state/store.ts index 85782f37..aa3764b8 100644 --- a/src/features/chat/application/state/store.ts +++ b/src/features/chat/application/state/store.ts @@ -40,8 +40,8 @@ function cloneChatState(state: ChatState): ChatState { threadList: { listedThreads: [...state.threadList.listedThreads], }, - activeThread: { ...state.activeThread }, - restoration: { ...state.restoration }, + panelThread: + state.panelThread.kind === "active" ? { kind: "active", thread: { ...state.panelThread.thread } } : { ...state.panelThread }, runtime: { ...state.runtime }, turn: { lifecycle: state.turn.lifecycle }, threadStream: cloneThreadStreamState(state.threadStream), diff --git a/src/features/chat/application/threads/active-thread-identity-sync.ts b/src/features/chat/application/threads/active-thread-identity-sync.ts index 89a33bf5..a6024ad4 100644 --- a/src/features/chat/application/threads/active-thread-identity-sync.ts +++ b/src/features/chat/application/threads/active-thread-identity-sync.ts @@ -1,3 +1,4 @@ +import { activeThreadId, awaitingResumeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; export interface ActiveThreadIdentitySyncHost { @@ -36,14 +37,16 @@ function clearActiveThreadIdentity(host: ActiveThreadIdentitySyncHost): void { function applyThreadArchiveToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string): void { const state = host.stateStore.getState(); - if (state.activeThread.id !== threadId && !(state.restoration.kind === "thread" && state.restoration.threadId === threadId)) return; + if (activeThreadId(state) !== threadId && awaitingResumeThreadState(state)?.threadId !== threadId) { + return; + } clearActiveThreadIdentity(host); } function applyThreadRenameToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string, name: string | null): void { const state = host.stateStore.getState(); - const restoredThreadChanged = state.restoration.kind === "thread" && state.restoration.threadId === threadId; - const activeThreadChanged = state.activeThread.id === threadId; + const restoredThreadChanged = awaitingResumeThreadState(state)?.threadId === threadId; + const activeThreadChanged = activeThreadId(state) === threadId; if (!restoredThreadChanged && !activeThreadChanged) return; if (restoredThreadChanged) host.stateStore.dispatch({ type: "panel/restored-thread-renamed", threadId, name }); host.notifyActiveThreadIdentityChanged(); diff --git a/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts b/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts index bae174bc..335cad85 100644 --- a/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts +++ b/src/features/chat/application/threads/ephemeral-thread-lifecycle.ts @@ -1,4 +1,5 @@ import { ephemeralThreadActivatedAction } from "../state/actions"; +import { activeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { activeTurnId, chatTurnBusy } from "../turns/turn-state"; import type { EphemeralThreadTransport } from "./ephemeral-thread-transport"; @@ -30,8 +31,8 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos let openGeneration = 0; const cleanupRequiredThreadIds = new Set(); const unsubscribeActiveEphemeralThread = async (): Promise => { - const active = host.stateStore.getState().activeThread; - if (active.id && active.lifetime?.kind === "ephemeral") { + const active = activeThreadState(host.stateStore.getState()); + if (active?.lifetime?.kind === "ephemeral") { return host.transport.unsubscribeEphemeralThread(active.id); } return true; @@ -70,7 +71,7 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos async prepareForPersistentNavigation(): Promise { const state = host.stateStore.getState(); - if (state.activeThread.lifetime?.kind !== "ephemeral") return true; + if (activeThreadState(state)?.lifetime?.kind !== "ephemeral") return true; if (chatTurnBusy(state)) { host.addSystemMessage("Finish or interrupt the current turn before switching threads."); return false; @@ -93,7 +94,8 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos disposed = true; openGeneration += 1; const state = host.stateStore.getState(); - const threadId = state.activeThread.lifetime?.kind === "ephemeral" ? state.activeThread.id : null; + const activeThread = activeThreadState(state); + const threadId = activeThread?.lifetime?.kind === "ephemeral" ? activeThread.id : null; const turnId = activeTurnId(state); if (threadId && turnId) { await settleWithin(host.interruptTurn(threadId, turnId), EPHEMERAL_INTERRUPT_DISPOSE_TIMEOUT_MS); diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index dc3a4cd2..afa1a7de 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -2,6 +2,7 @@ import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../.. import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items"; import type { GoalThreadStreamItem } from "../../domain/thread-stream/items"; import type { LocalIdSource } from "../local-id-source"; +import { activeThreadId, activeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport"; @@ -53,7 +54,7 @@ export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoa export function createGoalActions(host: GoalActionsHost): GoalActions { return { - activeGoal: () => host.stateStore.getState().activeThread.goal, + activeGoal: () => activeThreadState(host.stateStore.getState())?.goal ?? null, syncThreadGoal: (threadId) => syncThreadGoal(host, threadId), saveObjective: (objective, tokenBudget) => saveObjective(host, objective, tokenBudget), setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget), @@ -102,7 +103,7 @@ async function setNormalizedObjective( objective: NormalizedGoalObjective, tokenBudget: number | null, ): Promise { - const current = host.stateStore.getState().activeThread.goal; + const current = activeThreadState(host.stateStore.getState())?.goal ?? null; const isNewGoal = current === null; const applied = await setGoal(host, threadId, { objective, @@ -116,7 +117,7 @@ async function setNormalizedObjective( } async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise { - const plan = planGoalObjectiveSave(host.stateStore.getState().activeThread.id, objective, tokenBudget); + const plan = planGoalObjectiveSave(activeThreadId(host.stateStore.getState()), objective, tokenBudget); switch (plan.kind) { case "reject": host.addSystemMessage(plan.message); @@ -161,8 +162,9 @@ function applyGoalIfActive( options: { reportChange: boolean }, ): boolean { const state = host.stateStore.getState(); - if (state.activeThread.id !== threadId) return false; - const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), state.activeThread.goal, goal) : null; + const activeThread = activeThreadState(state); + if (!activeThread || activeThread.id !== threadId) return false; + const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), activeThread.goal, goal) : null; host.stateStore.dispatch({ type: "active-thread/goal-set", goal }); if (item) host.addGoalEvent(item); host.refreshLiveState(); @@ -171,7 +173,7 @@ function applyGoalIfActive( function startEditingCurrent(host: GoalActionsHost): void { host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); - const goal = host.stateStore.getState().activeThread.goal; + const goal = activeThreadState(host.stateStore.getState())?.goal ?? null; startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null); } @@ -216,7 +218,7 @@ async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, ob } function addThreadScopedSystemMessage(host: ThreadGoalSyncHost, threadId: string, text: string): void { - if (host.stateStore.getState().activeThread.id !== threadId) return; + if (activeThreadId(host.stateStore.getState()) !== threadId) return; host.addSystemMessage(text); } diff --git a/src/features/chat/application/threads/history-controller.ts b/src/features/chat/application/threads/history-controller.ts index 1d8306c3..985046ea 100644 --- a/src/features/chat/application/threads/history-controller.ts +++ b/src/features/chat/application/threads/history-controller.ts @@ -1,4 +1,4 @@ -import type { ChatAction, ChatState } from "../state/root-reducer"; +import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { threadStreamItems } from "../state/thread-stream"; import type { ThreadHistoryPage, ThreadHistoryTransport } from "./thread-loading-transport"; @@ -36,7 +36,7 @@ export class HistoryController { this.dispatch({ type: "thread-stream/history-loading-set", loading: false }); } - async loadLatest(threadId = this.state.activeThread.id): Promise { + async loadLatest(threadId = activeThreadId(this.state)): Promise { if (!threadId) return; const load = this.startLoading(threadId, "latest"); try { @@ -53,7 +53,7 @@ export class HistoryController { } applyLatestPage(threadId: string, response: ThreadHistoryPage): boolean { - if (this.state.activeThread.id !== threadId) return false; + if (activeThreadId(this.state) !== threadId) return false; this.host.setThreadTurnPresence(response.hadTurns); this.host.showLatestPageAtBottom(); this.dispatch({ @@ -66,8 +66,8 @@ export class HistoryController { async loadOlder(): Promise { const state = this.state; - if (!state.activeThread.id || !state.threadStream.historyCursor || state.threadStream.loadingHistory) return; - const threadId = state.activeThread.id; + const threadId = activeThreadId(state); + if (!threadId || !state.threadStream.historyCursor || state.threadStream.loadingHistory) return; const cursor = state.threadStream.historyCursor; const load = this.startLoading(threadId, "older"); try { @@ -105,7 +105,7 @@ export class HistoryController { } private isStale(load: ActiveThreadHistoryLoad): boolean { - return this.lifecycle !== load || this.state.activeThread.id !== load.threadId; + return this.lifecycle !== load || activeThreadId(this.state) !== load.threadId; } } diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index 2185cc98..8ac4f30e 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -1,6 +1,6 @@ import { threadRenameDraftTitle } from "../../../../domain/threads/title"; import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model"; -import type { ChatAction, ChatState } from "../state/root-reducer"; +import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { threadStreamItems } from "../state/thread-stream"; import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state"; @@ -113,7 +113,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH } export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null { - return state.activeThread.id === threadId ? firstThreadTitleContextFromThreadStreamItems(threadStreamItems(state.threadStream)) : null; + return activeThreadId(state) === threadId ? firstThreadTitleContextFromThreadStreamItems(threadStreamItems(state.threadStream)) : null; } function renameState(host: ThreadRenameEditorActionsHost): ChatRenameUiState { diff --git a/src/features/chat/application/threads/restoration-controller.ts b/src/features/chat/application/threads/restoration-controller.ts index 69418222..fcffe4c9 100644 --- a/src/features/chat/application/threads/restoration-controller.ts +++ b/src/features/chat/application/threads/restoration-controller.ts @@ -1,3 +1,4 @@ +import { awaitingResumeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; export interface RestorationControllerHost { @@ -16,8 +17,8 @@ export class RestorationController { } async ensureLoaded(loadThread: RestoredThreadLoader): Promise { - const restoredThread = this.host.stateStore.getState().restoration; - if (restoredThread.kind !== "thread") return true; + const restoredThread = awaitingResumeThreadState(this.host.stateStore.getState()); + if (!restoredThread) return true; if (this.loading?.threadId === restoredThread.threadId) { await this.loading.promise; return this.restorationLoaded(); @@ -35,11 +36,10 @@ export class RestorationController { } isPending(threadId: string): boolean { - const restoration = this.host.stateStore.getState().restoration; - return restoration.kind === "thread" && restoration.threadId === threadId; + return awaitingResumeThreadState(this.host.stateStore.getState())?.threadId === threadId; } private restorationLoaded(): boolean { - return this.host.stateStore.getState().restoration.kind === "none"; + return awaitingResumeThreadState(this.host.stateStore.getState()) === null; } } diff --git a/src/features/chat/application/threads/resume-actions.ts b/src/features/chat/application/threads/resume-actions.ts index 5d583d26..b6885c82 100644 --- a/src/features/chat/application/threads/resume-actions.ts +++ b/src/features/chat/application/threads/resume-actions.ts @@ -1,5 +1,6 @@ import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; import { resumedThreadAction } from "../state/actions"; +import { activeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { threadStreamIsEmpty } from "../state/thread-stream"; import type { HistoryController } from "./history-controller"; @@ -82,7 +83,8 @@ function recoverResumedThreadTokenUsage(host: ResumeActionsHost, threadId: strin .then((tokenUsage) => { if (!tokenUsage || isStaleResume(host, resume)) return; const state = host.stateStore.getState(); - if (state.activeThread.id !== threadId || state.activeThread.tokenUsage !== null) return; + const activeThread = activeThreadState(state); + if (!activeThread || activeThread.id !== threadId || activeThread.tokenUsage !== null) return; host.stateStore.dispatch({ type: "active-thread/token-usage-set", tokenUsage }); host.refreshLiveState(); }) diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 925add85..89cab876 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,7 +1,7 @@ import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model"; import { activeThreadRuntimeState } from "../../domain/runtime/state"; import { resumedThreadActionFromActiveRuntime } from "../state/actions"; -import type { ChatAction, ChatState } from "../state/root-reducer"; +import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream"; import { chatTurnBusy } from "../turns/turn-state"; @@ -60,7 +60,7 @@ export function createThreadManagementActions(host: ThreadManagementActionsHost) } async function compactActiveThread(host: ThreadManagementActionsHost): Promise { - const threadId = threadManagementState(host).activeThread.id; + const threadId = activeThreadId(threadManagementState(host)); if (!threadId) { host.addSystemMessage("No active thread to compact."); return; @@ -108,12 +108,12 @@ async function forkThreadFromTurn( turnId: string | null, archiveSource: boolean, ): Promise { - const activeThread = threadManagementState(host).activeThread; - if (activeThread.id === threadId && activeThread.lifetime?.kind === "ephemeral") { + const activeThread = activeThreadState(threadManagementState(host)); + if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") { host.addSystemMessage("Side chats cannot be forked."); return; } - if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") { + if (activeThread?.id === threadId && activeThread.provenance?.kind === "subagent") { host.addSystemMessage("Agent threads cannot be forked."); return; } @@ -177,12 +177,12 @@ async function renameThread(host: ThreadManagementActionsHost, threadId: string, } async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise { - const activeThread = threadManagementState(host).activeThread; - if (activeThread.id === threadId && activeThread.lifetime?.kind === "ephemeral") { + const activeThread = activeThreadState(threadManagementState(host)); + if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") { host.addSystemMessage("Side chats cannot be rolled back."); return; } - if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") { + if (activeThread?.id === threadId && activeThread.provenance?.kind === "subagent") { host.addSystemMessage("Agent threads cannot be rolled back."); return; } @@ -240,17 +240,17 @@ function threadManagementDispatch(host: ThreadManagementActionsHost, action: Cha function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope { return { targetThreadId, - initialActiveThreadId: threadManagementState(host).activeThread.id, + initialActiveThreadId: activeThreadId(threadManagementState(host)), initialTurnLifecycle: threadManagementState(host).turn.lifecycle, }; } function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean { const state = threadManagementState(host); - return state.activeThread.id === scope.targetThreadId && state.turn.lifecycle === scope.initialTurnLifecycle; + return activeThreadId(state) === scope.targetThreadId && state.turn.lifecycle === scope.initialTurnLifecycle; } function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean { if (!scope.initialActiveThreadId) return true; - return scope.initialActiveThreadId === scope.targetThreadId && threadManagementState(host).activeThread.id === scope.targetThreadId; + return scope.initialActiveThreadId === scope.targetThreadId && activeThreadId(threadManagementState(host)) === scope.targetThreadId; } diff --git a/src/features/chat/application/threads/thread-navigation-actions.ts b/src/features/chat/application/threads/thread-navigation-actions.ts index 7a07a0e3..173d921c 100644 --- a/src/features/chat/application/threads/thread-navigation-actions.ts +++ b/src/features/chat/application/threads/thread-navigation-actions.ts @@ -1,3 +1,4 @@ +import { activeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { chatTurnBusy } from "../turns/turn-state"; import type { ActiveThreadIdentitySync } from "./active-thread-identity-sync"; @@ -36,7 +37,7 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost) return { async startNewThread(): Promise { const state = host.stateStore.getState(); - if (chatTurnBusy(state) && state.activeThread.provenance?.kind !== "subagent") return; + if (chatTurnBusy(state) && activeThreadState(state)?.provenance?.kind !== "subagent") return; if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return; host.identity.clearActiveThreadIdentity(); diff --git a/src/features/chat/application/threads/thread-start-actions.ts b/src/features/chat/application/threads/thread-start-actions.ts index 65422169..3d03bc4a 100644 --- a/src/features/chat/application/threads/thread-start-actions.ts +++ b/src/features/chat/application/threads/thread-start-actions.ts @@ -4,7 +4,7 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch"; import { resumedThreadAction } from "../state/actions"; import { pendingSubmissionMatches } from "../state/pending-submission"; -import type { ChatState } from "../state/root-reducer"; +import { activeThreadId, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import type { ThreadStartTransport } from "./thread-start-transport"; @@ -46,7 +46,14 @@ async function startThread( permissions: permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig), }); if (!activation) return null; - if (options.preservePendingSubmissionId && !pendingSubmissionMatches(host.stateStore.getState(), options.preservePendingSubmissionId)) { + const current = host.stateStore.getState(); + if ( + options.preservePendingSubmissionId && + !pendingSubmissionMatches( + { pendingSubmission: current.pendingSubmission, activeThreadId: activeThreadId(current) }, + options.preservePendingSubmissionId, + ) + ) { return null; } @@ -60,7 +67,7 @@ async function startThread( const action = resumedThreadAction({ response: patchedActivation, listedThreads: state.threadList.listedThreads, - preserveRequestedRuntimeSettings: requestState.activeThread.id === null, + preserveRequestedRuntimeSettings: activeThreadId(requestState) === null, ...(options.preservePendingSubmissionId ? { preservePendingSubmissionId: options.preservePendingSubmissionId } : {}), }); host.stateStore.dispatch(action); diff --git a/src/features/chat/application/threads/thread-switching.ts b/src/features/chat/application/threads/thread-switching.ts index f3640594..cdea08b6 100644 --- a/src/features/chat/application/threads/thread-switching.ts +++ b/src/features/chat/application/threads/thread-switching.ts @@ -1,6 +1,6 @@ -import type { ChatState } from "../state/root-reducer"; +import { activeThreadId, type ChatState } from "../state/root-reducer"; import { chatTurnBusy } from "../turns/turn-state"; export function canSwitchToThread(state: ChatState, threadId: string): boolean { - return !chatTurnBusy(state) || threadId === state.activeThread.id; + return !chatTurnBusy(state) || threadId === activeThreadId(state); } diff --git a/src/features/chat/application/turns/composer-submit-actions.ts b/src/features/chat/application/turns/composer-submit-actions.ts index 3b4cb73f..0b9163f0 100644 --- a/src/features/chat/application/turns/composer-submit-actions.ts +++ b/src/features/chat/application/turns/composer-submit-actions.ts @@ -189,7 +189,11 @@ function beginPendingWebSubmission( } function pendingWebSubmissionIsCurrent(host: ComposerSubmitActionsHost, submissionId: string): boolean { - return cancellablePendingSubmissionMatches(host.stateStore.getState(), submissionId); + const state = host.stateStore.getState(); + return cancellablePendingSubmissionMatches( + { pendingSubmission: state.pendingSubmission, activeThreadId: submissionStateSnapshot(state).activeThreadId }, + submissionId, + ); } function cancelPendingWebSubmission(host: ComposerSubmitActionsHost, id: string): void { diff --git a/src/features/chat/application/turns/plan-implementation.ts b/src/features/chat/application/turns/plan-implementation.ts index 2b86fbeb..d1f4707a 100644 --- a/src/features/chat/application/turns/plan-implementation.ts +++ b/src/features/chat/application/turns/plan-implementation.ts @@ -1,6 +1,6 @@ import type { ChatRuntimeState } from "../../domain/runtime/state"; import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/thread-stream/selectors"; -import type { ChatActiveThreadState } from "../state/root-reducer"; +import { activeThreadId, activeThreadState, type ChatActiveThreadState, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { type ChatThreadStreamState, threadStreamItems } from "../state/thread-stream"; import { type ChatTurnState, chatTurnBusy } from "./turn-state"; @@ -14,15 +14,27 @@ export interface PlanImplementationHost { requestDefaultCollaborationModeForNextTurn(): void; } -export function implementPlanTargetFromState(state: { - activeThread: Pick & { provenance?: ChatActiveThreadState["provenance"] }; +interface PlanImplementationState { + activeThread: Pick | null; turn: ChatTurnState; runtime: { pending: Pick }; threadStream: Pick; -}): PlanImplementationTarget | null { +} + +export function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null { + return implementPlanTarget({ + activeThread: activeThreadState(state), + turn: state.turn, + runtime: state.runtime, + threadStream: state.threadStream, + }); +} + +export function implementPlanTarget(state: PlanImplementationState): PlanImplementationTarget | null { + const { activeThread } = state; if ( - !state.activeThread.id || - state.activeThread.provenance?.kind === "subagent" || + !activeThread || + activeThread.provenance?.kind === "subagent" || chatTurnBusy(state) || state.runtime.pending.collaborationMode.kind !== "set" || state.runtime.pending.collaborationMode.value !== "plan" @@ -34,7 +46,7 @@ export function implementPlanTargetFromState(state: { export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise { if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId) return; - if (!(await host.ensureConnected()) || !host.stateStore.getState().activeThread.id) return; + if (!(await host.ensureConnected()) || !activeThreadId(host.stateStore.getState())) return; host.requestDefaultCollaborationModeForNextTurn(); host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); diff --git a/src/features/chat/application/turns/submission-state.ts b/src/features/chat/application/turns/submission-state.ts index 3e983163..457352e9 100644 --- a/src/features/chat/application/turns/submission-state.ts +++ b/src/features/chat/application/turns/submission-state.ts @@ -1,6 +1,6 @@ import type { Thread } from "../../../../domain/threads/model"; import type { ThreadStreamItem } from "../../domain/thread-stream/items"; -import type { ChatState } from "../state/root-reducer"; +import { activeThreadState, type ChatState } from "../state/root-reducer"; import { threadStreamItems } from "../state/thread-stream"; import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state"; @@ -16,10 +16,11 @@ export interface SubmissionStateSnapshot { } export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapshot { + const activeThread = activeThreadState(state); return { - activeThreadId: state.activeThread.id, - activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral", - activeThreadSubagent: state.activeThread.provenance?.kind === "subagent", + activeThreadId: activeThread?.id ?? null, + activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral", + activeThreadSubagent: activeThread?.provenance?.kind === "subagent", activeTurnId: activeTurnId(state), busy: chatTurnBusy(state), listedThreads: state.threadList.listedThreads, diff --git a/src/features/chat/application/turns/turn-submission-actions.ts b/src/features/chat/application/turns/turn-submission-actions.ts index af6a9cce..21eea1da 100644 --- a/src/features/chat/application/turns/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -326,7 +326,12 @@ function isCurrentTurn(host: TurnSubmissionActionsHost, threadId: string, turnId } function pendingRequestIsCurrent(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean { - return request.pendingSubmissionId ? pendingSubmissionMatches(host.stateStore.getState(), request.pendingSubmissionId) : true; + if (!request.pendingSubmissionId) return true; + const state = host.stateStore.getState(); + return pendingSubmissionMatches( + { pendingSubmission: state.pendingSubmission, activeThreadId: submissionStateSnapshot(state).activeThreadId }, + request.pendingSubmissionId, + ); } function commitPendingRequest(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean { diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index 73c6bd03..753a8fb7 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -1,5 +1,6 @@ import type { ConnectionManager } from "../../../../app-server/connection/connection-manager"; import type { PendingRequestActions } from "../../application/pending-requests/pending-request-actions"; +import { activeThreadId, activeThreadState } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; import type { HistoryController } from "../../application/threads/history-controller"; import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions"; @@ -68,12 +69,12 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan rename, navigation, openSideChat: () => { - const activeThreadId = stateStore.getState().activeThread.id; - if (!activeThreadId) return; - const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === activeThreadId); - void environment.plugin.workspace.openSideChat(activeThreadId, thread?.name ?? thread?.preview ?? null); + const threadId = activeThreadId(stateStore.getState()); + if (!threadId) return; + const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId); + void environment.plugin.workspace.openSideChat(threadId, thread?.name ?? thread?.preview ?? null); }, - activeThreadChatActionsDisabled: () => stateStore.getState().activeThread.provenance?.kind === "subagent", + activeThreadChatActionsDisabled: () => activeThreadState(stateStore.getState())?.provenance?.kind === "subagent", }); const toolbarSurface: ChatPanelToolbarSurface = { connection: { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 1f6a9e91..a73093f4 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -2,7 +2,13 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import { type AppServerQueryContext, appServerQueryContextMatches, appServerQueryContextRawEquals } from "../../../app-server/query/keys"; import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate"; import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title"; -import type { ChatPanelRestorationState, ChatState } from "../application/state/root-reducer"; +import { + activeThreadId, + activeThreadState, + awaitingResumeThreadState, + type ChatState, + panelThreadId, +} from "../application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../application/state/store"; import { ChatResumeWorkTracker } from "../application/threads/resume-work"; import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom"; @@ -33,14 +39,14 @@ export class ChatPanelSession implements ChatPanelHandle { } displayTitle(): string { - if (this.state.activeThread.lifetime?.kind === "ephemeral") { + if (activeThreadState(this.state)?.lifetime?.kind === "ephemeral") { return "Side chat"; } return threadWindowTitle(this.panelThreadId(), this.state.threadList.listedThreads, this.restoredThreadTitle()); } persistedState(): Record { - const lifetime = this.state.activeThread.lifetime; + const lifetime = activeThreadState(this.state)?.lifetime; if (lifetime?.kind === "ephemeral") { return { version: 2, @@ -80,7 +86,7 @@ export class ChatPanelSession implements ChatPanelHandle { const nextContext = this.currentAppServerContext(); if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) { this.observedAppServerContext = nextContext; - const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(this.state.activeThread.id); + const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(activeThreadId(this.state)); void this.reconnectAfterAppServerContextChange(replacement); this.runtime.runtime.sharedState.applyCached(); } @@ -88,8 +94,8 @@ export class ChatPanelSession implements ChatPanelHandle { } prepareAppServerContextChange(): void { - const activeThreadId = this.pendingAppServerContextReplacement?.activeThreadId ?? this.state.activeThread.id; - this.captureAppServerContextReplacement(activeThreadId); + const threadId = this.pendingAppServerContextReplacement?.activeThreadId ?? activeThreadId(this.state); + this.captureAppServerContextReplacement(threadId); this.runtime.actions.prepareAppServerContextChange(); } @@ -180,7 +186,7 @@ export class ChatPanelSession implements ChatPanelHandle { async focusThread(threadId: string | null = null): Promise { const restoredThread = this.restoredThread(); - const restoredThreadId = restoredThread.kind === "thread" ? restoredThread.threadId : null; + const restoredThreadId = restoredThread?.threadId ?? null; if ((threadId && this.runtime.thread.restoration.isPending(threadId)) || (!threadId && restoredThreadId)) { await this.ensureRestoredThreadLoaded(); } @@ -282,26 +288,26 @@ export class ChatPanelSession implements ChatPanelHandle { } private activeThreadTitle(): string | null { - const threadId = this.state.activeThread.id; - if (!threadId) return null; + const activeThread = activeThreadState(this.state); + if (!activeThread) return null; + const threadId = activeThread.id; const thread = this.state.threadList.listedThreads.find((item) => item.id === threadId); - return thread ? threadMeaningfulTitle(thread) : (this.state.activeThread.title ?? null); + return thread ? threadMeaningfulTitle(thread) : (activeThread.title ?? null); } private restoredThreadTitle(): string | null { const restoredThread = this.restoredThread(); - if (restoredThread.kind !== "thread") return null; + if (!restoredThread) return null; const listedThread = this.state.threadList.listedThreads.find((thread) => thread.id === restoredThread.threadId); return listedThread ? threadMeaningfulTitle(listedThread) : restoredThread.fallbackTitle; } - private restoredThread(): ChatPanelRestorationState { - return this.state.restoration; + private restoredThread(): ReturnType { + return awaitingResumeThreadState(this.state); } private panelThreadId(): string | null { - const restoredThread = this.restoredThread(); - return restoredThread.kind === "thread" ? restoredThread.threadId : this.state.activeThread.id; + return panelThreadId(this.state); } private ensureRestoredThreadLoaded(): Promise { diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index f17ac6ba..9405f4aa 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -27,7 +27,7 @@ import { type PreparedComposerInput, preparedUserInputWithWikiLinkMentionsSkillsAndContext, } from "../application/composer/wikilink-context"; -import type { ChatAction, ChatState } from "../application/state/root-reducer"; +import { activeThreadState, type ChatAction, type ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer"; import { syncComposerHeight } from "../ui/composer.dom"; @@ -253,9 +253,9 @@ export class ChatComposerController { state.connection.availableModels, this.options.currentModelForSuggestions(), { - activeThreadId: state.activeThread.id, - activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral", - activeThreadSubagent: state.activeThread.provenance?.kind === "subagent", + activeThreadId: activeThreadState(state)?.id ?? null, + activeThreadEphemeral: activeThreadState(state)?.lifetime?.kind === "ephemeral", + activeThreadSubagent: activeThreadState(state)?.provenance?.kind === "subagent", contextReferences: this.contextReferences(), dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()), permissionProfiles: state.connection.availablePermissionProfiles, @@ -306,7 +306,7 @@ export class ChatComposerController { state.connection.availableModels, this.options.currentModelForSuggestions(), { - activeThreadId: state.activeThread.id, + activeThreadId: activeThreadState(state)?.id ?? null, contextReferences: this.contextReferences(), dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()), permissionProfiles: state.connection.availablePermissionProfiles, diff --git a/src/features/chat/panel/runtime-status-projection.ts b/src/features/chat/panel/runtime-status-projection.ts index 72775678..1ad88fbe 100644 --- a/src/features/chat/panel/runtime-status-projection.ts +++ b/src/features/chat/panel/runtime-status-projection.ts @@ -1,6 +1,6 @@ import { runtimeConfigOrDefault } from "../../../domain/runtime/config"; import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; -import type { ChatState } from "../application/state/root-reducer"; +import { activeThreadId, type ChatState } from "../application/state/root-reducer"; import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels"; import { resolveRuntimeControls } from "../domain/runtime/resolution"; import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; @@ -46,7 +46,7 @@ function statusDetails(input: ChatPanelRuntimeProjectionInput): ThreadStreamNoti const state = input.state(); return noticeSectionsFromRows( buildStatusDetails({ - activeThreadId: state.activeThread.id, + activeThreadId: activeThreadId(state), snapshot: runtimeSnapshot(state), nowMs: input.nowMs(), }), diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index b62ffa77..77be4814 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -1,7 +1,7 @@ import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals"; import { explicitThreadName } from "../../../domain/threads/model"; import { runtimeSnapshotForChatSlices, threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot"; -import type { ChatState } from "../application/state/root-reducer"; +import type { ChatActiveThreadState, ChatState } from "../application/state/root-reducer"; import { type ThreadStreamRollbackCandidate, threadStreamActiveItems, @@ -9,7 +9,7 @@ import { threadStreamRollbackCandidateFromItems, threadStreamStableItems, } from "../application/state/thread-stream"; -import { implementPlanTargetFromState } from "../application/turns/plan-implementation"; +import { implementPlanTarget } from "../application/turns/plan-implementation"; import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state"; import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; import type { ThreadStreamItem } from "../domain/thread-stream/items"; @@ -30,7 +30,8 @@ interface ChatPanelShellReadModel { interface ChatPanelShellSignals { connection: Signal; threadList: Signal; - activeThread: Signal; + panelThread: Signal; + activeThread: ReadonlySignal; runtime: Signal; turn: Signal; threadStream: Signal; @@ -40,9 +41,9 @@ interface ChatPanelShellSignals { ui: Signal; turnBusy: ReadonlySignal; activeTurnId: ReadonlySignal; - activeThreadId: ReadonlySignal; - activeThreadCwd: ReadonlySignal; - activeThreadGoal: ReadonlySignal; + activeThreadId: ReadonlySignal; + activeThreadCwd: ReadonlySignal; + activeThreadGoal: ReadonlySignal; threadStreamItems: ReadonlySignal; threadStreamStableItems: ReadonlySignal; threadStreamActiveItems: ReadonlySignal; @@ -73,7 +74,7 @@ interface ChatPanelToolbarDiagnosticState { } interface ChatPanelToolbarDebugState { - readonly activeThreadId: ChatState["activeThread"]["id"]; + readonly activeThreadId: string | null; readonly connection: ChatPanelToolbarDebugConnectionState; readonly runtimeConfig: ChatState["connection"]["runtimeConfig"]; readonly runtime: ChatState["runtime"]; @@ -82,7 +83,7 @@ interface ChatPanelToolbarDebugState { export interface ChatPanelToolbarReadModel { readonly threads: ReadonlySignal; - readonly activeThreadId: ReadonlySignal; + readonly activeThreadId: ReadonlySignal; readonly activeThreadSubagent: ReadonlySignal; readonly turnBusy: ReadonlySignal; readonly runtimeSnapshot: ReadonlySignal; @@ -96,7 +97,7 @@ export interface ChatPanelToolbarReadModel { // Goal read model export interface ChatPanelGoalReadModel { - readonly goal: ReadonlySignal; + readonly goal: ReadonlySignal; readonly goalEditor: ReadonlySignal; readonly goalObjectiveExpanded: ReadonlySignal; } @@ -104,8 +105,8 @@ export interface ChatPanelGoalReadModel { // Thread stream read model export interface ChatPanelThreadStreamReadModel { - readonly activeThreadId: ReadonlySignal; - readonly activeThreadCwd: ReadonlySignal; + readonly activeThreadId: ReadonlySignal; + readonly activeThreadCwd: ReadonlySignal; readonly activeTurnId: ReadonlySignal; readonly historyCursor: ReadonlySignal; readonly loadingHistory: ReadonlySignal; @@ -141,7 +142,7 @@ export interface ChatPanelComposerReadModel { readonly draft: ReadonlySignal; readonly suggestions: ReadonlySignal; readonly selectedSuggestionIndex: ReadonlySignal; - readonly activeThreadId: ReadonlySignal; + readonly activeThreadId: ReadonlySignal; readonly activeThreadSubagent: ReadonlySignal; readonly webSubmissionPending: ReadonlySignal; readonly webSubmissionCancellable: ReadonlySignal; @@ -153,7 +154,8 @@ export interface ChatPanelComposerReadModel { export function createChatPanelShellReadModelBinding(initialState: ChatState): ChatPanelShellReadModelBinding { const connection = signal(initialState.connection); const threadList = signal(initialState.threadList); - const activeThread = signal(initialState.activeThread); + const panelThread = signal(initialState.panelThread); + const activeThread = computed(() => (panelThread.value.kind === "active" ? panelThread.value.thread : null)); const runtime = signal(initialState.runtime); const turn = signal(initialState.turn); const threadStream = signal(initialState.threadStream); @@ -165,13 +167,14 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C const canonicalStreamItems = computed(() => threadStreamItems(threadStream.value)); const streamItems = computed(() => appendPendingSubmission(canonicalStreamItems.value, pendingSubmission.value)); const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(canonicalStreamItems.value)); - const activeThreadIdSignal = computed(() => activeThread.value.id); - const activeThreadCwd = computed(() => activeThread.value.cwd); - const activeThreadTokenUsage = computed(() => activeThread.value.tokenUsage); - const activeThreadGoal = computed(() => activeThread.value.goal); + const activeThreadIdSignal = computed(() => activeThread.value?.id ?? null); + const activeThreadCwd = computed(() => activeThread.value?.cwd ?? null); + const activeThreadTokenUsage = computed(() => activeThread.value?.tokenUsage ?? null); + const activeThreadGoal = computed(() => activeThread.value?.goal ?? null); const signals: ChatPanelShellSignals = { connection, threadList, + panelThread, activeThread, runtime, turn, @@ -197,18 +200,18 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C : threadStreamActiveItems(threadStream.value), ), threadStreamRollbackCandidate: computed(() => - turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent" + turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent" ? null : threadStreamRollbackCandidateFromItems(canonicalStreamItems.value), ), threadStreamForkCandidates: computed(() => - turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent" + turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent" ? [] : forkCandidatesFromItems(canonicalStreamItems.value), ), threadStreamImplementPlanTarget: computed(() => - implementPlanTargetFromState({ - activeThread: { id: activeThreadIdSignal.value, provenance: activeThread.value.provenance }, + implementPlanTarget({ + activeThread: activeThread.value, turn: turn.value, runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } }, threadStream: threadStream.value, @@ -255,7 +258,7 @@ function syncShellSignals(signals: ChatPanelShellSignals, nextState: ChatState): batch(() => { if (signals.connection.value !== nextState.connection) signals.connection.value = nextState.connection; if (signals.threadList.value !== nextState.threadList) signals.threadList.value = nextState.threadList; - if (signals.activeThread.value !== nextState.activeThread) signals.activeThread.value = nextState.activeThread; + if (signals.panelThread.value !== nextState.panelThread) signals.panelThread.value = nextState.panelThread; if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime; if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn; if (signals.threadStream.value !== nextState.threadStream) signals.threadStream.value = nextState.threadStream; @@ -286,7 +289,7 @@ function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelT return { threads: computed(() => signals.threadList.value.listedThreads), activeThreadId: signals.activeThreadId, - activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"), + activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"), turnBusy: signals.turnBusy, runtimeSnapshot: signals.toolbarRuntimeSnapshot, toolbarPanel: computed(() => signals.ui.value.toolbarPanel), @@ -356,16 +359,16 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel availableModels: computed(() => signals.connection.value.availableModels), }, activeListedThreadName: computed(() => activeListedThreadName(signals)), - sideChatActive: computed(() => signals.activeThread.value.lifetime?.kind === "ephemeral"), + sideChatActive: computed(() => signals.activeThread.value?.lifetime?.kind === "ephemeral"), sideChatSourceTitle: computed(() => { - const lifetime = signals.activeThread.value.lifetime; + const lifetime = signals.activeThread.value?.lifetime; return lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null; }), draft: computed(() => signals.composer.value.draft), suggestions: computed(() => signals.composer.value.suggestions), selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected), activeThreadId: signals.activeThreadId, - activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"), + activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"), webSubmissionPending: signals.webSubmissionPending, webSubmissionCancellable: signals.webSubmissionCancellable, turnBusy: signals.turnBusy, diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index 25b08eed..797afa7b 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -9,7 +9,12 @@ import { createChatInboundHandler, } from "../../../../../src/features/chat/app-server/inbound/handler"; import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source"; -import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; +import { + activeThreadState, + type ChatAction, + type ChatState, + chatReducer, +} from "../../../../../src/features/chat/application/state/root-reducer"; import type { ChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state"; import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/thread-stream/updates"; @@ -1351,7 +1356,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active" }, } satisfies Extract); - expect(handler.currentState().activeThread.id).toBe("thread-active"); + expect(activeThreadState(handler.currentState())?.id).toBe("thread-active"); expect(applyThreadCatalogEvent).toHaveBeenCalledWith( { type: "thread-archived", @@ -1413,7 +1418,7 @@ describe("ChatInboundHandler", () => { params: { thread: appServerThread("thread-other", "/workspace/other") }, } satisfies Extract); - expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); + expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active"); expect(applyThreadCatalogEvent).toHaveBeenCalledWith( { type: "thread-started", @@ -1434,7 +1439,7 @@ describe("ChatInboundHandler", () => { params: { thread: appServerThread("thread-active", "/workspace/active") }, } satisfies Extract); - expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); + expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active"); expect(applyThreadCatalogEvent).toHaveBeenCalledWith( { type: "thread-started", @@ -1444,7 +1449,7 @@ describe("ChatInboundHandler", () => { ); }); - it("keeps ephemeral thread-started notifications out of the shared catalog", () => { + it("keeps ephemeral thread-started notifications out of the shared catalog and an empty panel", () => { const applyThreadCatalogEvent = vi.fn(); const handler = handlerForState(chatStateFixture(), { applyThreadCatalogEvent }); @@ -1453,7 +1458,7 @@ describe("ChatInboundHandler", () => { params: { thread: { ...appServerThread("side", "/workspace/active"), ephemeral: true } }, } satisfies Extract); - expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); + expect(handler.currentState().panelThread).toEqual({ kind: "empty" }); expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); }); @@ -1775,7 +1780,7 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); + expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active"); expect(handler.currentState().runtime.active.model).toBe("gpt-5.5"); expect(handler.currentState().runtime.active.serviceTier).toBe("fast"); expect(handler.currentState().runtime.active.approvalsReviewer).toBe("auto_review"); @@ -1822,7 +1827,7 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); + expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active"); expect(handler.currentState().runtime.active.model).toBe("gpt-active"); expect(handler.currentState().runtime.active.serviceTier).toBe("flex"); expect(handler.currentState().runtime.active.approvalsReviewer).toBe("user"); @@ -1885,7 +1890,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: null, goal }, } satisfies Extract); - expect(handler.currentState().activeThread.goal).toEqual(goal); + expect(activeThreadState(handler.currentState())?.goal).toEqual(goal); expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "set: Finish", @@ -1950,7 +1955,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active" }, } satisfies Extract); - expect(handler.currentState().activeThread.goal).toBeNull(); + expect(activeThreadState(handler.currentState())?.goal).toBeNull(); expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "cleared: Finish well", @@ -1989,7 +1994,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-1", goal: completedGoal }, } satisfies Extract); - expect(handler.currentState().activeThread.goal).toEqual(completedGoal); + expect(activeThreadState(handler.currentState())?.goal).toEqual(completedGoal); expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1); expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ kind: "goal", @@ -2023,7 +2028,7 @@ describe("ChatInboundHandler", () => { method: "thread/goal/updated", params: { threadId: "previous-thread", turnId: null, goal }, } satisfies Extract); - expect(noActiveState.activeThread.goal).toBeNull(); + expect(activeThreadState(noActiveState)).toBeNull(); let otherThreadState = chatStateFixture(); otherThreadState = chatStateWith(otherThreadState, { activeThread: { id: "thread-active" } }); @@ -2035,7 +2040,7 @@ describe("ChatInboundHandler", () => { method: "thread/goal/cleared", params: { threadId: "previous-thread" }, } satisfies Extract); - expect(expectPresent(otherThreadState.activeThread.goal).objective).toBe("Current"); + expect(expectPresent(activeThreadState(otherThreadState)?.goal).objective).toBe("Current"); }); }); diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 92601b4f..2d251147 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -8,7 +8,7 @@ import { import type { RuntimeSettingsTransport } from "../../../../../src/features/chat/application/runtime/settings-transport"; import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot"; import type { ActiveThreadSettingsAppliedAction } from "../../../../../src/features/chat/application/state/actions"; -import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { activeThreadId, type ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent"; import { chatStateFixture, chatStateWith } from "../../support/state"; @@ -392,7 +392,7 @@ describe("createChatRuntimeSettingsActions", () => { await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false); expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); - expect(store.getState().activeThread.id).toBeNull(); + expect(activeThreadId(store.getState())).toBeNull(); expect(store.getState().runtime.active.model).toBeNull(); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); expect(messages).toEqual([]); @@ -413,7 +413,7 @@ describe("createChatRuntimeSettingsActions", () => { await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false); - expect(store.getState().activeThread.id).toBeNull(); + expect(activeThreadId(store.getState())).toBeNull(); expect(store.getState().runtime.active.model).toBeNull(); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); expect(messages).toEqual([]); diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index fafa14cd..b0ccf275 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; +import { activeThreadState, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { threadStreamItems } from "../../../../../src/features/chat/application/state/thread-stream"; import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state"; @@ -87,13 +87,38 @@ describe("chatReducer", () => { fallbackTitle: "Restored title", }); - expect(restored.restoration).toEqual({ kind: "thread", threadId: "restored-thread", fallbackTitle: "Restored title" }); - expect(restored.activeThread.id).toBeNull(); + expect(restored.panelThread).toEqual({ + kind: "awaiting-resume", + threadId: "restored-thread", + fallbackTitle: "Restored title", + }); expect(restored.connection.statusText).toBe("Thread ready to resume."); expectThreadScopeReset(restored, { items: [] }); const disconnected = chatReducer(restored, { type: "connection/scoped-cleared" }); - expect(disconnected.restoration).toEqual(restored.restoration); + expect(disconnected.panelThread).toEqual(restored.panelThread); + }); + + it("keeps active-only metadata out of the awaiting-resume phase", () => { + let state = chatReducer(chatStateFixture(), { + type: "panel/restored-thread-applied", + threadId: "restored-thread", + fallbackTitle: "Restored title", + }); + const awaitingResume = state.panelThread; + + state = chatReducer(state, { type: "active-thread/cwd-set", cwd: "/stale" }); + const usage = { + total: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 }, + last: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 }, + modelContextWindow: 100, + }; + state = chatReducer(state, { type: "active-thread/token-usage-set", tokenUsage: usage }); + state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("restored-thread") }); + state = chatReducer(state, { type: "turn/started", threadId: "other-thread", turnId: "stale-turn" }); + + expect(state.panelThread).toEqual(awaitingResume); + expect(state.turn.lifecycle).toEqual({ kind: "idle" }); }); it("clears active turn and thread-scoped state", () => { @@ -117,8 +142,7 @@ describe("chatReducer", () => { const next = chatReducer(pendingState, { type: "active-thread/cleared" }); - expect(next.activeThread.id).toBeNull(); - expect(next.activeThread.goal).toBeNull(); + expect(next.panelThread).toEqual({ kind: "empty" }); expect(next.runtime.active.model).toBeNull(); expect(next.runtime.active.reasoningEffort).toBeNull(); expect(next.runtime.active.serviceTier).toBeNull(); @@ -155,8 +179,7 @@ describe("chatReducer", () => { items: resumedItems, }); - expect(next.activeThread.id).toBe("resumed-thread"); - expect(next.activeThread.goal).toBeNull(); + expect(activeThreadState(next)).toMatchObject({ id: "resumed-thread", goal: null }); expect(next.runtime.active.collaborationMode).toBeNull(); expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" }); expectThreadScopeReset(next, { items: resumedItems }); @@ -188,7 +211,7 @@ describe("chatReducer", () => { preserveRequestedRuntimeSettings: true, }); - expect(next.activeThread.id).toBe("started-thread"); + expect(activeThreadState(next)?.id).toBe("started-thread"); expect(next.runtime.active.model).toBe("gpt-5"); expect(next.runtime.active.reasoningEffort).toBe("medium"); expect(next.runtime.active.serviceTier).toBe("fast"); @@ -635,7 +658,7 @@ describe("chatReducer", () => { }); it("clears expanded goal objective state when the displayed goal identity changes", () => { - let state = chatStateFixture(); + let state = chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } }); state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("thread") }); state = chatReducer(state, { type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: "thread", open: true }); @@ -827,14 +850,14 @@ describe("chatReducer", () => { panelA.dispatch({ type: "active-thread/cleared" }); expect(panelA.getState()).toMatchObject({ - activeThread: { id: null }, + panelThread: { kind: "empty" }, composer: { draft: "" }, requests: { pendingUserInputs: [] }, }); expect(panelA.getState().requests.userInputDrafts.size).toBe(0); expect(panelB.getState()).toMatchObject({ - activeThread: { id: "thread-b" }, + panelThread: { kind: "active", thread: { id: "thread-b" } }, composer: { draft: "panel B draft" }, requests: { pendingUserInputs: [expect.objectContaining({ requestId: 2 })] }, }); diff --git a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts index 630d1525..c0ed2e0d 100644 --- a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts +++ b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { activeThreadId, createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createActiveThreadIdentitySync } from "../../../../../src/features/chat/application/threads/active-thread-identity-sync"; @@ -48,7 +48,7 @@ describe("createActiveThreadIdentitySync", () => { sync.applyThreadArchiveToActiveIdentity("thread"); - expect(stateStore.getState().activeThread.id).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBeNull(); expect(host.invalidateThreadWork).toHaveBeenCalledOnce(); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); @@ -74,7 +74,7 @@ describe("createActiveThreadIdentitySync", () => { sync.applyThreadArchiveToActiveIdentity("other"); - expect(stateStore.getState().activeThread.id).toBe("active"); + expect(activeThreadId(stateStore.getState())).toBe("active"); expect(host.invalidateThreadWork).not.toHaveBeenCalled(); expect(host.resetThreadTurnPresence).not.toHaveBeenCalled(); expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); @@ -86,9 +86,9 @@ describe("createActiveThreadIdentitySync", () => { sync.applyThreadArchiveToActiveIdentity("thread"); - expect(stateStore.getState().activeThread.id).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBeNull(); expect(host.invalidateThreadWork).toHaveBeenCalledOnce(); - expect(stateStore.getState().restoration).toEqual({ kind: "none" }); + expect(stateStore.getState().panelThread.kind).toBe("empty"); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); }); @@ -122,7 +122,7 @@ describe("createActiveThreadIdentitySync", () => { sync.applyThreadRenameToActiveIdentity("thread", "New"); - expect(stateStore.getState().restoration).toEqual({ kind: "thread", threadId: "thread", fallbackTitle: "New" }); + expect(stateStore.getState().panelThread).toEqual({ kind: "awaiting-resume", threadId: "thread", fallbackTitle: "New" }); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); }); diff --git a/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts b/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts index bf71e0f5..2b117775 100644 --- a/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts +++ b/tests/features/chat/application/threads/ephemeral-thread-lifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; - import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation"; +import { activeThreadId, activeThreadState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createEphemeralThreadLifecycle } from "../../../../../src/features/chat/application/threads/ephemeral-thread-lifecycle"; import type { EphemeralThreadTransport } from "../../../../../src/features/chat/application/threads/ephemeral-thread-transport"; @@ -35,7 +35,7 @@ describe("ephemeral thread lifecycle", () => { await expect(lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: "Source" })).resolves.toBe(true); expect(transport.forkEphemeralThread).toHaveBeenCalledWith("source"); - expect(store.getState().activeThread).toMatchObject({ + expect(activeThreadState(store.getState())).toMatchObject({ id: "side", lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, }); @@ -58,7 +58,7 @@ describe("ephemeral thread lifecycle", () => { await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(true); expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side"); - expect(store.getState().activeThread.id).toBeNull(); + expect(activeThreadId(store.getState())).toBeNull(); }); it("interrupts a running side turn before close cleanup", async () => { @@ -108,7 +108,7 @@ describe("ephemeral thread lifecycle", () => { await expect(opening).resolves.toBe(false); expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side"); - expect(store.getState().activeThread.id).toBeNull(); + expect(activeThreadId(store.getState())).toBeNull(); }); it("still unsubscribes the side chat when interrupting its running turn fails", async () => { @@ -173,7 +173,7 @@ describe("ephemeral thread lifecycle", () => { await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(false); - expect(store.getState().activeThread.id).toBe("side"); + expect(activeThreadId(store.getState())).toBe("side"); expect(addSystemMessage).toHaveBeenCalledWith("Could not discard the side chat. Try again before switching threads."); }); diff --git a/tests/features/chat/application/threads/goal-actions.test.ts b/tests/features/chat/application/threads/goal-actions.test.ts index 7c6f7171..713afa81 100644 --- a/tests/features/chat/application/threads/goal-actions.test.ts +++ b/tests/features/chat/application/threads/goal-actions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; - import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source"; +import { activeThreadId, activeThreadState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createGoalActions } from "../../../../../src/features/chat/application/threads/goal-actions"; import type { ThreadGoalTransport } from "../../../../../src/features/chat/application/threads/goal-transport"; @@ -28,7 +28,7 @@ describe("createGoalActions", () => { await actions.syncThreadGoal("thread"); - expect(stateStore.getState().activeThread.goal).toEqual(currentGoal); + expect(activeThreadState(stateStore.getState())?.goal).toEqual(currentGoal); expect(refreshLiveState).toHaveBeenCalledOnce(); }); @@ -50,8 +50,8 @@ describe("createGoalActions", () => { await actions.syncThreadGoal("thread"); - expect(stateStore.getState().activeThread.id).toBe("thread"); - expect(stateStore.getState().activeThread.goal).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBe("thread"); + expect(activeThreadState(stateStore.getState())?.goal).toBeNull(); expect(addSystemMessage).toHaveBeenCalledWith("Could not load thread goal: offline"); }); @@ -90,7 +90,7 @@ describe("createGoalActions", () => { expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "updated: Updated", objective: "Updated" })); expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "paused: Updated", objective: "Updated" })); expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "cleared: Updated", objective: "Updated" })); - expect(stateStore.getState().activeThread.goal).toBeNull(); + expect(activeThreadState(stateStore.getState())?.goal).toBeNull(); }); it("does not report stale goal action failures after the active thread changes", async () => { @@ -366,7 +366,7 @@ describe("createGoalActions", () => { await actions.syncThreadGoal("thread"); - expect(stateStore.getState().activeThread.goal).toEqual(currentGoal); + expect(activeThreadState(stateStore.getState())?.goal).toEqual(currentGoal); expect(addSystemMessage).not.toHaveBeenCalled(); }); }); diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 820636b0..c0353576 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ThreadTokenUsage } from "../../../../../src/domain/runtime/metrics"; import type { Thread as PanelThread } from "../../../../../src/domain/threads/model"; -import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { activeThreadId, activeThreadState, createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import type { HistoryController } from "../../../../../src/features/chat/application/threads/history-controller"; import { createResumeActions, type ResumeActionsHost } from "../../../../../src/features/chat/application/threads/resume-actions"; @@ -75,7 +75,7 @@ describe("ResumeActions", () => { expect(resumeThread).toHaveBeenCalledWith("thread"); expect(host.syncThreadGoal).toHaveBeenCalledWith("thread"); - expect(stateStore.getState().activeThread.id).toBe("thread"); + expect(activeThreadId(stateStore.getState())).toBe("thread"); expect(loadLatest).toHaveBeenCalledWith("thread"); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); @@ -98,7 +98,7 @@ describe("ResumeActions", () => { await actions.resumeThread("thread"); expect(resumeThread).toHaveBeenCalledWith("thread"); - expect(stateStore.getState().activeThread.id).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBeNull(); expect(loadLatest).not.toHaveBeenCalled(); expect(host.syncThreadGoal).not.toHaveBeenCalled(); }); @@ -148,11 +148,11 @@ describe("ResumeActions", () => { expect(recoverTokenUsageFromRollout).toHaveBeenCalledWith("/tmp/rollout.jsonl"); expect(loadLatest).toHaveBeenCalledWith("thread"); - expect(stateStore.getState().activeThread.tokenUsage).toBeNull(); + expect(activeThreadState(stateStore.getState())?.tokenUsage).toBeNull(); await recovery.resolveAndFlush(tokenUsageFixture(42)); - expect(stateStore.getState().activeThread.tokenUsage).toMatchObject({ last: { inputTokens: 42 } }); + expect(activeThreadState(stateStore.getState())?.tokenUsage).toMatchObject({ last: { inputTokens: 42 } }); }); it("ignores stale rollout token usage recovery", async () => { @@ -181,8 +181,8 @@ describe("ResumeActions", () => { await recovery.resolveAndFlush(tokenUsageFixture(42)); - expect(stateStore.getState().activeThread.id).toBe("other"); - expect(stateStore.getState().activeThread.tokenUsage).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBe("other"); + expect(activeThreadState(stateStore.getState())?.tokenUsage).toBeNull(); }); it("does not let late rollout token usage recovery overwrite live token usage", async () => { @@ -196,7 +196,7 @@ describe("ResumeActions", () => { await recovery.resolveAndFlush(tokenUsageFixture(42)); - expect(stateStore.getState().activeThread.tokenUsage).toMatchObject({ last: { inputTokens: 99 } }); + expect(activeThreadState(stateStore.getState())?.tokenUsage).toMatchObject({ last: { inputTokens: 99 } }); }); it("ignores rollout token usage recovery failures", async () => { @@ -207,7 +207,7 @@ describe("ResumeActions", () => { await actions.resumeThread("thread"); await Promise.resolve(); - expect(stateStore.getState().activeThread.tokenUsage).toBeNull(); + expect(activeThreadState(stateStore.getState())?.tokenUsage).toBeNull(); expect(host.addSystemMessage).not.toHaveBeenCalledWith("read failed"); }); }); diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index 0ac5f064..05c3f2f7 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -1,7 +1,7 @@ import type { Mock } from "vitest"; import { describe, expect, it, vi } from "vitest"; - import type { Thread } from "../../../../../src/domain/threads/model"; +import { activeThreadId } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createThreadManagementActions, @@ -443,7 +443,7 @@ describe("thread management actions", () => { rollback.resolve(rollbackSnapshot()); await pendingRollback; - expect(host.stateStore.getState().activeThread.id).toBe("other"); + expect(activeThreadId(host.stateStore.getState())).toBe("other"); expect(host.setComposerText).not.toHaveBeenCalled(); expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled(); @@ -580,7 +580,7 @@ function hostMock({ threadTransport: transportOverrides = {}, }: { items: ThreadStreamItem[]; - activeThread?: Partial["activeThread"]>; + activeThread?: NonNullable[1]["activeThread"]>; operations?: Partial; threadTransport?: Partial; }): ThreadManagementActionsHostMock { diff --git a/tests/features/chat/application/threads/thread-start-actions.test.ts b/tests/features/chat/application/threads/thread-start-actions.test.ts index ae34fc0b..fbbd8200 100644 --- a/tests/features/chat/application/threads/thread-start-actions.test.ts +++ b/tests/features/chat/application/threads/thread-start-actions.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; - import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation"; import type { Thread } from "../../../../../src/domain/threads/model"; import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot"; import { resumedThreadAction } from "../../../../../src/features/chat/application/state/actions"; +import { activeThreadId } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createThreadStartActions } from "../../../../../src/features/chat/application/threads/thread-start-actions"; import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission"; @@ -148,7 +148,7 @@ describe("thread start actions", () => { started.resolve(activationFixture(threadFixture("delayed"))); await expect(starting).resolves.toBeNull(); - expect(stateStore.getState().activeThread.id).toBe("selected"); + expect(activeThreadId(stateStore.getState())).toBe("selected"); expect(recordStartedThread).not.toHaveBeenCalled(); }); @@ -228,7 +228,7 @@ describe("thread start actions", () => { }); await expect(actions.startThread("local preview")).resolves.toBeNull(); - expect(stateStore.getState().activeThread.id).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBeNull(); expect(stateStore.getState().threadList.listedThreads).toEqual([]); expect(recordStartedThread).not.toHaveBeenCalled(); expect(syncThreadGoal).not.toHaveBeenCalled(); diff --git a/tests/features/chat/application/turns/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts index 820d8c87..0ea82e6a 100644 --- a/tests/features/chat/application/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -6,6 +6,7 @@ import { createChatState } from "../../../../../src/features/chat/application/st import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { submitComposer } from "../../../../../src/features/chat/application/turns/composer-submit-actions"; import { deferred } from "../../../../support/async"; +import { chatStateWith } from "../../support/state"; import { chatStateThreadStreamItems } from "../../support/thread-stream"; function thread(id: string): Thread { @@ -24,10 +25,8 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) { const initialState = createChatState(); const stateStore = createChatStateStore( options.subagent - ? { - ...initialState, + ? chatStateWith(initialState, { activeThread: { - ...initialState.activeThread, id: "child", provenance: { kind: "subagent", @@ -39,7 +38,7 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) { agentRole: "explorer", }, }, - } + }) : initialState, ); const interruptTurn = vi.fn().mockResolvedValue({}); diff --git a/tests/features/chat/application/turns/slash-command-executor.test.ts b/tests/features/chat/application/turns/slash-command-executor.test.ts index 3ad80d0c..75b45322 100644 --- a/tests/features/chat/application/turns/slash-command-executor.test.ts +++ b/tests/features/chat/application/turns/slash-command-executor.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { activeThreadState, createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { executeSlashCommandWithState, @@ -58,7 +58,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { resetReasoningEffortToConfig: vi.fn(), }, goals: { - activeGoal: vi.fn(() => stateStore.getState().activeThread.goal), + activeGoal: vi.fn(() => activeThreadState(stateStore.getState())?.goal ?? null), setObjective: vi.fn().mockResolvedValue(true), setStatus: vi.fn().mockResolvedValue(true), clear: vi.fn().mockResolvedValue(true), diff --git a/tests/features/chat/application/turns/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts index 2882f21b..78678266 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source"; -import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { activeThreadId, createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { RestorationController } from "../../../../../src/features/chat/application/threads/restoration-controller"; import { optimisticTurnStart } from "../../../../../src/features/chat/application/turns/optimistic-turn-start"; @@ -223,7 +223,7 @@ describe("TurnSubmissionActions", () => { }); await vi.waitFor(() => expect(host.applyPendingThreadSettings).toHaveBeenCalledOnce()); - expect(stateStore.getState().activeThread.id).toBe("thread"); + expect(activeThreadId(stateStore.getState())).toBe("thread"); expect(stateStore.getState().pendingSubmission).toMatchObject({ id: pending.id, targetThreadId: "thread", @@ -522,7 +522,7 @@ describe("TurnSubmissionActions", () => { it("applies reserved runtime settings after creating a thread and before starting the turn", async () => { const { host, startTurn, stateStore } = createHost(); const applyPendingThreadSettings = vi.fn().mockImplementation(async () => { - expect(stateStore.getState().activeThread.id).toBe("thread"); + expect(activeThreadId(stateStore.getState())).toBe("thread"); return true; }); host.applyPendingThreadSettings = applyPendingThreadSettings; diff --git a/tests/features/chat/host/session-runtime.test.ts b/tests/features/chat/host/session-runtime.test.ts index 31a3e7e0..ce6b3c41 100644 --- a/tests/features/chat/host/session-runtime.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StaleAppServerSharedQueryContextError } from "../../../../src/app-server/query/shared-queries"; import type { ModelMetadata } from "../../../../src/domain/catalog/metadata"; import type { Thread } from "../../../../src/domain/threads/model"; +import { activeThreadId } from "../../../../src/features/chat/application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller"; import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/threads/resume-work"; @@ -175,7 +176,7 @@ describe("ChatPanelSessionRuntime actions", () => { await runtime.actions.startNewThread(); - expect(stateStore.getState().activeThread.id).toBeNull(); + expect(activeThreadId(stateStore.getState())).toBeNull(); expect(stateStore.getState().ui.toolbarPanel).toBeNull(); expect(stateStore.getState().connection.statusText).toBe("New chat."); expect(focusComposer).toHaveBeenCalledOnce(); @@ -187,6 +188,17 @@ describe("ChatPanelSessionRuntime actions", () => { it("does not clear the current thread while a turn is busy", async () => { const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); const { runtime, stateStore } = sessionRuntimeFixture(); + stateStore.dispatch({ + type: "turn/optimistic-started", + item: { + id: "local-user", + kind: "dialogue", + dialogueKind: "user", + role: "user", + text: "prompt", + }, + pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] }, + }); stateStore.dispatch({ type: "turn/started", threadId: "thread-1", @@ -195,7 +207,7 @@ describe("ChatPanelSessionRuntime actions", () => { await runtime.actions.startNewThread(); - expect(stateStore.getState().activeThread.id).toBe("thread-1"); + expect(activeThreadId(stateStore.getState())).toBe("thread-1"); expect(focusComposer).not.toHaveBeenCalled(); }); diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 1275238b..9def8381 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -20,6 +20,7 @@ import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-r import { deferred } from "../../../support/async"; import { installObsidianDomShims } from "../../../support/dom"; import { composerReadModelFromChatState, threadStreamReadModelFromChatState } from "../support/shell-read-model"; +import { chatStateFixture, chatStateWith } from "../support/state"; installObsidianDomShims(); @@ -127,7 +128,7 @@ describe("ChatComposerController", () => { }); it("keeps a pending web submission after a turn that completes during the fetch", () => { - const stateStore = createChatStateStore(); + const stateStore = createChatStateStore(chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } })); const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize"); if (!pending) throw new Error("Expected pending web submission"); const assistant: ThreadStreamItem = { diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index e1cd241b..8c5fccf0 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -477,7 +477,7 @@ describe("chat panel surface projections", () => { it("projects goal editor and disclosure state before action wiring", () => { let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { goal: goalFixture("thread-1") } }); + state = chatStateWith(state, { activeThread: { id: "thread-1", goal: goalFixture("thread-1") } }); state = chatStateWith(state, { ui: { goalEditor: { kind: "editing", threadId: "thread-1", objectiveDraft: "Draft goal", tokenBudgetDraft: 1234 } }, }); diff --git a/tests/features/chat/support/state.ts b/tests/features/chat/support/state.ts index 75ffd63f..8bcf5092 100644 --- a/tests/features/chat/support/state.ts +++ b/tests/features/chat/support/state.ts @@ -1,4 +1,9 @@ -import { type ChatState, createChatState } from "../../../../src/features/chat/application/state/root-reducer"; +import { + activeThreadState, + type ChatActiveThreadState, + type ChatState, + createChatState, +} from "../../../../src/features/chat/application/state/root-reducer"; interface RuntimePatch { active?: Partial; @@ -8,8 +13,7 @@ interface RuntimePatch { interface ChatStateFixturePatch { connection?: Partial; threadList?: Partial; - activeThread?: Partial; - restoration?: ChatState["restoration"]; + activeThread?: Partial> & { id?: string | null }; runtime?: RuntimePatch; turn?: Partial; threadStream?: Partial; @@ -31,8 +35,7 @@ export function chatStateWith(state: ChatState, patch: ChatStateFixturePatch): C ...state, ...(patch.connection ? { connection: { ...state.connection, ...patch.connection } } : {}), ...(patch.threadList ? { threadList: { ...state.threadList, ...patch.threadList } } : {}), - ...(patch.activeThread ? { activeThread: { ...state.activeThread, ...patch.activeThread } } : {}), - ...(patch.restoration ? { restoration: patch.restoration } : {}), + ...(patch.activeThread ? { panelThread: panelThreadWithPatch(state, patch.activeThread) } : {}), ...(patch.runtime ? { runtime: runtimeWithPatch(state.runtime, patch.runtime) } : {}), ...(patch.turn ? { turn: { ...state.turn, ...patch.turn } } : {}), ...(patch.threadStream ? { threadStream: { ...state.threadStream, ...patch.threadStream } } : {}), @@ -50,6 +53,27 @@ export function chatStateWith(state: ChatState, patch: ChatStateFixturePatch): C }; } +function panelThreadWithPatch(state: ChatState, patch: NonNullable): ChatState["panelThread"] { + if (patch.id === null) { + return { kind: "empty" }; + } + const current = activeThreadState(state); + const id = patch.id ?? current?.id; + if (!id) throw new Error("An active thread fixture patch requires a thread id."); + return { + kind: "active", + thread: { + id, + title: patch.title === undefined ? (current?.title ?? null) : patch.title, + cwd: patch.cwd === undefined ? (current?.cwd ?? null) : patch.cwd, + goal: patch.goal === undefined ? (current?.goal ?? null) : patch.goal, + tokenUsage: patch.tokenUsage === undefined ? (current?.tokenUsage ?? null) : patch.tokenUsage, + lifetime: patch.lifetime === undefined ? (current?.lifetime ?? null) : patch.lifetime, + provenance: patch.provenance === undefined ? (current?.provenance ?? null) : patch.provenance, + }, + }; +} + function runtimeWithPatch(runtime: ChatState["runtime"], patch: RuntimePatch): ChatState["runtime"] { return { active: { diff --git a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx index d3477ad9..f2134914 100644 --- a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx @@ -3,7 +3,7 @@ import { MarkdownRenderer } from "obsidian"; import { act } from "preact/test-utils"; import { describe, expect, it, vi } from "vitest"; -import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/turns/plan-implementation"; +import { implementPlanTarget } from "../../../../../src/features/chat/application/turns/plan-implementation"; import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission"; import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; @@ -799,7 +799,7 @@ describe("thread stream rendering and action menu", () => { dialogueState: "completed", } as const; const baseState = { - activeThread: { id: "thread" }, + activeThread: { id: "thread", provenance: null }, turn: { lifecycle: { kind: "idle" as const } }, runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } }, threadStream: { @@ -819,11 +819,11 @@ describe("thread stream rendering and action menu", () => { }, }; - expect(implementPlanTargetFromState(baseState)).toEqual({ itemId: secondPlan.id }); + expect(implementPlanTarget(baseState)).toEqual({ itemId: secondPlan.id }); expect( - implementPlanTargetFromState({ ...baseState, runtime: { pending: { collaborationMode: setCollaborationModeIntent("default") } } }), + implementPlanTarget({ ...baseState, runtime: { pending: { collaborationMode: setCollaborationModeIntent("default") } } }), ).toBeNull(); - expect(implementPlanTargetFromState({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull(); + expect(implementPlanTarget({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull(); }); it("does not render copy actions for tool items", () => {