Own restored panel state and discard unrestorable side chats

This commit is contained in:
murashit 2026-07-13 13:00:00 +09:00
parent b30b913c04
commit b92b6d5fd3
20 changed files with 274 additions and 311 deletions

View file

@ -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();
}

View file

@ -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),

View file

@ -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();
}

View file

@ -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<void>;
export class RestorationController {
private lifecycle: RestoredThreadLifecycleState = { kind: "idle" };
private loading: { threadId: string; promise: Promise<void> } | 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<boolean> {
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";
}
}

View file

@ -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<void> | null };
export type RestoredThreadPlaceholderState = Extract<RestoredThreadLifecycleState, { kind: "placeholder" }>;
export type RestoredThreadLifecycleEvent =
| { type: "placeholder-restored"; restoredThread: RestoredThreadState }
| { type: "renamed"; threadId: string; name: string | null }
| { type: "loading-started"; loading: Promise<void> }
| { type: "loading-finished"; loading: Promise<void> }
| { 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<string, unknown>;
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,
};
}

View file

@ -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();
}

View file

@ -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 {

View file

@ -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,

View file

@ -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<void> {
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<void> {
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<void> {
await this.runtime.actions.startNewThread();
if (!this.state.activeThread.id) this.ephemeralSourcePlaceholder = null;
}
async openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise<boolean> {
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<boolean> {
@ -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" };

View file

@ -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<string, unknown>;
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;
}

View file

@ -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 {

View file

@ -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, {

View file

@ -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();
});
});

View file

@ -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<undefined>();
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<undefined>();
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<ConstructorParameters<typeof RestorationController>[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 };
}

View file

@ -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();
});
});

View file

@ -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();
});

View file

@ -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<typeof createChatStateStore
}
describe("TurnSubmissionActions", () => {
it("aborts when restoration changes target while hydration is pending", async () => {
const { host, startTurn, stateStore } = createHost();
const restoration = new RestorationController({ stateStore });
const resume = deferred<void>();
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 () => {

View file

@ -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<ReturnType<typeof resumedThread>>();
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 () => {

View file

@ -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",
});
});
});

View file

@ -9,6 +9,7 @@ interface ChatStateFixturePatch {
connection?: Partial<ChatState["connection"]>;
threadList?: Partial<ChatState["threadList"]>;
activeThread?: Partial<ChatState["activeThread"]>;
restoration?: ChatState["restoration"];
runtime?: RuntimePatch;
turn?: Partial<ChatState["turn"]>;
threadStream?: Partial<ChatState["threadStream"]>;
@ -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 } } : {}),