Separate restored placeholders from active chat state

This commit is contained in:
murashit 2026-06-22 14:32:18 +09:00
parent 711e300f1b
commit 18327ff883
19 changed files with 52 additions and 183 deletions

View file

@ -22,8 +22,6 @@ export type RestoredThreadLifecycleEvent =
export interface ChatViewDeferredTasks {
scheduleDiagnostics(callback: () => void): void;
clearDiagnostics(): void;
scheduleRestoredThreadHydration(callback: () => void): void;
clearRestoredThreadHydration(): void;
scheduleAppServerWarmup(callback: () => void): void;
clearAppServerWarmup(): void;
clearAll(): void;

View file

@ -93,11 +93,6 @@ export interface ThreadListAppliedAction {
threadsLoaded?: boolean;
}
export interface ActiveThreadRestoredPlaceholderAction {
type: "active-thread/restored-placeholder";
threadId: string;
}
export interface DisclosureSetAction {
type: "ui/disclosure-set";
bucket: "details" | "activityGroups" | "textDetails" | "userMessageExpanded" | "goalObjectiveExpanded" | "approvalDetails";

View file

@ -31,7 +31,6 @@ import type { ComposerSuggestion } from "../composer/suggestions";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import type {
ActiveThreadResumedAction,
ActiveThreadRestoredPlaceholderAction,
ActiveThreadSettingsAppliedAction,
ClearActiveThreadAction,
ClearDisconnectedConnectionStateAction,
@ -233,7 +232,6 @@ type ChatTransitionAction =
| ClearActiveThreadAction
| ActiveThreadResumedAction
| ActiveThreadSettingsAppliedAction
| ActiveThreadRestoredPlaceholderAction
| { type: "active-thread/goal-set"; goal: ThreadGoal | null }
| TurnAction
| RequestResolvedAction
@ -269,7 +267,6 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
case "active-thread/cleared":
case "active-thread/resumed":
case "active-thread/settings-applied":
case "active-thread/restored-placeholder":
case "active-thread/goal-set":
case "turn/started":
case "turn/completed":
@ -295,8 +292,6 @@ function reduceChatTransition(state: ChatState, action: ChatTransitionAction): C
return reduceActiveThreadResumedTransition(state, action);
case "active-thread/settings-applied":
return reduceActiveThreadSettingsAppliedTransition(state, action);
case "active-thread/restored-placeholder":
return reduceActiveThreadRestoredPlaceholderTransition(state, action);
case "active-thread/goal-set":
return reduceActiveThreadGoalSetTransition(state, action.goal);
case "turn/started":
@ -374,22 +369,6 @@ function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: A
});
}
function reduceActiveThreadRestoredPlaceholderTransition(state: ChatState, action: ActiveThreadRestoredPlaceholderAction): ChatState {
return clearTurnScopedState(
patchChatState(state, {
activeThread: {
id: action.threadId,
cwd: null,
goal: null,
tokenUsage: null,
},
runtime: initialChatRuntimeState(),
messageStream: initialMessageStreamState(),
ui: initialUiState(),
}),
);
}
function reduceActiveThreadGoalSetTransition(state: ChatState, goal: ThreadGoal | null): ChatState {
return patchChatState(state, {
activeThread: {

View file

@ -6,7 +6,6 @@ export interface ActiveThreadIdentitySyncHost {
stateStore: ChatStateStore;
restoration: RestorationController;
invalidateThreadWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshTabHeader: () => void;
@ -35,14 +34,13 @@ export function createActiveThreadIdentitySync(host: ActiveThreadIdentitySyncHos
function clearActiveThreadIdentity(host: ActiveThreadIdentitySyncHost): void {
host.invalidateThreadWork();
host.restoration.clear();
host.clearDeferredRestoredThreadHydration();
host.stateStore.dispatch({ type: "active-thread/cleared" });
host.resetThreadTurnPresence(false);
host.notifyActiveThreadIdentityChanged();
}
function applyThreadArchiveToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string): void {
if (activeThreadId(host.stateStore.getState()) !== threadId) return;
if (activeThreadId(host.stateStore.getState()) !== threadId && !host.restoration.isPending(threadId)) return;
clearActiveThreadIdentity(host);
}

View file

@ -1,6 +1,6 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { recoverRolloutTokenUsage } from "../../../../app-server/services/rollout-token-usage";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatResumeWorkTracker } from "../lifecycle";
import type { ChatStateStore } from "../state/store";
import type { GoalActions } from "./goal-actions";
import type { HistoryController } from "./history-controller";
@ -16,11 +16,9 @@ export interface ThreadLifecyclePartsContext {
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
history: HistoryController;
invalidateThreadWork: () => void;
getOpened: () => boolean;
getClosing: () => boolean;
};
thread: {
@ -47,12 +45,9 @@ export interface ThreadLifecycleParts {
export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts {
const { settingsRef, stateStore, client, lifecycle, thread, status, liveState, goals, resetThreadTurnPresence } = context;
const { deferredTasks, resumeWork, history, invalidateThreadWork } = lifecycle;
const { resumeWork, history, invalidateThreadWork } = lifecycle;
const restoration = new RestorationController({
deferredTasks,
opened: lifecycle.getOpened,
invalidateThreadWork,
stateStore,
setStatus: status.set,
refreshTabHeader: thread.refreshTabHeader,
});
@ -66,9 +61,6 @@ export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext)
ensureConnected: client.ensureConnected,
closing: lifecycle.getClosing,
resetThreadTurnPresence,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
addSystemMessage: status.addSystemMessage,
refreshLiveState: liveState.refresh,
@ -83,9 +75,6 @@ export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext)
stateStore,
restoration,
invalidateThreadWork,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,

View file

@ -1,19 +1,14 @@
import type { ChatStateStore } from "../state/store";
import {
transitionRestoredThreadLifecycle,
type RestoredThreadLifecycleState,
type RestoredThreadPlaceholderState,
type RestoredThreadState,
type ChatViewDeferredTasks,
} from "../lifecycle";
const STATUS_THREAD_READY_TO_RESUME = "Thread ready to resume.";
export interface RestorationControllerHost {
deferredTasks: ChatViewDeferredTasks;
opened: () => boolean;
invalidateThreadWork: () => void;
stateStore: ChatStateStore;
setStatus: (status: string) => void;
refreshTabHeader: () => void;
}
@ -49,7 +44,6 @@ export class RestorationController {
type: "placeholder-restored",
restoredThread,
});
this.host.stateStore.dispatch({ type: "active-thread/restored-placeholder", threadId: restoredThread.threadId });
this.host.setStatus(STATUS_THREAD_READY_TO_RESUME);
this.host.refreshTabHeader();
}
@ -57,7 +51,6 @@ export class RestorationController {
async ensureLoaded(loadThread: RestoredThreadLoader): Promise<boolean> {
const restoredThread = this.placeholder();
if (!restoredThread) return true;
this.clearHydration();
if (restoredThread.loading) {
const threadId = restoredThread.threadId;
await restoredThread.loading;
@ -78,18 +71,4 @@ export class RestorationController {
isPending(threadId: string): boolean {
return this.placeholder()?.threadId === threadId;
}
scheduleHydration(loadThread: RestoredThreadLoader): void {
const restoredThread = this.placeholder();
if (!this.host.opened() || !restoredThread) return;
const threadId = restoredThread.threadId;
this.host.deferredTasks.scheduleRestoredThreadHydration(() => {
if (!this.isPending(threadId)) return;
void this.ensureLoaded(loadThread);
});
}
clearHydration(): void {
this.host.deferredTasks.clearRestoredThreadHydration();
}
}

View file

@ -18,7 +18,6 @@ export interface ResumeActionsHost {
ensureConnected: () => Promise<void>;
closing: () => boolean;
resetThreadTurnPresence: (hadTurns: boolean) => void;
clearDeferredRestoredThreadHydration: () => void;
notifyActiveThreadIdentityChanged: () => void;
addSystemMessage: (text: string) => void;
refreshLiveState: () => void;
@ -88,7 +87,6 @@ function applyResumedThread(host: ResumeActionsHost, response: ChatThreadResumeS
}),
);
host.restoration.clear();
host.clearDeferredRestoredThreadHydration();
host.resetThreadTurnPresence(false);
host.notifyActiveThreadIdentityChanged();
}

View file

@ -3,7 +3,6 @@ import type { ChatViewDeferredTasks } from "../application/lifecycle";
export function createChatViewDeferredTasks(getWindow: () => DeferredTaskWindow): ChatViewDeferredTasks {
const diagnosticsTask = new DeferredTask(getWindow, 1_000);
const restoredThreadHydrationTask = new DeferredTask(getWindow, 1_500);
const appServerWarmupTask = new DeferredTask(getWindow, 0);
return {
@ -15,14 +14,6 @@ export function createChatViewDeferredTasks(getWindow: () => DeferredTaskWindow)
diagnosticsTask.clear();
},
scheduleRestoredThreadHydration(callback): void {
restoredThreadHydrationTask.schedule(callback);
},
clearRestoredThreadHydration(): void {
restoredThreadHydrationTask.clear();
},
scheduleAppServerWarmup(callback): void {
appServerWarmupTask.schedule(callback);
},
@ -32,7 +23,6 @@ export function createChatViewDeferredTasks(getWindow: () => DeferredTaskWindow)
},
clearAll(): void {
restoredThreadHydrationTask.clear();
appServerWarmupTask.clear();
diagnosticsTask.clear();
},

View file

@ -135,7 +135,6 @@ interface ChatPanelSessionGraphHost {
resumeWork: ChatResumeWorkTracker;
connectionWork: ConnectionWorkTracker;
messageScrollController: ChatMessageScrollController;
getOpened: () => boolean;
getClosing: () => boolean;
viewWindow: () => Window;
}
@ -640,11 +639,9 @@ function createSessionThreadLifecycle(
ensureConnected,
},
lifecycle: {
deferredTasks: host.deferredTasks,
resumeWork: host.resumeWork,
history,
invalidateThreadWork,
getOpened: host.getOpened,
getClosing: host.getClosing,
},
thread: {

View file

@ -4,7 +4,7 @@ import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../.
import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title";
import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import { createChatViewDeferredTasks } from "./lifecycle";
import { ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle";
import { ChatResumeWorkTracker, type ChatViewDeferredTasks, type RestoredThreadPlaceholderState } from "../application/lifecycle";
import { openPanelTurnLifecycle, parseRestoredThreadState, type ChatPanelSnapshot } from "../panel/snapshot";
import type { ChatState } from "../application/state/root-reducer";
import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate";
@ -34,11 +34,11 @@ export class ChatPanelSession implements ChatSurfaceHandle {
}
displayTitle(): string {
return threadWindowTitle(this.state.activeThread.id, this.state.threadList.listedThreads, this.restoredThreadTitle());
return threadWindowTitle(this.panelThreadId(), this.state.threadList.listedThreads, this.restoredThreadTitle());
}
persistedState(): Record<string, unknown> {
const threadId = this.state.activeThread.id;
const threadId = this.panelThreadId();
if (!threadId) return { version: 1 };
const threadTitle = this.restoredThreadTitle() ?? this.activeThreadTitle();
@ -53,13 +53,11 @@ export class ChatPanelSession implements ChatSurfaceHandle {
const restoredThread = parseRestoredThreadState(state);
if (restoredThread) {
this.graph.thread.restoration.restore(restoredThread);
this.scheduleRestoredThreadHydration();
return;
}
this.graph.actions.invalidateThreadWork();
this.graph.thread.restoration.clear();
this.graph.thread.restoration.clearHydration();
this.scheduleWarmup();
}
@ -93,7 +91,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
const pendingRequests = pendingRequestCountsFromQueues(this.state.requests);
return {
viewId: this.environment.obsidian.viewId,
threadId: this.closing ? null : this.state.activeThread.id,
threadId: this.closing ? null : this.panelThreadId(),
turnLifecycle: openPanelTurnLifecycle(this.state.turn.lifecycle),
pendingApprovals: pendingRequests.approvals,
pendingUserInputs: pendingRequests.userInputs,
@ -109,7 +107,8 @@ export class ChatPanelSession implements ChatSurfaceHandle {
}
async focusThread(threadId: string | null = null): Promise<void> {
if (threadId && this.graph.thread.restoration.isPending(threadId)) {
const restoredThreadId = this.restoredThread()?.threadId ?? null;
if ((threadId && this.graph.thread.restoration.isPending(threadId)) || (!threadId && restoredThreadId)) {
await this.ensureRestoredThreadLoaded();
}
this.focusComposer();
@ -136,7 +135,6 @@ export class ChatPanelSession implements ChatSurfaceHandle {
this.graph.runtime.sharedState.subscribe();
this.mountOrRepairShell();
this.scheduleWarmup();
this.scheduleRestoredThreadHydration();
}
close(): void {
@ -231,17 +229,21 @@ export class ChatPanelSession implements ChatSurfaceHandle {
}
private restoredThreadTitle(): string | null {
return this.graph.thread.restoration.title();
return this.restoredThread()?.title ?? null;
}
private restoredThread(): RestoredThreadPlaceholderState | null {
return this.graph.thread.restoration.placeholder();
}
private panelThreadId(): string | null {
return this.restoredThread()?.threadId ?? this.state.activeThread.id;
}
private ensureRestoredThreadLoaded(): Promise<boolean> {
return this.graph.thread.restoration.ensureLoaded((threadId) => this.graph.thread.resume.resumeThread(threadId));
}
private scheduleRestoredThreadHydration(): void {
this.graph.thread.restoration.scheduleHydration((threadId) => this.graph.thread.resume.resumeThread(threadId));
}
private createSessionGraph(): ChatPanelSessionGraph {
return createChatPanelSessionGraph({
environment: this.environment,
@ -250,7 +252,6 @@ export class ChatPanelSession implements ChatSurfaceHandle {
resumeWork: this.resumeWork,
connectionWork: this.connectionWork,
messageScrollController: this.messageScrollController,
getOpened: () => this.opened,
getClosing: () => this.closing,
viewWindow: () => this.viewWindow(),
});

View file

@ -223,7 +223,7 @@ function onOffLabel(active: boolean): string {
}
function activeComposerThreadName(state: ChatPanelComposerShellState, restoredThread: RestoredThreadTitleSnapshot | null): string | null {
const threadId = state.activeThread.id;
const threadId = restoredThread?.threadId ?? state.activeThread.id ?? null;
if (!threadId) return null;
const thread = state.threadList.listedThreads.find((item) => item.id === threadId);
const listedName = thread ? explicitThreadName(thread) : null;

View file

@ -223,7 +223,6 @@ describe("createChatPanelSessionGraph actions", () => {
resumeWork,
connectionWork: new ConnectionWorkTracker(),
messageScrollController: createChatMessageScrollController(),
getOpened: () => true,
getClosing: () => false,
viewWindow: () => window,
});

View file

@ -196,46 +196,6 @@ describe("chatReducer", () => {
expect(chatStateMessageStreamItems(next)).toEqual([]);
});
it("keeps composer state when restoring a thread placeholder", () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "previous-thread" } });
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "previous-turn" } } });
state = chatStateWith(state, { runtime: { activeModel: "gpt-5.1" } });
state = chatStateWith(state, { activeThread: { goal: goal("previous-thread") } });
state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } });
state = chatStateWith(state, { messageStream: { loadingHistory: true } });
state = chatStateWith(state, { composer: { draft: "draft in this panel" } });
state = chatStateWith(state, { composer: { suggestSelected: 1 } });
state = chatStateWith(state, { composer: { suggestions: [suggestion("/resume")] } });
state = chatStateWith(state, { composer: { suggestionsDismissedSignature: "dismissed" } });
state = withChatStateMessageStreamItems(state, [message("previous-message")]);
state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["previous-turn", "@@"]]) } });
state = chatStateWith(state, { requests: { approvals: [approval(1)] } });
state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } });
state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "answer"]]) } });
const next = chatReducer(state, {
type: "active-thread/restored-placeholder",
threadId: "restored-thread",
});
expect(next.activeThread.id).toBe("restored-thread");
expect(activeTurnId(next)).toBeNull();
expect(next.runtime.activeModel).toBeNull();
expect(next.activeThread.goal).toBeNull();
expect(next.messageStream.historyCursor).toBeNull();
expect(next.messageStream.loadingHistory).toBe(false);
expect(chatStateMessageStreamItems(next)).toEqual([]);
expect(next.messageStream.turnDiffs.size).toBe(0);
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
expect(next.requests.userInputDrafts.size).toBe(0);
expect(next.composer.draft).toBe("draft in this panel");
expect(next.composer.suggestSelected).toBe(1);
expect(next.composer.suggestions).toEqual([suggestion("/resume")]);
expect(next.composer.suggestionsDismissedSignature).toBe("dismissed");
});
it("updates map-backed turn diffs and toolbar panel state", () => {
let state = chatStateFixture();
state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["turn", "old"]]) } });

