Persist restored Codex panel thread state

This commit is contained in:
murashit 2026-05-25 13:27:12 +09:00
parent 59f35ebc85
commit 1e94b7eaa6
6 changed files with 434 additions and 6 deletions

View file

@ -10,11 +10,11 @@ export function getThreadTitle(thread: Thread): string {
);
}
export function codexPanelDisplayTitle(activeThreadId: string | null, threads: Thread[]): string {
export function codexPanelDisplayTitle(activeThreadId: string | null, threads: Thread[], fallbackTitle?: string | null): string {
if (!activeThreadId) return "Codex";
const thread = threads.find((item) => item.id === activeThreadId);
const title = thread ? fullThreadTitle(thread) : shortThreadId(activeThreadId);
const title = thread ? fullThreadTitle(thread) : (fallbackTitle ?? shortThreadId(activeThreadId));
return title ? `Codex: ${title}` : "Codex";
}

View file

@ -1,4 +1,4 @@
import { ItemView, Notice, type WorkspaceLeaf } from "obsidian";
import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import type { AppServerClient } from "../../app-server/client";
import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager";
@ -79,6 +79,11 @@ export interface CodexChatHost {
refreshOpenThreadLists(): void;
}
interface RestoredThreadState {
threadId: string;
title: string | null;
}
export class CodexChatView extends ItemView {
private client: AppServerClient | null = null;
private readonly connection: ConnectionManager;
@ -96,10 +101,14 @@ export class CodexChatView extends ItemView {
private messagesSlotEl: HTMLElement | null = null;
private composerSlotEl: HTMLElement | null = null;
private scheduledConnectionTimer: number | null = null;
private scheduledRestoredThreadHydrationTimer: number | null = null;
private scheduledRenderTimer: number | null = null;
private scheduledDiagnosticsTimer: number | null = null;
private connectingPromise: Promise<void> | null = null;
private connectionGeneration = 0;
private restoredThread: RestoredThreadState | null = null;
private restoredThreadLoading: Promise<void> | null = null;
private opened = false;
private toolbarSignature: string | null = null;
private forceScrollMessagesToBottomOnNextRender = false;
@ -222,13 +231,37 @@ export class CodexChatView extends ItemView {
}
override getDisplayText(): string {
return codexPanelDisplayTitle(this.state.activeThreadId, this.state.listedThreads);
return codexPanelDisplayTitle(this.state.activeThreadId, this.state.listedThreads, this.restoredThread?.title ?? null);
}
override getIcon(): string {
return "bot-message-square";
}
override getState(): Record<string, unknown> {
const threadId = this.state.activeThreadId;
if (!threadId) return { version: 1 };
const threadTitle = this.restoredThread?.title ?? this.activeThreadTitle();
return {
version: 1,
threadId,
...(threadTitle ? { threadTitle } : {}),
};
}
override async setState(state: unknown, result: ViewStateResult): Promise<void> {
await super.setState(state, result);
const restoredThread = parseRestoredThreadState(state);
if (!restoredThread) {
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
return;
}
this.restoreThreadPlaceholder(restoredThread);
}
refreshSettings(): void {
this.render();
}
@ -242,20 +275,29 @@ export class CodexChatView extends ItemView {
}
override async onOpen(): Promise<void> {
this.opened = true;
this.composerController.registerNoteIndexInvalidation((eventRef) => {
this.registerEvent(eventRef);
});
this.registerDomEvent(this.containerEl.doc, "pointerdown", (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf === this.leaf) this.scrollMessagesToBottomOnFocus();
}),
);
this.render();
this.scheduleDeferredConnection();
this.scheduleDeferredRestoredThreadHydration();
}
override async onClose(): Promise<void> {
this.opened = false;
this.connectionGeneration += 1;
this.connectingPromise = null;
this.clearDeferredConnection();
this.clearDeferredRestoredThreadHydration();
if (this.scheduledRenderTimer !== null) {
this.containerEl.win.clearTimeout(this.scheduledRenderTimer);
this.scheduledRenderTimer = null;
@ -326,6 +368,8 @@ export class CodexChatView extends ItemView {
async startNewThread(): Promise<void> {
if (this.state.busy) return;
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
await this.ensureConnected();
if (!this.client) return;
@ -338,6 +382,7 @@ export class CodexChatView extends ItemView {
this.forceMessagesToBottom();
await this.refreshThreads();
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
this.render();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
@ -396,8 +441,11 @@ export class CodexChatView extends ItemView {
this.state.turnDiffs.clear();
this.state.historyCursor = null;
this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread);
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
this.threadRename.resetThreadTurnPresence(false);
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
this.forceMessagesToBottom();
await this.history.loadLatest(response.thread.id);
if (this.state.displayItems.length === 0) {
@ -422,6 +470,10 @@ export class CodexChatView extends ItemView {
}
}
private requestWorkspaceLayoutSave(): void {
void this.app.workspace.requestSaveLayout();
}
private async sendMessage(): Promise<void> {
const text = this.composerController.trimmedDraft;
if (!text) return;
@ -444,6 +496,7 @@ export class CodexChatView extends ItemView {
}
private async sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void> {
if (!(await this.ensureRestoredThreadLoaded())) return;
const client = this.client;
if (!client) return;
@ -458,6 +511,7 @@ export class CodexChatView extends ItemView {
const threadResponse = await this.session.startThread();
if (!threadResponse) return;
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
this.threadRename.resetThreadTurnPresence(false);
}
const activeThreadId = this.state.activeThreadId;
@ -835,6 +889,82 @@ export class CodexChatView extends ItemView {
this.state.status = status;
}
private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void {
this.restoredThread = restoredThread;
this.state.activeThreadId = restoredThread.threadId;
this.state.activeThreadCwd = null;
this.state.activeTurnId = null;
this.state.activeModel = null;
this.state.activeReasoningEffort = null;
this.state.activeCollaborationMode = "default";
this.state.activeServiceTier = null;
this.state.activeApprovalsReviewer = null;
this.state.activeThreadCliVersion = null;
this.state.tokenUsage = null;
this.state.busy = false;
this.state.displayItems = [this.systemItem("Thread restored. Send a message to resume it.")];
this.state.pendingTurnStart = null;
this.state.turnDiffs.clear();
this.state.approvals = [];
this.state.pendingUserInputs = [];
this.state.userInputDrafts.clear();
this.state.historyCursor = null;
this.state.loadingHistory = false;
this.state.messagesPinnedToBottom = true;
this.setStatus("Thread ready to resume.");
this.refreshTabHeader();
this.scheduleDeferredRestoredThreadHydration();
}
private async ensureRestoredThreadLoaded(): Promise<boolean> {
if (!this.restoredThread) return true;
this.clearDeferredRestoredThreadHydration();
if (this.restoredThreadLoading) {
const threadId = this.restoredThread.threadId;
await this.restoredThreadLoading;
return !this.isRestoredThreadPending(threadId);
}
const threadId = this.restoredThread.threadId;
const loading = this.resumeThread(threadId);
this.restoredThreadLoading = loading;
try {
await loading;
} finally {
if (this.restoredThreadLoading === loading) {
this.restoredThreadLoading = null;
}
}
return !this.isRestoredThreadPending(threadId);
}
private isRestoredThreadPending(threadId: string): boolean {
return this.restoredThread?.threadId === threadId;
}
private scheduleDeferredRestoredThreadHydration(): void {
if (!this.opened || !this.restoredThread || this.scheduledRestoredThreadHydrationTimer !== null) return;
const threadId = this.restoredThread.threadId;
this.scheduledRestoredThreadHydrationTimer = this.containerEl.win.setTimeout(() => {
this.scheduledRestoredThreadHydrationTimer = null;
if (!this.isRestoredThreadPending(threadId)) return;
void this.ensureRestoredThreadLoaded();
}, 1_500);
}
private clearDeferredRestoredThreadHydration(): void {
if (this.scheduledRestoredThreadHydrationTimer === null) return;
this.containerEl.win.clearTimeout(this.scheduledRestoredThreadHydrationTimer);
this.scheduledRestoredThreadHydrationTimer = null;
}
private activeThreadTitle(): string | null {
const threadId = this.state.activeThreadId;
if (!threadId) return null;
const thread = this.state.listedThreads.find((item) => item.id === threadId);
return thread ? getThreadTitle(thread) : null;
}
private render(): void {
if (this.scheduledRenderTimer !== null) {
window.clearTimeout(this.scheduledRenderTimer);
@ -1241,8 +1371,10 @@ export class CodexChatView extends ItemView {
}
await this.client.archiveThread(threadId);
if (this.state.activeThreadId === threadId) {
this.restoredThread = null;
clearActiveThreadState(this.state);
this.threadRename.resetThreadTurnPresence(false);
this.requestWorkspaceLayoutSave();
}
await this.refreshThreads();
this.render();
@ -1312,6 +1444,7 @@ export class CodexChatView extends ItemView {
this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
this.setStatus("Rolled back latest turn.");
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
await this.refreshThreads();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
@ -1324,6 +1457,11 @@ export class CodexChatView extends ItemView {
this.forceScrollMessagesToBottomOnNextRender = true;
}
private scrollMessagesToBottomOnFocus(): void {
this.forceMessagesToBottom();
this.render();
}
private renderMessages(parent: HTMLElement): void {
this.messageRenderer.render(parent);
}
@ -1351,3 +1489,15 @@ export class CodexChatView extends ItemView {
function latestProposedPlanItem(items: DisplayItem[]): DisplayItem | null {
return [...items].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ?? null;
}
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,
};
}

View file

@ -8,11 +8,17 @@ import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormali
import { CodexPanelSettingTab } from "./settings/tab";
import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/ui/turn-diff";
const BOOT_RESTORED_PANEL_LOAD_DELAY_MS = 1_000;
const BOOT_RESTORED_PANEL_LOAD_STAGGER_MS = 250;
export default class CodexPanelPlugin extends Plugin {
settings: CodexPanelSettings = DEFAULT_SETTINGS;
vaultPath = "";
private bootRestoredPanelLoadCancelled = false;
private readonly bootRestoredPanelLoadTimers = new Set<number>();
override async onload(): Promise<void> {
this.bootRestoredPanelLoadCancelled = false;
this.vaultPath = getVaultPath(this.app);
await this.loadSettings();
@ -47,6 +53,16 @@ export default class CodexPanelPlugin extends Plugin {
registerSelectionRewriteCommand(this);
this.addSettingTab(new CodexPanelSettingTab(this.app, this));
this.scheduleBootRestoredPanelLoads();
}
override onunload(): void {
this.bootRestoredPanelLoadCancelled = true;
for (const timer of this.bootRestoredPanelLoadTimers) {
window.clearTimeout(timer);
}
this.bootRestoredPanelLoadTimers.clear();
}
async activateView(): Promise<CodexChatView> {
@ -122,4 +138,33 @@ export default class CodexPanelPlugin extends Plugin {
return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER);
}
private scheduleBootRestoredPanelLoads(): void {
this.scheduleBootRestoredPanelLoadTimer(() => {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL);
leaves.forEach((leaf, index) => {
this.scheduleBootRestoredPanelLoadTimer(() => {
void this.loadRestoredPanelLeaf(leaf);
}, index * BOOT_RESTORED_PANEL_LOAD_STAGGER_MS);
});
}, BOOT_RESTORED_PANEL_LOAD_DELAY_MS);
}
private scheduleBootRestoredPanelLoadTimer(callback: () => void, delay: number): void {
const timer = window.setTimeout(() => {
this.bootRestoredPanelLoadTimers.delete(timer);
if (this.bootRestoredPanelLoadCancelled) return;
callback();
}, delay);
this.bootRestoredPanelLoadTimers.add(timer);
}
private async loadRestoredPanelLeaf(leaf: WorkspaceLeaf): Promise<void> {
if (this.bootRestoredPanelLoadCancelled) return;
try {
await leaf.loadIfDeferred();
} catch (error) {
console.warn("Codex Panel could not hydrate a restored panel leaf.", error);
}
}
}

View file

@ -104,6 +104,95 @@ describe("CodexChatView connection lifecycle", () => {
expect(notices).toEqual([]);
expect(client.listThreads).not.toHaveBeenCalled();
});
it("restores the active thread from workspace state and hydrates it after a delay", async () => {
vi.useFakeTimers();
const client = connectedClient({
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
});
connectionMock.state.client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
expect(view.getDisplayText()).toBe("Codex: Restored thread");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(connectionMock.state.connectCalls).toBe(0);
expect(client.resumeThread).not.toHaveBeenCalled();
expect(view.containerEl.textContent).toContain("Thread restored. Send a message to resume it.");
await vi.advanceTimersByTimeAsync(1_500);
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
it("hydrates a restored thread when workspace state arrives after open", async () => {
vi.useFakeTimers();
const client = connectedClient();
connectionMock.state.client = client;
const view = await chatView();
await view.onOpen();
await vi.advanceTimersByTimeAsync(1_500);
expect(client.resumeThread).not.toHaveBeenCalled();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await vi.advanceTimersByTimeAsync(1_500);
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
it("resumes a restored thread before sending the first message", async () => {
const client = connectedClient();
connectionMock.state.client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
view.setComposerText("hello");
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.startTurn).toHaveBeenCalledWith("thread-1", "/vault", [{ type: "text", text: "hello", text_elements: [] }]);
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
});
it("requests a workspace layout save after resuming a thread", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
connectionMock.state.client = client;
const view = await chatView({ requestSaveLayout });
await view.openThread("thread-1");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
});
it("scrolls restored messages to the bottom when the panel is focused", async () => {
const activeLeafChangeListeners: ((leaf: unknown) => void)[] = [];
const view = await chatView({ activeLeafChangeListeners });
await view.onOpen();
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
messages.scrollTop = 0;
activeLeafChangeListeners.forEach((listener) => {
listener(view.leaf);
});
await new Promise<void>((resolve) => {
messages.win.requestAnimationFrame(() => {
resolve();
});
});
expect(messages.scrollTop).toBe(1000);
});
});
function connectedClient(overrides: Partial<ReturnType<typeof baseClient>> = {}): ReturnType<typeof baseClient> {
@ -120,10 +209,30 @@ function baseClient() {
listSkills: vi.fn().mockResolvedValue({ data: [] }),
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: null }),
listThreads: vi.fn().mockResolvedValue({ data: [] }),
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
startTurn: vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }),
};
}
async function chatView() {
function resumedThread(threadId: string) {
return {
thread: {
id: threadId,
name: "Restored thread",
preview: "Restored thread",
cwd: "/vault",
cliVersion: "0.0.0",
},
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
};
}
async function chatView(options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; requestSaveLayout?: () => void } = {}) {
const { CodexChatView } = await import("../../../src/features/chat/view");
const containerEl = document.createElement("div");
containerEl.createDiv();
@ -131,8 +240,18 @@ async function chatView() {
return new CodexChatView(
{
app: {
workspace: {
getActiveFile: vi.fn(() => null),
on: vi.fn((eventName: string, callback: (leaf: unknown) => void) => {
if (eventName === "active-leaf-change") options.activeLeafChangeListeners?.push(callback);
return {};
}),
openLinkText: vi.fn(),
requestSaveLayout: options.requestSaveLayout ?? vi.fn(),
},
vault: {
on: vi.fn(() => ({})),
getMarkdownFiles: vi.fn(() => []),
},
},
containerEl,

67
tests/main.test.ts Normal file
View file

@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FileSystemAdapter } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../src/constants";
describe("CodexPanelPlugin boot restored panel loading", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("loads restored Codex panel leaves after startup without blocking onload", async () => {
vi.useFakeTimers();
const firstLeaf = leaf();
const secondLeaf = leaf();
const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]);
await plugin.onload();
expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled();
expect(secondLeaf.loadIfDeferred).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(1);
expect(firstLeaf.loadIfDeferred).toHaveBeenCalledTimes(1);
expect(secondLeaf.loadIfDeferred).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(249);
expect(secondLeaf.loadIfDeferred).toHaveBeenCalledTimes(1);
});
it("cancels pending boot panel loads on unload", async () => {
vi.useFakeTimers();
const firstLeaf = leaf();
const plugin = await pluginWithLeaves([firstLeaf]);
await plugin.onload();
plugin.onunload();
await vi.advanceTimersByTimeAsync(1_000);
expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled();
});
});
async function pluginWithLeaves(leaves: ReturnType<typeof leaf>[]) {
const { default: CodexPanelPlugin } = await import("../src/main");
const adapter = new FileSystemAdapter();
vi.spyOn(adapter, "getBasePath").mockReturnValue("/vault");
return new CodexPanelPlugin(
{
vault: {
adapter,
},
workspace: {
getLeavesOfType: vi.fn((type: string) => (type === VIEW_TYPE_CODEX_PANEL ? leaves : [])),
},
} as never,
{} as never,
);
}
function leaf() {
return {
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
};
}

View file

@ -21,7 +21,7 @@ export type App = Record<string, never>;
export const notices: string[] = [];
export class FileSystemAdapter {
constructor(readonly basePath: string) {}
constructor(readonly basePath = "") {}
getBasePath(): string {
return this.basePath;
@ -61,12 +61,59 @@ export class ItemView {
registerEvent(_eventRef: unknown): void {
// Test mock placeholder.
}
getState(): Record<string, unknown> {
return {};
}
setState(_state: unknown, _result: unknown): Promise<void> {
return Promise.resolve();
}
}
export class MarkdownView {
file: TFile | null = null;
}
export class Plugin {
constructor(readonly app: App) {}
register(_callback: () => void): void {
// Test mock placeholder.
}
addCommand(_command: unknown): void {
// Test mock placeholder.
}
addRibbonIcon(_icon: string, _title: string, _callback: () => void): void {
// Test mock placeholder.
}
addSettingTab(_tab: unknown): void {
// Test mock placeholder.
}
registerView(_type: string, _factory: unknown): void {
// Test mock placeholder.
}
loadData(): Promise<unknown> {
return Promise.resolve(null);
}
saveData(_data: unknown): Promise<void> {
return Promise.resolve();
}
}
export const MarkdownRenderer = {
render(_app: unknown, text: string, parent: HTMLElement): Promise<void> {
parent.textContent = text;
return Promise.resolve();
},
};
export class PluginSettingTab {
containerEl: HTMLElement;