mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Own restored panel state and discard unrestorable side chats
This commit is contained in:
parent
b30b913c04
commit
b92b6d5fd3
20 changed files with 274 additions and 311 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
22
src/features/chat/host/view-state.ts
Normal file
22
src/features/chat/host/view-state.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
21
tests/features/chat/host/view-state.test.ts
Normal file
21
tests/features/chat/host/view-state.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } } : {}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue