diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 5931ae20..7135e13d 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -114,6 +114,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 ActiveThreadLifetime = | { readonly kind: "persistent" } | { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null }; @@ -129,6 +133,7 @@ interface ChatStateShape { connection: ChatConnectionState; threadList: ChatThreadListState; activeThread: ChatActiveThreadState; + restoration: ChatPanelRestorationState; runtime: ChatRuntimeState; turn: ChatTurnState; threadStream: ChatThreadStreamState; @@ -235,6 +240,9 @@ type ChatTransitionAction = | ActiveThreadResumedAction | ActiveThreadSettingsAppliedAction | { type: "active-thread/goal-set"; goal: ThreadGoal | null } + | { type: "panel/restored-thread-applied"; threadId: string; fallbackTitle: string | null } + | { type: "panel/restored-thread-renamed"; threadId: string; name: string | null } + | { type: "panel/view-state-cleared" } | TurnAction | RequestResolvedAction | PendingStartHookUpsertedAction; @@ -254,6 +262,7 @@ export function createChatState(): ChatState { connection: initialConnectionState(), threadList: initialThreadListState(), activeThread: initialActiveThreadState(), + restoration: initialPanelRestorationState(), runtime: initialChatRuntimeState(), turn: initialTurnState(), threadStream: initialThreadStreamState(), @@ -270,6 +279,9 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { case "active-thread/resumed": case "active-thread/settings-applied": case "active-thread/goal-set": + case "panel/restored-thread-applied": + case "panel/restored-thread-renamed": + case "panel/view-state-cleared": case "turn/started": case "turn/completed": case "turn/scoped-cleared": @@ -296,6 +308,12 @@ function reduceChatTransition(state: ChatState, action: ChatTransitionAction): C return reduceActiveThreadSettingsAppliedTransition(state, action); case "active-thread/goal-set": return reduceActiveThreadGoalSetTransition(state, action.goal); + case "panel/restored-thread-applied": + return reduceRestoredThreadAppliedTransition(state, action.threadId, action.fallbackTitle); + case "panel/restored-thread-renamed": + return reduceRestoredThreadRenamedTransition(state, action.threadId, action.name); + case "panel/view-state-cleared": + return reduceViewStateClearedTransition(state); case "turn/started": return reduceTurnStartedTransition(state, action); case "turn/completed": @@ -336,6 +354,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr lifetime: action.lifetime ?? { kind: "persistent" }, provenance: action.thread.provenance, }, + restoration: initialPanelRestorationState(), runtime: { ...runtimeBase, active: { @@ -401,10 +420,29 @@ function reduceActiveThreadGoalSetTransition(state: ChatState, goal: ThreadGoal }); } +function reduceRestoredThreadAppliedTransition(state: ChatState, threadId: string, fallbackTitle: string | null): ChatState { + const cleared = clearThreadScopedState(state); + return patchChatState(cleared, { + connection: { ...cleared.connection, statusText: "Thread ready to resume." }, + restoration: { kind: "thread", 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 } }); +} + +function reduceViewStateClearedTransition(state: ChatState): ChatState { + const cleared = clearThreadScopedState(state); + return patchChatState(cleared, { connection: { ...cleared.connection, statusText: "Idle" } }); +} + function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction): ChatState { const lifecycle = transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "started", turnId: action.turnId }); return patchChatState(state, { activeThread: { ...state.activeThread, id: action.threadId }, + restoration: initialPanelRestorationState(), turn: { lifecycle }, connection: { ...state.connection, statusText: STATUS_TURN_RUNNING }, threadStream: action.items @@ -494,6 +532,7 @@ function clearThreadScopedState(state: ChatState): ChatState { return clearTurnScopedState( patchChatState(state, { activeThread: initialActiveThreadState(), + restoration: initialPanelRestorationState(), runtime: initialChatRuntimeState(), threadStream: initialThreadStreamState(), composer: initialComposerState(), @@ -523,6 +562,7 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState connection: reduceConnectionSlice(state.connection, action), threadList: reduceThreadListSlice(state.threadList, action), activeThread: reduceActiveThreadSlice(state.activeThread, action), + restoration: state.restoration, runtime: reduceRuntimeSlice(state.runtime, action), turn: state.turn, requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests, @@ -664,6 +704,10 @@ 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 e7341670..30eccd04 100644 --- a/src/features/chat/application/state/store.ts +++ b/src/features/chat/application/state/store.ts @@ -42,6 +42,7 @@ function cloneChatState(state: ChatState): ChatState { threadsLoaded: state.threadList.threadsLoaded, }, activeThread: { ...state.activeThread }, + restoration: { ...state.restoration }, 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 b7340ec5..89a33bf5 100644 --- a/src/features/chat/application/threads/active-thread-identity-sync.ts +++ b/src/features/chat/application/threads/active-thread-identity-sync.ts @@ -1,13 +1,10 @@ import type { ChatStateStore } from "../state/store"; -import type { RestorationController } from "./restoration-controller"; export interface ActiveThreadIdentitySyncHost { stateStore: ChatStateStore; - restoration: RestorationController; invalidateThreadWork: () => void; resetThreadTurnPresence: (hadTurns: boolean) => void; notifyActiveThreadIdentityChanged: () => void; - refreshTabHeader: () => void; } export interface ActiveThreadIdentitySync { @@ -32,29 +29,22 @@ export function createActiveThreadIdentitySync(host: ActiveThreadIdentitySyncHos function clearActiveThreadIdentity(host: ActiveThreadIdentitySyncHost): void { host.invalidateThreadWork(); - host.restoration.clear(); host.stateStore.dispatch({ type: "active-thread/cleared" }); host.resetThreadTurnPresence(false); host.notifyActiveThreadIdentityChanged(); } function applyThreadArchiveToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string): void { - if (host.stateStore.getState().activeThread.id !== threadId && !host.restoration.isPending(threadId)) return; + const state = host.stateStore.getState(); + if (state.activeThread.id !== threadId && !(state.restoration.kind === "thread" && state.restoration.threadId === threadId)) return; clearActiveThreadIdentity(host); } function applyThreadRenameToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string, name: string | null): void { - let changed = false; - const restoredThread = host.restoration.placeholder(); - if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { - host.restoration.rename(threadId, name); - changed = true; - } - const activeThreadChanged = host.stateStore.getState().activeThread.id === threadId || host.restoration.isPending(threadId); - if (!changed && !activeThreadChanged) return; - if (activeThreadChanged) { - host.notifyActiveThreadIdentityChanged(); - } else { - host.refreshTabHeader(); - } + const state = host.stateStore.getState(); + const restoredThreadChanged = state.restoration.kind === "thread" && state.restoration.threadId === threadId; + const activeThreadChanged = state.activeThread.id === 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/restoration-controller.ts b/src/features/chat/application/threads/restoration-controller.ts index dce34a04..69418222 100644 --- a/src/features/chat/application/threads/restoration-controller.ts +++ b/src/features/chat/application/threads/restoration-controller.ts @@ -1,74 +1,45 @@ -import { - type RestoredThreadLifecycleState, - type RestoredThreadPlaceholderState, - type RestoredThreadState, - transitionRestoredThreadLifecycle, -} from "./restored-thread-lifecycle"; - -const STATUS_THREAD_READY_TO_RESUME = "Thread ready to resume."; +import type { ChatStateStore } from "../state/store"; export interface RestorationControllerHost { - invalidateThreadWork: () => void; - setStatus: (status: string) => void; - refreshTabHeader: () => void; + stateStore: ChatStateStore; } export type RestoredThreadLoader = (threadId: string) => Promise; export class RestorationController { - private lifecycle: RestoredThreadLifecycleState = { kind: "idle" }; + private loading: { threadId: string; promise: Promise } | null = null; constructor(private readonly host: RestorationControllerHost) {} - placeholder(): RestoredThreadPlaceholderState | null { - return this.lifecycle.kind === "placeholder" ? this.lifecycle : null; - } - - title(): string | null { - return this.placeholder()?.title ?? null; - } - - clear(): void { - this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "cleared" }); - } - - rename(threadId: string, name: string | null): boolean { - const previous = this.placeholder(); - this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "renamed", threadId, name }); - return this.placeholder() !== previous; - } - - restore(restoredThread: RestoredThreadState): void { - this.host.invalidateThreadWork(); - this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { - type: "placeholder-restored", - restoredThread, - }); - this.host.setStatus(STATUS_THREAD_READY_TO_RESUME); - this.host.refreshTabHeader(); + invalidate(): void { + this.loading = null; } async ensureLoaded(loadThread: RestoredThreadLoader): Promise { - const restoredThread = this.placeholder(); - if (!restoredThread) return true; - if (restoredThread.loading) { - const threadId = restoredThread.threadId; - await restoredThread.loading; - return !this.isPending(threadId); + const restoredThread = this.host.stateStore.getState().restoration; + if (restoredThread.kind !== "thread") return true; + if (this.loading?.threadId === restoredThread.threadId) { + await this.loading.promise; + return this.restorationLoaded(); } const threadId = restoredThread.threadId; - const loading = loadThread(threadId); - this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "loading-started", loading }); + const loading = { threadId, promise: loadThread(threadId) }; + this.loading = loading; try { - await loading; + await loading.promise; } finally { - this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "loading-finished", loading }); + if (this.loading === loading) this.loading = null; } - return !this.isPending(threadId); + return this.restorationLoaded(); } isPending(threadId: string): boolean { - return this.placeholder()?.threadId === threadId; + const restoration = this.host.stateStore.getState().restoration; + return restoration.kind === "thread" && restoration.threadId === threadId; + } + + private restorationLoaded(): boolean { + return this.host.stateStore.getState().restoration.kind === "none"; } } diff --git a/src/features/chat/application/threads/restored-thread-lifecycle.ts b/src/features/chat/application/threads/restored-thread-lifecycle.ts deleted file mode 100644 index bceb7927..00000000 --- a/src/features/chat/application/threads/restored-thread-lifecycle.ts +++ /dev/null @@ -1,50 +0,0 @@ -export interface RestoredThreadState { - threadId: string; - title: string | null; - explicitName: string | null; -} - -export type RestoredThreadLifecycleState = - | { kind: "idle" } - | { kind: "placeholder"; threadId: string; title: string | null; explicitName: string | null; loading: Promise | null }; -export type RestoredThreadPlaceholderState = Extract; -export type RestoredThreadLifecycleEvent = - | { type: "placeholder-restored"; restoredThread: RestoredThreadState } - | { type: "renamed"; threadId: string; name: string | null } - | { type: "loading-started"; loading: Promise } - | { type: "loading-finished"; loading: Promise } - | { type: "cleared" }; - -export function transitionRestoredThreadLifecycle( - state: RestoredThreadLifecycleState, - event: RestoredThreadLifecycleEvent, -): RestoredThreadLifecycleState { - switch (event.type) { - case "placeholder-restored": - return { kind: "placeholder", ...event.restoredThread, loading: null }; - case "renamed": - if (state.kind !== "placeholder" || state.threadId !== event.threadId) return state; - return { ...state, title: event.name, explicitName: event.name }; - case "loading-started": - if (state.kind !== "placeholder") return state; - return { ...state, loading: event.loading }; - case "loading-finished": - if (state.kind !== "placeholder" || state.loading !== event.loading) return state; - return { ...state, loading: null }; - case "cleared": - return state.kind === "idle" ? state : { kind: "idle" }; - } -} - -export function parseRestoredThreadState(state: unknown): RestoredThreadState | null { - if (!state || typeof state !== "object") return null; - const record = state as Record; - const threadId = record["threadId"]; - if (typeof threadId !== "string" || threadId.trim().length === 0) return null; - const title = record["threadTitle"]; - return { - threadId, - title: typeof title === "string" && title.trim().length > 0 ? title : null, - explicitName: null, - }; -} diff --git a/src/features/chat/application/threads/resume-actions.ts b/src/features/chat/application/threads/resume-actions.ts index 66c62b33..5d583d26 100644 --- a/src/features/chat/application/threads/resume-actions.ts +++ b/src/features/chat/application/threads/resume-actions.ts @@ -3,7 +3,6 @@ import { resumedThreadAction } from "../state/actions"; import type { ChatStateStore } from "../state/store"; import { threadStreamIsEmpty } from "../state/thread-stream"; import type { HistoryController } from "./history-controller"; -import type { RestorationController } from "./restoration-controller"; import type { ActiveChatResume, ChatResumeWorkTracker } from "./resume-work"; import type { ThreadResumeSnapshot, ThreadResumeTransport } from "./thread-loading-transport"; import { canSwitchToThread } from "./thread-switching"; @@ -12,7 +11,6 @@ export interface ResumeActionsHost { stateStore: ChatStateStore; resumeWork: ChatResumeWorkTracker; history: HistoryController; - restoration: RestorationController; resumeTransport: ThreadResumeTransport; closing: () => boolean; resetThreadTurnPresence: (hadTurns: boolean) => void; @@ -73,7 +71,6 @@ function applyResumedThread(host: ResumeActionsHost, response: ThreadResumeSnaps listedThreads: host.stateStore.getState().threadList.listedThreads, }), ); - host.restoration.clear(); host.resetThreadTurnPresence(false); host.notifyActiveThreadIdentityChanged(); } diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index b8786001..0e3008a1 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -78,7 +78,6 @@ interface ChatPanelThreadLifecycleInput { status: ChatPanelThreadStatus; threadStart: ThreadStartActions; foundation: ChatPanelThreadFoundation; - refreshTabHeader: () => void; refreshLiveState: () => void; notifyActiveThreadIdentityChanged: () => void; } @@ -190,17 +189,8 @@ export function createThreadLifecycleBundle( host: ChatPanelThreadHost, input: ChatPanelThreadLifecycleInput, ): ChatPanelThreadLifecycleBundle { - const { - appServer, - localItemIds, - ensureConnected, - status, - threadStart, - foundation, - refreshTabHeader, - refreshLiveState, - notifyActiveThreadIdentityChanged, - } = input; + const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, refreshLiveState, notifyActiveThreadIdentityChanged } = + input; const goals = createGoalActions({ stateStore: host.stateStore, goalTransport: appServer.threadGoal, @@ -230,7 +220,6 @@ export function createThreadLifecycleBundle( invalidateThreadWork: () => { foundation.invalidateThreadWork(); }, - refreshTabHeader, refreshLiveState, notifyActiveThreadIdentityChanged, }); @@ -305,7 +294,6 @@ function createSessionThreadLifecycle( autoTitleCoordinator: AutoTitleCoordinator; history: HistoryController; invalidateThreadWork: () => void; - refreshTabHeader: () => void; refreshLiveState: () => void; notifyActiveThreadIdentityChanged: () => void; }, @@ -317,14 +305,11 @@ function createSessionThreadLifecycle( autoTitleCoordinator, history, invalidateThreadWork, - refreshTabHeader, refreshLiveState, notifyActiveThreadIdentityChanged, } = input; const restoration = new RestorationController({ - invalidateThreadWork, - setStatus: status.set, - refreshTabHeader, + stateStore: host.stateStore, }); const resetThreadTurnPresence = (hadTurns: boolean) => { autoTitleCoordinator.resetThreadTurnPresence(hadTurns); @@ -334,7 +319,6 @@ function createSessionThreadLifecycle( resumeTransport: appServer.threadResume, resumeWork: host.resumeWork, history, - restoration, closing: host.getClosing, resetThreadTurnPresence, notifyActiveThreadIdentityChanged, @@ -346,11 +330,9 @@ function createSessionThreadLifecycle( }); const identity = createActiveThreadIdentitySync({ stateStore: host.stateStore, - restoration, invalidateThreadWork, resetThreadTurnPresence, notifyActiveThreadIdentityChanged, - refreshTabHeader, }); return { diff --git a/src/features/chat/host/session-runtime.ts b/src/features/chat/host/session-runtime.ts index 59da9d86..5d38e6a8 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -192,7 +192,6 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat status, threadStart, foundation: threadFoundation, - refreshTabHeader, refreshLiveState, notifyActiveThreadIdentityChanged, }); @@ -312,6 +311,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat actions: { invalidateThreadWork: () => { threadFoundation.invalidateThreadWork(); + threadLifecycle.restoration.invalidate(); }, reconnect, refreshSharedThreads, diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 3048ee25..d55d25aa 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -2,15 +2,15 @@ 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 { ChatState } from "../application/state/root-reducer"; +import type { ChatPanelRestorationState, ChatState } from "../application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../application/state/store"; -import { parseRestoredThreadState, type RestoredThreadPlaceholderState } from "../application/threads/restored-thread-lifecycle"; import { ChatResumeWorkTracker } from "../application/threads/resume-work"; import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom"; import { type ChatThreadStreamScrollBinding, createChatThreadStreamScrollBinding } from "../panel/thread-stream-scroll-binding"; import type { ChatPanelEnvironment, ChatPanelHandle, ChatWorkspacePanelSnapshot, ChatWorkspacePanelTurnLifecycle } from "./contracts"; import { type ChatViewDeferredTasks, createChatViewDeferredTasks } from "./session/deferred-work"; import { ChatPanelSessionRuntime } from "./session-runtime"; +import { parseChatPanelViewState } from "./view-state"; export class ChatPanelSession implements ChatPanelHandle { private readonly stateStore: ChatStateStore = createChatStateStore(); @@ -20,7 +20,6 @@ export class ChatPanelSession implements ChatPanelHandle { private readonly resumeWork = new ChatResumeWorkTracker(); private readonly threadStreamScrollBinding: ChatThreadStreamScrollBinding = createChatThreadStreamScrollBinding(); private observedAppServerContext: AppServerQueryContext; - private ephemeralSourcePlaceholder: { threadId: string; title: string | null } | null = null; private opened = false; private closing = false; @@ -31,7 +30,7 @@ export class ChatPanelSession implements ChatPanelHandle { } displayTitle(): string { - if (this.state.activeThread.lifetime?.kind === "ephemeral" || (!this.state.activeThread.id && this.ephemeralSourcePlaceholder)) { + if (this.state.activeThread.lifetime?.kind === "ephemeral") { return "Side chat"; } return threadWindowTitle(this.panelThreadId(), this.state.threadList.listedThreads, this.restoredThreadTitle()); @@ -45,9 +44,6 @@ export class ChatPanelSession implements ChatPanelHandle { ephemeralSource: { threadId: lifetime.sourceThreadId, title: lifetime.sourceThreadTitle }, }; } - if (!this.state.activeThread.id && this.ephemeralSourcePlaceholder) { - return { version: 2, ephemeralSource: { ...this.ephemeralSourcePlaceholder } }; - } const threadId = this.panelThreadId(); if (!threadId) return { version: 1 }; @@ -60,31 +56,20 @@ export class ChatPanelSession implements ChatPanelHandle { } applyViewState(state: unknown): void { - const ephemeralSource = parseEphemeralSourceState(state); - if (ephemeralSource) { - this.ephemeralSourcePlaceholder = ephemeralSource; - this.runtime.actions.invalidateThreadWork(); - this.runtime.thread.restoration.clear(); + const restoredState = parseChatPanelViewState(state); + this.runtime.actions.invalidateThreadWork(); + if (restoredState.kind === "thread") { this.stateStore.dispatch({ - type: "thread-stream/system-item-added", - item: { - id: "restored-side-chat-unavailable", - kind: "system", - role: "system", - text: "This side conversation is no longer available.", - }, + type: "panel/restored-thread-applied", + threadId: restoredState.threadId, + fallbackTitle: restoredState.fallbackTitle, }); - return; - } - this.ephemeralSourcePlaceholder = null; - const restoredThread = parseRestoredThreadState(state); - if (restoredThread) { - this.runtime.thread.restoration.restore(restoredThread); + this.environment.view.refreshTabHeader(); return; } - this.runtime.actions.invalidateThreadWork(); - this.runtime.thread.restoration.clear(); + this.stateStore.dispatch({ type: "panel/view-state-cleared" }); + this.environment.view.refreshTabHeader(); this.scheduleWarmup(); } @@ -140,13 +125,13 @@ export class ChatPanelSession implements ChatPanelHandle { async openThread(threadId: string): Promise { if (!(await this.runtime.thread.ephemeral.prepareForPersistentNavigation())) return; - this.ephemeralSourcePlaceholder = null; await this.runtime.thread.resume.resumeThread(threadId); this.focusComposer(); } async focusThread(threadId: string | null = null): Promise { - const restoredThreadId = this.restoredThread()?.threadId ?? null; + const restoredThread = this.restoredThread(); + const restoredThreadId = restoredThread.kind === "thread" ? restoredThread.threadId : null; if ((threadId && this.runtime.thread.restoration.isPending(threadId)) || (!threadId && restoredThreadId)) { await this.ensureRestoredThreadLoaded(); } @@ -166,11 +151,7 @@ export class ChatPanelSession implements ChatPanelHandle { } applyThreadRenamed(threadId: string, name: string | null): void { - const previousRestoredExplicitName = this.restoredThread()?.explicitName ?? null; this.runtime.thread.identity.applyThreadRenameToActiveIdentity(threadId, name); - if (this.restoredThread()?.explicitName !== previousRestoredExplicitName) { - this.mountOrRepairShell(); - } } open(): void { @@ -203,13 +184,11 @@ export class ChatPanelSession implements ChatPanelHandle { async startNewThread(): Promise { await this.runtime.actions.startNewThread(); - if (!this.state.activeThread.id) this.ephemeralSourcePlaceholder = null; } async openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise { const opened = await this.runtime.thread.ephemeral.open(input); if (!opened) return false; - this.ephemeralSourcePlaceholder = null; this.focusComposer(); return true; } @@ -261,15 +240,19 @@ export class ChatPanelSession implements ChatPanelHandle { } private restoredThreadTitle(): string | null { - return this.restoredThread()?.title ?? null; + const restoredThread = this.restoredThread(); + if (restoredThread.kind !== "thread") return null; + const listedThread = this.state.threadList.listedThreads.find((thread) => thread.id === restoredThread.threadId); + return listedThread ? threadMeaningfulTitle(listedThread) : restoredThread.fallbackTitle; } - private restoredThread(): RestoredThreadPlaceholderState | null { - return this.runtime.thread.restoration.placeholder(); + private restoredThread(): ChatPanelRestorationState { + return this.state.restoration; } private panelThreadId(): string | null { - return this.restoredThread()?.threadId ?? this.state.activeThread.id; + const restoredThread = this.restoredThread(); + return restoredThread.kind === "thread" ? restoredThread.threadId : this.state.activeThread.id; } private ensureRestoredThreadLoaded(): Promise { @@ -289,16 +272,6 @@ export class ChatPanelSession implements ChatPanelHandle { } } -function parseEphemeralSourceState(state: unknown): { threadId: string; title: string | null } | null { - if (!state || typeof state !== "object") return null; - const source = (state as { ephemeralSource?: unknown }).ephemeralSource; - if (!source || typeof source !== "object") return null; - const threadId = (source as { threadId?: unknown }).threadId; - const title = (source as { title?: unknown }).title; - if (typeof threadId !== "string" || threadId.length === 0) return null; - return { threadId, title: typeof title === "string" ? title : null }; -} - function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): ChatWorkspacePanelTurnLifecycle { if (state.kind === "running") return { kind: "running", turnId: state.turnId }; if (state.kind === "starting") return { kind: "starting" }; diff --git a/src/features/chat/host/view-state.ts b/src/features/chat/host/view-state.ts new file mode 100644 index 00000000..383f54e8 --- /dev/null +++ b/src/features/chat/host/view-state.ts @@ -0,0 +1,22 @@ +export type ParsedChatPanelViewState = { kind: "empty" } | { kind: "thread"; threadId: string; fallbackTitle: string | null }; + +export function parseChatPanelViewState(state: unknown): ParsedChatPanelViewState { + if (!state || typeof state !== "object") return { kind: "empty" }; + const record = state as Record; + if (isEphemeralSourceState(record["ephemeralSource"])) return { kind: "empty" }; + + const threadId = record["threadId"]; + if (typeof threadId !== "string" || threadId.trim().length === 0) return { kind: "empty" }; + const title = record["threadTitle"]; + return { + kind: "thread", + threadId, + fallbackTitle: typeof title === "string" && title.trim().length > 0 ? title : null, + }; +} + +function isEphemeralSourceState(source: unknown): boolean { + if (!source || typeof source !== "object") return false; + const threadId = (source as { threadId?: unknown }).threadId; + return typeof threadId === "string" && threadId.length > 0; +} diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index cc3cb3a5..ae089cfe 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -4,6 +4,7 @@ import { VIEW_TYPE_CODEX_PANEL } from "../constants"; import { hasPendingRequests, pendingRequestCounts } from "../domain/pending-requests/aggregate"; import type { ChatWorkspacePanelSnapshot, ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; import { CodexChatView } from "../features/chat/host/view.obsidian"; +import { parseChatPanelViewState } from "../features/chat/host/view-state"; type ThreadPanelTarget = | { @@ -446,10 +447,8 @@ function workspacePanelSurface(view: CodexChatView): ChatWorkspacePanelSurface { } function restoredThreadId(leaf: WorkspaceLeaf): string | null { - const state = leaf.getViewState().state; - if (!state || typeof state !== "object") return null; - const threadId = (state as { threadId?: unknown }).threadId; - return typeof threadId === "string" && threadId.length > 0 ? threadId : null; + const state = parseChatPanelViewState(leaf.getViewState().state); + return state.kind === "thread" ? state.threadId : null; } function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): WorkspacePanelSnapshot | null { diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index 7d8f904c..a11397a7 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -11,6 +11,24 @@ import { chatStateFixture, chatStateWith } from "../../support/state"; import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; describe("chatReducer", () => { + it("applies restored thread identity as an atomic thread-scoped transition", () => { + const state = threadScopedResidue({ threadId: "old-thread", draft: "stale draft", itemId: "stale-item" }); + + const restored = chatReducer(state, { + type: "panel/restored-thread-applied", + threadId: "restored-thread", + fallbackTitle: "Restored title", + }); + + expect(restored.restoration).toEqual({ kind: "thread", threadId: "restored-thread", fallbackTitle: "Restored title" }); + expect(restored.activeThread.id).toBeNull(); + 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); + }); + it("clears active turn and thread-scoped state", () => { let state = threadScopedResidue({ draft: "keep me", itemId: "m1" }); state = chatStateWith(state, { 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 076ec498..630d1525 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 @@ -3,8 +3,6 @@ import type { Thread } from "../../../../../src/domain/threads/model"; import { 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"; -import type { RestorationController } from "../../../../../src/features/chat/application/threads/restoration-controller"; -import type { RestoredThreadPlaceholderState } from "../../../../../src/features/chat/application/threads/restored-thread-lifecycle"; function thread(id: string, name: string | null = null): Thread { return { @@ -18,31 +16,20 @@ function thread(id: string, name: string | null = null): Thread { }; } -function createIdentitySyncHarness(options: { restoredThreadPending?: boolean } = {}) { +function createIdentitySyncHarness() { const stateStore = createChatStateStore(createChatState()); - const restoredPlaceholder = vi.fn<() => RestoredThreadPlaceholderState | null>(() => null); - const restoredClear = vi.fn(); - const restoredRename = vi.fn(); - const restoration = { - clear: restoredClear, - isPending: vi.fn(() => options.restoredThreadPending ?? false), - placeholder: restoredPlaceholder, - rename: restoredRename, - } as unknown as RestorationController; const host = { stateStore, - restoration, invalidateThreadWork: vi.fn(), resetThreadTurnPresence: vi.fn(), notifyActiveThreadIdentityChanged: vi.fn(), - refreshTabHeader: vi.fn(), }; - return { sync: createActiveThreadIdentitySync(host), host, restoredClear, restoredPlaceholder, restoredRename, stateStore }; + return { sync: createActiveThreadIdentitySync(host), host, stateStore }; } describe("createActiveThreadIdentitySync", () => { it("clears active thread identity as a complete archive transaction", () => { - const { sync, host, restoredClear, stateStore } = createIdentitySyncHarness(); + const { sync, host, stateStore } = createIdentitySyncHarness(); stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -63,14 +50,12 @@ describe("createActiveThreadIdentitySync", () => { expect(stateStore.getState().activeThread.id).toBeNull(); expect(host.invalidateThreadWork).toHaveBeenCalledOnce(); - expect(restoredClear).toHaveBeenCalledOnce(); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); - expect(host.refreshTabHeader).not.toHaveBeenCalled(); }); it("ignores archive notifications for non-active threads without identity side effects", () => { - const { sync, host, restoredClear, stateStore } = createIdentitySyncHarness(); + const { sync, host, stateStore } = createIdentitySyncHarness(); stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -91,27 +76,25 @@ describe("createActiveThreadIdentitySync", () => { expect(stateStore.getState().activeThread.id).toBe("active"); expect(host.invalidateThreadWork).not.toHaveBeenCalled(); - expect(restoredClear).not.toHaveBeenCalled(); expect(host.resetThreadTurnPresence).not.toHaveBeenCalled(); expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); - expect(host.refreshTabHeader).not.toHaveBeenCalled(); }); it("clears pending restored thread identity when that thread is archived", () => { - const { sync, host, restoredClear, stateStore } = createIdentitySyncHarness({ restoredThreadPending: true }); + const { sync, host, stateStore } = createIdentitySyncHarness(); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "thread", fallbackTitle: "Restored" }); sync.applyThreadArchiveToActiveIdentity("thread"); expect(stateStore.getState().activeThread.id).toBeNull(); expect(host.invalidateThreadWork).toHaveBeenCalledOnce(); - expect(restoredClear).toHaveBeenCalledOnce(); + expect(stateStore.getState().restoration).toEqual({ kind: "none" }); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); - expect(host.refreshTabHeader).not.toHaveBeenCalled(); }); it("routes active thread rename notifications through active identity refresh", () => { - const { sync, host, restoredRename, stateStore } = createIdentitySyncHarness(); + const { sync, host, stateStore } = createIdentitySyncHarness(); stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -131,37 +114,25 @@ describe("createActiveThreadIdentitySync", () => { sync.applyThreadRenameToActiveIdentity("thread", "New"); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); - expect(host.refreshTabHeader).not.toHaveBeenCalled(); - expect(restoredRename).not.toHaveBeenCalled(); }); it("routes pending restored thread rename notifications through active identity refresh", () => { - const { sync, host, restoredPlaceholder, restoredRename } = createIdentitySyncHarness({ restoredThreadPending: true }); - restoredPlaceholder.mockReturnValue({ - kind: "placeholder", - threadId: "thread", - title: "Old", - explicitName: "Old", - loading: null, - }); + const { sync, host, stateStore } = createIdentitySyncHarness(); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "thread", fallbackTitle: "Old" }); sync.applyThreadRenameToActiveIdentity("thread", "New"); - expect(restoredRename).toHaveBeenCalledWith("thread", "New"); + expect(stateStore.getState().restoration).toEqual({ kind: "thread", threadId: "thread", fallbackTitle: "New" }); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); - expect(host.refreshTabHeader).not.toHaveBeenCalled(); }); it("ignores rename notifications for inactive and unrestored threads without identity side effects", () => { - const { sync, host, restoredPlaceholder, restoredRename, stateStore } = createIdentitySyncHarness(); + const { sync, host, stateStore } = createIdentitySyncHarness(); stateStore.dispatch({ type: "thread-list/applied", threads: [thread("thread", "Old")] }); sync.applyThreadRenameToActiveIdentity("other", "New"); expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Old"); - expect(restoredPlaceholder).toHaveBeenCalledOnce(); - expect(restoredRename).not.toHaveBeenCalled(); - expect(host.refreshTabHeader).not.toHaveBeenCalled(); expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); }); }); diff --git a/tests/features/chat/application/threads/restoration-controller.test.ts b/tests/features/chat/application/threads/restoration-controller.test.ts index e6580ff7..88808018 100644 --- a/tests/features/chat/application/threads/restoration-controller.test.ts +++ b/tests/features/chat/application/threads/restoration-controller.test.ts @@ -2,17 +2,15 @@ import { describe, expect, it, vi } from "vitest"; +import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { RestorationController } from "../../../../../src/features/chat/application/threads/restoration-controller"; import { deferred } from "../../../../support/async"; describe("RestorationController", () => { - it("restores a placeholder for explicit hydration", async () => { + it("loads the store-owned restored thread on explicit hydration", async () => { const resumeThread = vi.fn().mockResolvedValue(undefined); - const controller = restoredThreadControllerFixture(); - - controller.restore({ threadId: "thread", title: "Title", explicitName: null }); - - expect(controller.placeholder()).toMatchObject({ threadId: "thread", title: "Title" }); + const { controller, stateStore } = restoredThreadControllerFixture(); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "thread", fallbackTitle: "Title" }); expect(resumeThread).not.toHaveBeenCalled(); await controller.ensureLoaded(resumeThread); @@ -23,35 +21,36 @@ describe("RestorationController", () => { it("shares an in-flight restore load", async () => { const resume = deferred(); const loadThread = vi.fn(() => resume.promise); - const controller = restoredThreadControllerFixture(); - controller.restore({ threadId: "thread", title: null, explicitName: null }); + const { controller, stateStore } = restoredThreadControllerFixture(); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "thread", fallbackTitle: null }); const first = controller.ensureLoaded(loadThread); const second = controller.ensureLoaded(loadThread); await Promise.resolve(); - expect(controller.placeholder()?.loading).toBe(resume.promise); + expect(loadThread).toHaveBeenCalledOnce(); resume.resolve(undefined); await Promise.all([first, second]); - expect(controller.placeholder()?.loading).toBeNull(); + expect(loadThread).toHaveBeenCalledOnce(); }); - it("updates placeholder rename state without touching other threads", () => { - const controller = restoredThreadControllerFixture(); - controller.restore({ threadId: "thread", title: "Old", explicitName: null }); + it("does not load a replaced restored thread through a stale request", async () => { + const first = deferred(); + const loadThread = vi.fn(() => first.promise); + const { controller, stateStore } = restoredThreadControllerFixture(); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "first", fallbackTitle: null }); + const loading = controller.ensureLoaded(loadThread); - expect(controller.rename("other", "Other")).toBe(false); - expect(controller.rename("thread", "New")).toBe(true); - expect(controller.placeholder()).toMatchObject({ title: "New", explicitName: "New" }); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "second", fallbackTitle: null }); + first.resolve(undefined); + + await expect(loading).resolves.toBe(false); + expect(controller.isPending("second")).toBe(true); }); }); -function restoredThreadControllerFixture(overrides: Partial[0]> = {}) { - return new RestorationController({ - invalidateThreadWork: vi.fn(), - setStatus: vi.fn(), - refreshTabHeader: vi.fn(), - ...overrides, - }); +function restoredThreadControllerFixture() { + const stateStore = createChatStateStore(); + return { controller: new RestorationController({ stateStore }), stateStore }; } diff --git a/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts b/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts deleted file mode 100644 index f64a48f2..00000000 --- a/tests/features/chat/application/threads/restored-thread-lifecycle.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - parseRestoredThreadState, - transitionRestoredThreadLifecycle, -} from "../../../../../src/features/chat/application/threads/restored-thread-lifecycle"; - -describe("transitionRestoredThreadLifecycle", () => { - it("clears restored-thread loading only for the active loading promise", () => { - const firstLoading = Promise.resolve(); - const secondLoading = Promise.resolve(); - const placeholder = transitionRestoredThreadLifecycle( - { kind: "idle" }, - { type: "placeholder-restored", restoredThread: { threadId: "thread", title: "Old", explicitName: null } }, - ); - const loading = transitionRestoredThreadLifecycle(placeholder, { type: "loading-started", loading: secondLoading }); - - expect(transitionRestoredThreadLifecycle(loading, { type: "loading-finished", loading: firstLoading })).toBe(loading); - expect(transitionRestoredThreadLifecycle(loading, { type: "renamed", threadId: "thread", name: "New" })).toMatchObject({ - title: "New", - explicitName: "New", - loading: secondLoading, - }); - expect(transitionRestoredThreadLifecycle(loading, { type: "loading-finished", loading: secondLoading })).toMatchObject({ - kind: "placeholder", - loading: null, - }); - }); - - it("parses restored thread view state defensively", () => { - expect(parseRestoredThreadState({ threadId: "thread", threadTitle: "Title" })).toEqual({ - threadId: "thread", - title: "Title", - explicitName: null, - }); - expect(parseRestoredThreadState({ threadId: "" })).toBeNull(); - expect(parseRestoredThreadState(null)).toBeNull(); - }); -}); diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 3cb6f7ca..820636b0 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -5,7 +5,6 @@ import type { Thread as PanelThread } from "../../../../../src/domain/threads/mo import { 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 type { RestorationController } from "../../../../../src/features/chat/application/threads/restoration-controller"; import { createResumeActions, type ResumeActionsHost } from "../../../../../src/features/chat/application/threads/resume-actions"; import { ChatResumeWorkTracker } from "../../../../../src/features/chat/application/threads/resume-work"; import type { @@ -43,12 +42,10 @@ function createActions(response: ThreadResumeSnapshot | null = activation("threa const loadLatest = vi.fn().mockResolvedValue(undefined); const applyLatestPage = vi.fn(); const invalidateHistory = vi.fn(); - const restoredClear = vi.fn(); const host = { stateStore, resumeWork: new ChatResumeWorkTracker(), history: { loadLatest, applyLatestPage, invalidate: invalidateHistory } as unknown as HistoryController, - restoration: { clear: restoredClear } as unknown as RestorationController, resumeTransport: { resumeThread }, closing: () => false, systemItem: (text: string) => ({ id: "system", kind: "system" as const, role: "system" as const, text }), @@ -65,7 +62,6 @@ function createActions(response: ThreadResumeSnapshot | null = activation("threa applyLatestPage, invalidateHistory, loadLatest, - restoredClear, resumeThread, stateStore, }; @@ -73,7 +69,7 @@ function createActions(response: ThreadResumeSnapshot | null = activation("threa describe("ResumeActions", () => { it("resumes the thread and loads its latest history", async () => { - const { actions, host, loadLatest, restoredClear, resumeThread, stateStore } = createActions(); + const { actions, host, loadLatest, resumeThread, stateStore } = createActions(); await actions.resumeThread("thread"); @@ -81,7 +77,6 @@ describe("ResumeActions", () => { expect(host.syncThreadGoal).toHaveBeenCalledWith("thread"); expect(stateStore.getState().activeThread.id).toBe("thread"); expect(loadLatest).toHaveBeenCalledWith("thread"); - expect(restoredClear).toHaveBeenCalledOnce(); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); }); @@ -98,14 +93,13 @@ describe("ResumeActions", () => { }); it("does not change active thread when the resume transport has no snapshot", async () => { - const { actions, host, loadLatest, restoredClear, resumeThread, stateStore } = createActions(null); + const { actions, host, loadLatest, resumeThread, stateStore } = createActions(null); await actions.resumeThread("thread"); expect(resumeThread).toHaveBeenCalledWith("thread"); expect(stateStore.getState().activeThread.id).toBeNull(); expect(loadLatest).not.toHaveBeenCalled(); - expect(restoredClear).not.toHaveBeenCalled(); expect(host.syncThreadGoal).not.toHaveBeenCalled(); }); 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 9967cd2c..a6fb1261 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -5,6 +5,7 @@ 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 { 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"; import { createTurnSubmissionActions, @@ -108,6 +109,27 @@ function resumeSubagentThread(stateStore: ReturnType { + it("aborts when restoration changes target while hydration is pending", async () => { + const { host, startTurn, stateStore } = createHost(); + const restoration = new RestorationController({ stateStore }); + const resume = deferred(); + const loadThread = vi.fn(() => resume.promise); + host.ensureRestoredThreadLoaded = () => restoration.ensureLoaded(loadThread); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "first", fallbackTitle: null }); + const actions = createTurnSubmissionActions(host); + + const submitting = actions.sendTurnText({ text: "hello" }); + await vi.waitFor(() => { + expect(loadThread).toHaveBeenCalledWith("first"); + }); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "second", fallbackTitle: null }); + resume.resolve(undefined); + + await expect(submitting).resolves.toBe(false); + expect(host.startThread).not.toHaveBeenCalled(); + expect(startTurn).not.toHaveBeenCalled(); + }); + it("blocks direct turn submission after a restored thread resolves to a subagent", async () => { const { host, startTurn, stateStore } = createHost({ ensureRestoredThreadLoaded: vi.fn().mockImplementation(async () => { diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 291770f4..9f5ea5d5 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -414,6 +414,27 @@ describe("CodexChatView connection lifecycle", () => { ); }); + it("replaces active thread-scoped state when late workspace state restores another thread", async () => { + const client = connectedClient(); + connectionMock.state.client = client; + const view = await chatView(); + + await view.onOpen(); + await view.surface.openThread("thread-1"); + view.surface.setComposerText("stale draft"); + + await view.setState({ threadId: "thread-2", threadTitle: "Restored thread 2" }, {} as never); + + expect(view.getDisplayText()).toBe("Codex: Restored thread 2"); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread 2" }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ + threadId: "thread-2", + turnLifecycle: { kind: "idle" }, + hasComposerDraft: false, + }); + expect(composerElement(view).value).toBe(""); + }); + it("warms app-server metadata for an empty restored panel after the shell is open", async () => { vi.useFakeTimers(); const client = connectedClient({ @@ -496,6 +517,32 @@ describe("CodexChatView connection lifecycle", () => { ); }); + it("starts fresh hydration when the same restored view state is reapplied", async () => { + const resume = deferred>(); + const client = connectedClient({ + "thread/resume": vi.fn(() => resume.promise), + }); + connectionMock.state.client = client; + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + await view.onOpen(); + const firstHydration = view.surface.focusThread("thread-1"); + await waitForAsyncWork(() => { + expectRequestTimes(client, "thread/resume", 1); + }); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + const secondHydration = view.surface.focusThread("thread-1"); + await waitForAsyncWork(() => { + expectRequestTimes(client, "thread/resume", 2); + }); + + resume.resolve(resumedThread("thread-1")); + await Promise.all([firstHydration, secondHydration]); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); + }); + it("resumes a restored thread before sending the first message", async () => { const client = connectedClient(); connectionMock.state.client = client; @@ -563,15 +610,14 @@ describe("CodexChatView connection lifecycle", () => { expect(requestSaveLayout).toHaveBeenCalledTimes(2); }); - it("turns a restored unavailable side-chat tab into a normal empty chat", async () => { + it("restores an unavailable side-chat tab as a normal empty chat", async () => { const view = await chatView(); await view.setState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } }, {} as never); - expect(view.getDisplayText()).toBe("Side chat"); - await view.surface.startNewThread(); - expect(view.getState()).toEqual({ version: 1 }); expect(view.getDisplayText()).not.toBe("Side chat"); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false }); + expect(view.containerEl.textContent).not.toContain("This side conversation is no longer available."); }); it("focuses the composer after panel thread actions", async () => { diff --git a/tests/features/chat/host/view-state.test.ts b/tests/features/chat/host/view-state.test.ts new file mode 100644 index 00000000..57d4baba --- /dev/null +++ b/tests/features/chat/host/view-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { parseChatPanelViewState } from "../../../../src/features/chat/host/view-state"; + +describe("parseChatPanelViewState", () => { + it("parses persistent thread state defensively", () => { + expect(parseChatPanelViewState({ threadId: "thread", threadTitle: "Title" })).toEqual({ + kind: "thread", + threadId: "thread", + fallbackTitle: "Title", + }); + expect(parseChatPanelViewState({ threadId: "" })).toEqual({ kind: "empty" }); + expect(parseChatPanelViewState(null)).toEqual({ kind: "empty" }); + }); + + it("treats persisted ephemeral side chats as discarded", () => { + expect(parseChatPanelViewState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } })).toEqual({ + kind: "empty", + }); + }); +}); diff --git a/tests/features/chat/support/state.ts b/tests/features/chat/support/state.ts index cdb834e7..75ffd63f 100644 --- a/tests/features/chat/support/state.ts +++ b/tests/features/chat/support/state.ts @@ -9,6 +9,7 @@ interface ChatStateFixturePatch { connection?: Partial; threadList?: Partial; activeThread?: Partial; + restoration?: ChatState["restoration"]; runtime?: RuntimePatch; turn?: Partial; threadStream?: Partial; @@ -31,6 +32,7 @@ export function chatStateWith(state: ChatState, patch: ChatStateFixturePatch): C ...(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.runtime ? { runtime: runtimeWithPatch(state.runtime, patch.runtime) } : {}), ...(patch.turn ? { turn: { ...state.turn, ...patch.turn } } : {}), ...(patch.threadStream ? { threadStream: { ...state.threadStream, ...patch.threadStream } } : {}),