View file

@ -33,7 +33,6 @@ function createController(options: { restoredThreadPending?: boolean } = {}) {
stateStore,
restoration,
invalidateThreadWork: vi.fn(),
clearDeferredRestoredThreadHydration: vi.fn(),
resetThreadTurnPresence: vi.fn(),
notifyActiveThreadIdentityChanged: vi.fn(),
refreshTabHeader: vi.fn(),
@ -61,7 +60,6 @@ describe("createActiveThreadIdentitySync", () => {
expect(stateStore.getState().activeThread.id).toBeNull();
expect(host.invalidateThreadWork).toHaveBeenCalledOnce();
expect(restoredClear).toHaveBeenCalledOnce();
expect(host.clearDeferredRestoredThreadHydration).toHaveBeenCalledOnce();
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
expect(host.refreshTabHeader).not.toHaveBeenCalled();
@ -86,12 +84,24 @@ describe("createActiveThreadIdentitySync", () => {
expect(stateStore.getState().activeThread.id).toBe("active");
expect(host.invalidateThreadWork).not.toHaveBeenCalled();
expect(restoredClear).not.toHaveBeenCalled();
expect(host.clearDeferredRestoredThreadHydration).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 { controller, host, restoredClear, stateStore } = createController({ restoredThreadPending: true });
controller.applyThreadArchiveToActiveIdentity("thread");
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("routes active thread rename notifications through active identity refresh", () => {
const { controller, host, restoredRename, stateStore } = createController();
stateStore.dispatch({

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import { ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../../../../src/features/chat/application/lifecycle";
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/lifecycle";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import type { GoalActions } from "../../../../src/features/chat/application/threads/goal-actions";
import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller";
@ -27,11 +27,9 @@ describe("ThreadLifecycleParts", () => {
ensureConnected: vi.fn().mockResolvedValue(undefined),
},
lifecycle: {
deferredTasks: deferredTasks(),
resumeWork,
history,
invalidateThreadWork,
getOpened: () => true,
getClosing: () => false,
},
thread: {
@ -61,15 +59,3 @@ describe("ThreadLifecycleParts", () => {
expect(invalidateThreadWork).toHaveBeenCalledOnce();
});
});
function deferredTasks(): ChatViewDeferredTasks {
return {
scheduleDiagnostics: vi.fn(),
clearDiagnostics: vi.fn(),
scheduleRestoredThreadHydration: vi.fn(),
clearRestoredThreadHydration: vi.fn(),
scheduleAppServerWarmup: vi.fn(),
clearAppServerWarmup: vi.fn(),
clearAll: vi.fn(),
};
}

View file

@ -2,34 +2,22 @@
import { describe, expect, it, vi } from "vitest";
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 { createChatViewDeferredTasks } from "../../../../src/features/chat/host/lifecycle";
import { deferred } from "../../../support/async";
describe("RestorationController", () => {
it("restores a placeholder and schedules deferred hydration", () => {
vi.useFakeTimers();
const stateStore = createChatStateStore(createChatState());
it("restores a placeholder for explicit hydration", async () => {
const resumeThread = vi.fn().mockResolvedValue(undefined);
const controller = new RestorationController({
deferredTasks: createChatViewDeferredTasks(() => window),
opened: () => true,
invalidateThreadWork: vi.fn(),
stateStore,
setStatus: vi.fn(),
refreshTabHeader: vi.fn(),
});
const controller = restoredThreadControllerFixture();
controller.restore({ threadId: "thread", title: "Title", explicitName: null });
controller.scheduleHydration(resumeThread);
expect(controller.placeholder()).toMatchObject({ threadId: "thread", title: "Title" });
expect(stateStore.getState().activeThread.id).toBe("thread");
vi.advanceTimersByTime(1_500);
expect(resumeThread).not.toHaveBeenCalled();
await controller.ensureLoaded(resumeThread);
expect(resumeThread).toHaveBeenCalledWith("thread");
vi.useRealTimers();
});
it("shares an in-flight restore load", async () => {
@ -61,10 +49,7 @@ describe("RestorationController", () => {
function restoredThreadControllerFixture(overrides: Partial<ConstructorParameters<typeof RestorationController>[0]> = {}) {
return new RestorationController({
deferredTasks: createChatViewDeferredTasks(() => window),
opened: () => false,
invalidateThreadWork: vi.fn(),
stateStore: createChatStateStore(createChatState()),
setStatus: vi.fn(),
refreshTabHeader: vi.fn(),
...overrides,

View file

@ -50,7 +50,6 @@ function createActions(response: ChatThreadResumeSnapshot = activation("thread")
closing: () => false,
systemItem: (text: string) => ({ id: "system", kind: "system" as const, role: "system" as const, text }),
resetThreadTurnPresence: vi.fn(),
clearDeferredRestoredThreadHydration: vi.fn(),
notifyActiveThreadIdentityChanged: vi.fn(),
addSystemMessage: vi.fn(),
refreshLiveState: vi.fn(),

View file

@ -347,7 +347,7 @@ describe("CodexChatView connection lifecycle", () => {
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false });
});
it("restores the active thread from workspace state and hydrates it after a delay", async () => {
it("restores workspace thread state without hydrating it automatically", async () => {
vi.useFakeTimers();
const client = connectedClient({
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
@ -360,6 +360,7 @@ describe("CodexChatView connection lifecycle", () => {
expect(view.getDisplayText()).toBe("Codex: Restored thread");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" });
expect(connectionMock.state.connectCalls).toBe(0);
expect(client.resumeThread).not.toHaveBeenCalled();
expect(view.containerEl.textContent).not.toContain("Thread restored. Send a message to resume it.");
@ -373,8 +374,8 @@ describe("CodexChatView connection lifecycle", () => {
await vi.advanceTimersByTimeAsync(1_500);
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
expect(client.resumeThread).not.toHaveBeenCalled();
expect(client.threadTurnsList).not.toHaveBeenCalled();
});
it("formats the panel title from listed thread metadata", async () => {
@ -394,7 +395,7 @@ describe("CodexChatView connection lifecycle", () => {
expect(view.getDisplayText()).toBe("Codex: 019e061e");
});
it("hydrates a restored thread when workspace state arrives after open", async () => {
it("keeps late workspace thread state restored until explicit focus", async () => {
vi.useFakeTimers();
const client = connectedClient();
connectionMock.state.client = client;
@ -410,6 +411,14 @@ describe("CodexChatView connection lifecycle", () => {
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await vi.advanceTimersByTimeAsync(1_500);
expect(view.getDisplayText()).toBe("Codex: Restored thread");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" });
expect(client.resumeThread).not.toHaveBeenCalled();
expect(client.threadTurnsList).not.toHaveBeenCalled();
await view.surface.focusThread("thread-1");
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});

View file

@ -14,18 +14,15 @@ describe("createChatViewDeferredTasks", () => {
it("clears scheduled deferred work", async () => {
const tasks = createChatViewDeferredTasks(() => window);
const diagnostics = vi.fn();
const hydration = vi.fn();
const warmup = vi.fn();
tasks.scheduleDiagnostics(diagnostics);
tasks.scheduleRestoredThreadHydration(hydration);
tasks.scheduleAppServerWarmup(warmup);
tasks.clearAll();
await vi.advanceTimersByTimeAsync(1_500);
expect(diagnostics).not.toHaveBeenCalled();
expect(hydration).not.toHaveBeenCalled();
expect(warmup).not.toHaveBeenCalled();
});
});