diff --git a/src/domain/threads/model.ts b/src/domain/threads/model.ts index 44a570e7..61b24fa8 100644 --- a/src/domain/threads/model.ts +++ b/src/domain/threads/model.ts @@ -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"; } diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index d0e01011..f04faabe 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -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 | null = null; private connectionGeneration = 0; + private restoredThread: RestoredThreadState | null = null; + private restoredThreadLoading: Promise | 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 { + 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 { + 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 { + 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 { + 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 { 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 { 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 { + 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 { + 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; + 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, + }; +} diff --git a/src/main.ts b/src/main.ts index 58a2b616..9b8d4933 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); override async onload(): Promise { + 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 { @@ -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 { + if (this.bootRestoredPanelLoadCancelled) return; + try { + await leaf.loadIfDeferred(); + } catch (error) { + console.warn("Codex Panel could not hydrate a restored panel leaf.", error); + } + } } diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 2328cbc8..daffd112 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -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 }).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(".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((resolve) => { + messages.win.requestAnimationFrame(() => { + resolve(); + }); + }); + + expect(messages.scrollTop).toBe(1000); + }); }); function connectedClient(overrides: Partial> = {}): ReturnType { @@ -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, diff --git a/tests/main.test.ts b/tests/main.test.ts new file mode 100644 index 00000000..808b0d06 --- /dev/null +++ b/tests/main.test.ts @@ -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[]) { + 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), + }; +} diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 4a56f127..9512cd6c 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -21,7 +21,7 @@ export type App = Record; 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 { + return {}; + } + + setState(_state: unknown, _result: unknown): Promise { + 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 { + return Promise.resolve(null); + } + + saveData(_data: unknown): Promise { + return Promise.resolve(); + } +} + +export const MarkdownRenderer = { + render(_app: unknown, text: string, parent: HTMLElement): Promise { + parent.textContent = text; + return Promise.resolve(); + }, +}; + export class PluginSettingTab { containerEl: HTMLElement;