From 646dcb2ce05754b7e715cdf27c78e55e820c7c7e Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 25 May 2026 11:48:48 +0900 Subject: [PATCH] Stabilize thread restore and resume lifecycle --- src/features/chat/chat-message-renderer.ts | 28 +- src/features/chat/thread-history.ts | 5 + src/features/chat/thread-rename.ts | 5 +- src/features/chat/view.ts | 236 +++++++----- src/features/threads-view/view.ts | 35 +- src/main.ts | 147 +++++++- .../chat/chat-message-renderer.test.ts | 94 +++++ tests/features/chat/view-connection.test.ts | 344 +++++++++++++++++- tests/features/threads-view/view.test.ts | 107 +++++- tests/main.test.ts | 164 ++++++++- 10 files changed, 1006 insertions(+), 159 deletions(-) create mode 100644 tests/features/chat/chat-message-renderer.test.ts diff --git a/src/features/chat/chat-message-renderer.ts b/src/features/chat/chat-message-renderer.ts index 06ffff89..6efa208c 100644 --- a/src/features/chat/chat-message-renderer.ts +++ b/src/features/chat/chat-message-renderer.ts @@ -16,7 +16,7 @@ export interface ChatMessageRendererOptions { state: ChatState; vaultPath: string; blockSignatures: Map; - consumeForceScrollToBottom: () => boolean; + consumeScrollIntent: () => ChatMessageScrollIntent; loadOlderTurns: () => void; rollbackThread: (threadId: string) => void; implementPlan: (item: DisplayItem) => void; @@ -25,17 +25,24 @@ export interface ChatMessageRendererOptions { renderPendingRequests: () => HTMLElement | null; } +export type ChatMessageScrollIntent = "auto" | "force-bottom" | "preserve"; + export class ChatMessageRenderer { + private renderGeneration = 0; + constructor(private readonly options: ChatMessageRendererOptions) {} render(parent: HTMLElement): void { + const generation = ++this.renderGeneration; const { state } = this.options; const messagesEl = parent.querySelector(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" }); messagesEl.onscroll = () => { state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); }; - const wasNearBottom = isNearScrollBottom(messagesEl); - const shouldScrollToBottom = this.options.consumeForceScrollToBottom() || wasNearBottom; + const scrollIntent = this.options.consumeScrollIntent(); + const shouldPreserveScroll = scrollIntent === "preserve"; + const wasNearBottom = shouldPreserveScroll ? false : isNearScrollBottom(messagesEl); + const shouldScrollToBottom = scrollIntent === "force-bottom" || wasNearBottom; const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl); state.messagesPinnedToBottom = shouldScrollToBottom; const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems); @@ -83,12 +90,14 @@ export class ChatMessageRenderer { syncMessageRenderBlocks(messagesEl, blocks, this.options.blockSignatures); messagesEl.win.requestAnimationFrame(() => { + if (generation !== this.renderGeneration) return; if (shouldScrollToBottom) { - messagesEl.scrollTop = bottomScrollTop(messagesEl); + if (!state.messagesPinnedToBottom) return; + this.pinMessagesToBottom(messagesEl); } else { restoreScrollAnchor(messagesEl, scrollAnchor); + state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); } - state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); }); } @@ -113,11 +122,16 @@ export class ChatMessageRenderer { if (!messagesEl) return; messagesEl.win.requestAnimationFrame(() => { if (!this.options.state.messagesPinnedToBottom) return; - messagesEl.scrollTop = bottomScrollTop(messagesEl); - this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); + this.pinMessagesToBottom(messagesEl); }); } + private pinMessagesToBottom(messagesEl: HTMLElement): void { + messagesEl.scrollTop = bottomScrollTop(messagesEl); + messagesEl.lastElementChild?.scrollIntoView({ block: "end" }); + this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); + } + private bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string): void { parent.querySelectorAll("a.internal-link").forEach((link) => { link.addClass("codex-panel__wikilink"); diff --git a/src/features/chat/thread-history.ts b/src/features/chat/thread-history.ts index bbee57bb..38d9dc5c 100644 --- a/src/features/chat/thread-history.ts +++ b/src/features/chat/thread-history.ts @@ -17,6 +17,11 @@ export class ThreadHistoryLoader { constructor(private readonly host: ThreadHistoryLoaderHost) {} + invalidate(): void { + this.generation += 1; + this.host.state.loadingHistory = false; + } + async loadLatest(threadId = this.host.state.activeThreadId): Promise { const client = this.host.currentClient(); if (!client || !threadId) return; diff --git a/src/features/chat/thread-rename.ts b/src/features/chat/thread-rename.ts index b12d29c8..36ae017b 100644 --- a/src/features/chat/thread-rename.ts +++ b/src/features/chat/thread-rename.ts @@ -27,6 +27,7 @@ export interface ThreadRenameControllerHost { refreshThreads: () => Promise; render: () => void; addSystemMessage: (text: string) => void; + notifyThreadRenamed: (threadId: string, name: string) => void; } export class ThreadRenameController { @@ -91,7 +92,7 @@ export class ThreadRenameController { thread.id === threadId ? { ...thread, name: title } : thread, ); this.clear(); - await this.host.refreshThreads(); + this.host.notifyThreadRenamed(threadId, title); } catch (error) { this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); } finally { @@ -155,7 +156,7 @@ export class ThreadRenameController { this.host.state.listedThreads = this.host.state.listedThreads.map((thread) => thread.id === threadId ? { ...thread, name: title } : thread, ); - await this.host.refreshThreads(); + this.host.notifyThreadRenamed(threadId, title); } catch { // Auto-naming is best-effort metadata. Leave the thread preview untouched on failure. } finally { diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 69fa7371..709d3428 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -13,6 +13,7 @@ import type { DisplayDetailSection, DisplayItem } from "./display/types"; import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort"; import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer"; import type { Thread } from "../../generated/app-server/v2/Thread"; +import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse"; import type { ThreadSettingsUpdateParams } from "../../generated/app-server/v2/ThreadSettingsUpdateParams"; import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { @@ -69,14 +70,17 @@ import { import { renderPendingRequestMessage } from "./ui/pending-request-message"; import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewModel } from "./ui/toolbar"; import type { ChatTurnDiffViewState } from "./ui/turn-diff"; -import { ChatMessageRenderer } from "./chat-message-renderer"; +import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer"; import type { OpenCodexPanelSnapshot } from "./panel-snapshot"; export interface CodexChatHost { readonly settings: CodexPanelSettings; readonly vaultPath: string; openThreadInNewView(threadId: string): Promise; + openThreadInAvailableView(threadId: string): Promise; openTurnDiff(state: ChatTurnDiffViewState): Promise; + notifyThreadArchived(threadId: string): void; + notifyThreadRenamed(threadId: string, name: string): void; refreshOpenThreadLists(): void; refreshThreadsViewLiveState(): void; refreshThreadsViewThreadList(): void; @@ -103,18 +107,18 @@ export class CodexChatView extends ItemView { private configSlotEl: HTMLElement | null = null; 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 resumeGeneration = 0; private restoredThread: RestoredThreadState | null = null; private restoredThreadLoading: Promise | null = null; private opened = false; private closing = false; private toolbarSignature: string | null = null; - private forceScrollMessagesToBottomOnNextRender = false; + private nextMessageScrollIntent: ChatMessageScrollIntent = "auto"; constructor( leaf: WorkspaceLeaf, @@ -127,9 +131,9 @@ export class CodexChatView extends ItemView { state: this.state, vaultPath: this.plugin.vaultPath, blockSignatures: this.blockSignatures, - consumeForceScrollToBottom: () => { - const value = this.forceScrollMessagesToBottomOnNextRender; - this.forceScrollMessagesToBottomOnNextRender = false; + consumeScrollIntent: () => { + const value = this.nextMessageScrollIntent; + this.nextMessageScrollIntent = "auto"; return value; }, loadOlderTurns: () => void this.history.loadOlder(), @@ -171,6 +175,7 @@ export class CodexChatView extends ItemView { this.render(); }, onExit: () => { + this.invalidateResumeWork(); this.setStatus("Codex app-server stopped."); clearConnectionScopedState(this.state); this.threadRename.resetThreadTurnPresence(false); @@ -201,7 +206,7 @@ export class CodexChatView extends ItemView { currentClient: () => this.connection.currentClient(), runtimeSnapshot: () => this.runtimeSnapshot(), forceMessagesToBottom: () => { - this.forceMessagesToBottom(); + this.queueMessagesBottomScroll(); }, }); this.history = new ThreadHistoryLoader({ @@ -214,10 +219,10 @@ export class CodexChatView extends ItemView { this.addSystemMessage(text); }, forceMessagesToBottom: () => { - this.forceMessagesToBottom(); + this.queueMessagesBottomScroll(); }, keepCurrentScrollPosition: () => { - this.forceScrollMessagesToBottomOnNextRender = false; + this.preserveMessagesScrollPosition(); }, setThreadTurnPresence: (hadTurns) => { this.threadRename.resetThreadTurnPresence(hadTurns); @@ -236,6 +241,9 @@ export class CodexChatView extends ItemView { addSystemMessage: (text) => { this.addSystemMessage(text); }, + notifyThreadRenamed: (threadId, name) => { + this.plugin.notifyThreadRenamed(threadId, name); + }, }); } @@ -267,6 +275,7 @@ export class CodexChatView extends ItemView { await super.setState(state, result); const restoredThread = parseRestoredThreadState(state); if (!restoredThread) { + this.invalidateResumeWork(); this.restoredThread = null; this.clearDeferredRestoredThreadHydration(); return; @@ -300,6 +309,40 @@ export class CodexChatView extends ItemView { await this.resumeThread(threadId); } + async focusThread(threadId: string | null = null): Promise { + if (threadId && this.isRestoredThreadPending(threadId)) { + await this.ensureRestoredThreadLoaded(); + } + this.scrollMessagesToBottomOnFocus(); + } + + notifyThreadArchived(threadId: string): void { + if (this.clearArchivedActiveThread(threadId)) { + this.render(); + } + } + + notifyThreadRenamed(threadId: string, name: string): void { + let changed = false; + this.state.listedThreads = this.state.listedThreads.map((thread) => { + if (thread.id !== threadId) return thread; + changed = true; + return { ...thread, name }; + }); + if (this.restoredThread?.threadId === threadId && this.restoredThread.title !== name) { + this.restoredThread = { ...this.restoredThread, title: name }; + changed = true; + } + const activeThreadChanged = this.state.activeThreadId === threadId || this.restoredThread?.threadId === threadId; + if (!changed && !activeThreadChanged) return; + if (activeThreadChanged) { + this.notifyActiveThreadIdentityChanged(); + } else { + this.refreshTabHeader(); + } + this.render(); + } + override async onOpen(): Promise { this.opened = true; this.closing = false; @@ -315,7 +358,6 @@ export class CodexChatView extends ItemView { }), ); this.render(); - this.scheduleDeferredConnection(); this.scheduleDeferredRestoredThreadHydration(); } @@ -323,8 +365,8 @@ export class CodexChatView extends ItemView { this.opened = false; this.closing = true; this.connectionGeneration += 1; + this.invalidateResumeWork(); this.connectingPromise = null; - this.clearDeferredConnection(); this.clearDeferredRestoredThreadHydration(); if (this.scheduledRenderTimer !== null) { this.containerEl.win.clearTimeout(this.scheduledRenderTimer); @@ -344,7 +386,6 @@ export class CodexChatView extends ItemView { } async connect(): Promise { - this.clearDeferredConnection(); await this.ensureConnected(); } @@ -400,26 +441,17 @@ export class CodexChatView extends ItemView { async startNewThread(): Promise { if (this.state.busy) return; + this.invalidateResumeWork(); this.restoredThread = null; this.clearDeferredRestoredThreadHydration(); - await this.ensureConnected(); - if (!this.client) return; - - try { - const response = await this.session.startThread(); - if (!response) return; - this.threadRename.resetThreadTurnPresence(false); - this.state.turnDiffs.clear(); - this.state.displayItems = [this.systemItem(`Started thread ${response.thread.id}`)]; - this.forceMessagesToBottom(); - await this.refreshThreads(); - this.plugin.refreshThreadsViewThreadList(); - this.refreshTabHeader(); - this.requestWorkspaceLayoutSave(); - this.render(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } + clearActiveThreadState(this.state); + this.threadRename.resetThreadTurnPresence(false); + this.state.openDetails.delete("history"); + this.setStatus("New chat."); + this.queueMessagesBottomScroll(); + this.plugin.refreshThreadsViewLiveState(); + this.notifyActiveThreadIdentityChanged(); + this.render(); } private async refreshThreads(): Promise { @@ -456,38 +488,24 @@ export class CodexChatView extends ItemView { this.addSystemMessage("Finish or interrupt the current turn before switching threads."); return; } + const resumeGeneration = this.beginResumeWork(); await this.ensureConnected(); - if (!this.client) return; + if (!this.client || this.isStaleResumeWork(resumeGeneration)) return; try { const response = await this.client.resumeThread(threadId, this.plugin.vaultPath); - this.state.activeThreadId = response.thread.id; - this.state.activeThreadCwd = response.cwd; - this.state.activeTurnId = null; - this.state.activeModel = response.model; - this.state.activeReasoningEffort = response.reasoningEffort; - this.state.activeServiceTier = reportedServiceTier(response.serviceTier); - this.state.activeApprovalsReviewer = response.approvalsReviewer; - this.state.activeThreadCliVersion = response.thread.cliVersion; - this.state.tokenUsage = null; - this.state.displayItems = []; - 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(); + if (this.isStaleResumeWork(resumeGeneration)) return; + this.applyResumedThread(response); await this.history.loadLatest(response.thread.id); + if (this.isStaleResumeWork(resumeGeneration)) return; if (this.state.displayItems.length === 0) { this.state.displayItems.push(this.systemItem(`Resumed thread ${response.thread.id}`)); - this.forceMessagesToBottom(); + this.queueMessagesBottomScroll(); this.render(); } this.plugin.refreshThreadsViewLiveState(); } catch (error) { + if (this.isStaleResumeWork(resumeGeneration)) return; this.addSystemMessage(error instanceof Error ? error.message : String(error)); } } @@ -504,6 +522,33 @@ export class CodexChatView extends ItemView { } } + private applyResumedThread(response: ThreadResumeResponse): void { + this.state.activeThreadId = response.thread.id; + this.state.activeThreadCwd = response.cwd; + this.state.activeTurnId = null; + this.state.activeModel = response.model; + this.state.activeReasoningEffort = response.reasoningEffort; + this.state.activeServiceTier = reportedServiceTier(response.serviceTier); + this.state.activeApprovalsReviewer = response.approvalsReviewer; + this.state.activeThreadCliVersion = response.thread.cliVersion; + this.state.tokenUsage = null; + this.state.displayItems = [this.systemItem("Loading thread...")]; + 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.notifyActiveThreadIdentityChanged(); + this.render(); + this.plugin.refreshThreadsViewLiveState(); + } + + private notifyActiveThreadIdentityChanged(): void { + this.refreshTabHeader(); + this.requestWorkspaceLayoutSave(); + } + private requestWorkspaceLayoutSave(): void { void this.app.workspace.requestSaveLayout(); } @@ -522,7 +567,6 @@ export class CodexChatView extends ItemView { if (result?.sendText) { await this.sendTurnText(result.sendText, result.sendInput, result.referencedThread); } - this.render(); return; } @@ -544,8 +588,7 @@ export class CodexChatView extends ItemView { if (!this.state.activeThreadId) { const threadResponse = await this.session.startThread(); if (!threadResponse) return; - this.refreshTabHeader(); - this.requestWorkspaceLayoutSave(); + this.notifyActiveThreadIdentityChanged(); this.threadRename.resetThreadTurnPresence(false); } const activeThreadId = this.state.activeThreadId; @@ -566,7 +609,7 @@ export class CodexChatView extends ItemView { markdown: true, }); this.state.pendingTurnStart = { anchorItemId: optimisticUserId, promptSubmitHookItemIds: [] }; - this.forceMessagesToBottom(); + this.queueMessagesBottomScroll(); this.composerController.setDraft(""); this.state.busy = true; this.render(); @@ -632,7 +675,7 @@ export class CodexChatView extends ItemView { ...(mentionedFiles.length > 0 ? { mentionedFiles } : {}), markdown: true, }); - this.forceMessagesToBottom(); + this.queueMessagesBottomScroll(); this.setStatus("Steered current turn."); } catch (error) { this.composerController.setDraft(text, { focus: true }); @@ -677,7 +720,7 @@ export class CodexChatView extends ItemView { activeThreadId: this.state.activeThreadId, listedThreads: this.state.listedThreads, startNewThread: () => this.startNewThread(), - resumeThread: (threadId) => this.resumeThread(threadId), + resumeThread: (threadId) => this.selectThread(threadId), referThread: (thread, message) => this.referencedThreadInput(thread, message), forkThread: (threadId) => this.forkThread(threadId), rollbackThread: (threadId) => this.rollbackThread(threadId), @@ -927,6 +970,7 @@ export class CodexChatView extends ItemView { } private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void { + this.invalidateResumeWork(); this.restoredThread = restoredThread; this.state.activeThreadId = restoredThread.threadId; this.state.activeThreadCwd = null; @@ -953,6 +997,21 @@ export class CodexChatView extends ItemView { this.scheduleDeferredRestoredThreadHydration(); } + private beginResumeWork(): number { + this.resumeGeneration += 1; + this.history.invalidate(); + return this.resumeGeneration; + } + + private invalidateResumeWork(): void { + this.resumeGeneration += 1; + this.history.invalidate(); + } + + private isStaleResumeWork(generation: number): boolean { + return generation !== this.resumeGeneration || this.closing; + } + private async ensureRestoredThreadLoaded(): Promise { if (!this.restoredThread) return true; this.clearDeferredRestoredThreadHydration(); @@ -1052,7 +1111,7 @@ export class CodexChatView extends ItemView { resumeThread: (threadId) => { if (this.state.busy && threadId !== this.state.activeThreadId) return; this.state.openDetails.delete("history"); - void this.resumeThread(threadId); + void this.selectThread(threadId); }, archiveThread: (threadId) => void this.archiveThread(threadId), startRenameThread: (threadId) => { @@ -1122,8 +1181,8 @@ export class CodexChatView extends ItemView { const threadId = this.state.activeThreadId; this.state.openDetails.delete("status-panel"); this.connectionGeneration += 1; + this.invalidateResumeWork(); this.connectingPromise = null; - this.clearDeferredConnection(); this.clearDeferredDiagnostics(); this.connection.reconnect(); this.client = null; @@ -1188,6 +1247,14 @@ export class CodexChatView extends ItemView { this.scheduleRender(); } + private async selectThread(threadId: string): Promise { + if (this.state.busy && threadId !== this.state.activeThreadId) { + this.addSystemMessage("Finish or interrupt the current turn before switching threads."); + return; + } + await this.plugin.openThreadInAvailableView(threadId); + } + private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { if (!this.hasOpenToolbarPanel()) return; @@ -1222,20 +1289,6 @@ export class CodexChatView extends ItemView { }, 50); } - private scheduleDeferredConnection(): void { - if (this.scheduledConnectionTimer !== null || this.connection.isConnected()) return; - this.scheduledConnectionTimer = this.containerEl.win.setTimeout(() => { - this.scheduledConnectionTimer = null; - void this.ensureConnected(); - }, 1_500); - } - - private clearDeferredConnection(): void { - if (this.scheduledConnectionTimer === null) return; - this.containerEl.win.clearTimeout(this.scheduledConnectionTimer); - this.scheduledConnectionTimer = null; - } - private scheduleDeferredDiagnostics(): void { if (this.scheduledDiagnosticsTimer !== null) return; this.scheduledDiagnosticsTimer = this.containerEl.win.setTimeout(() => { @@ -1407,14 +1460,7 @@ export class CodexChatView extends ItemView { new Notice(`Saved archived thread to ${result.path}.`); } 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(); + this.plugin.notifyThreadArchived(threadId); } catch (error) { this.addSystemMessage(error instanceof Error ? error.message : String(error)); } @@ -1435,7 +1481,7 @@ export class CodexChatView extends ItemView { if (sourceName) { try { await this.client.setThreadName(forkedThreadId, sourceName); - this.plugin.refreshOpenThreadLists(); + this.plugin.notifyThreadRenamed(forkedThreadId, sourceName); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`); @@ -1480,25 +1526,41 @@ export class CodexChatView extends ItemView { this.setComposerText(candidate.text); this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); this.setStatus("Rolled back latest turn."); - this.refreshTabHeader(); - this.requestWorkspaceLayoutSave(); + this.notifyActiveThreadIdentityChanged(); await this.refreshThreads(); + this.plugin.refreshOpenThreadLists(); } catch (error) { this.addSystemMessage(error instanceof Error ? error.message : String(error)); this.setStatus("Rollback failed."); } } - private forceMessagesToBottom(): void { + private queueMessagesBottomScroll(): void { this.state.messagesPinnedToBottom = true; - this.forceScrollMessagesToBottomOnNextRender = true; + this.nextMessageScrollIntent = "force-bottom"; + } + + private preserveMessagesScrollPosition(): void { + this.nextMessageScrollIntent = "preserve"; } private scrollMessagesToBottomOnFocus(): void { - this.forceMessagesToBottom(); + this.queueMessagesBottomScroll(); this.render(); } + private clearArchivedActiveThread(threadId: string): boolean { + if (this.state.activeThreadId !== threadId) return false; + this.invalidateResumeWork(); + this.restoredThread = null; + this.clearDeferredRestoredThreadHydration(); + clearActiveThreadState(this.state); + this.threadRename.resetThreadTurnPresence(false); + this.notifyActiveThreadIdentityChanged(); + this.plugin.refreshThreadsViewLiveState(); + return true; + } + private renderMessages(parent: HTMLElement): void { this.messageRenderer.render(parent); } diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index d715f935..abb8de49 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -13,11 +13,10 @@ import { threadRows } from "./state"; export interface CodexThreadsHost { readonly settings: CodexPanelSettings; readonly vaultPath: string; - openThreadInNewView(threadId: string): Promise; - openThreadInIdleEmptyView(threadId: string): Promise; + openThreadInAvailableView(threadId: string): Promise; getOpenPanelSnapshots(): OpenCodexPanelSnapshot[]; - focusOpenPanel(viewId: string): Promise; - refreshOpenThreadLists(): void; + notifyThreadArchived(threadId: string): void; + notifyThreadRenamed(threadId: string, name: string): void; } export class CodexThreadsView extends ItemView { @@ -25,6 +24,7 @@ export class CodexThreadsView extends ItemView { private client: AppServerClient | null = null; private connectingPromise: Promise | null = null; private connectionGeneration = 0; + private refreshGeneration = 0; private renderTimer: number | null = null; private refreshTimer: number | null = null; private status: string | null = null; @@ -76,6 +76,7 @@ export class CodexThreadsView extends ItemView { override async onClose(): Promise { this.connectionGeneration += 1; + this.refreshGeneration += 1; this.connectingPromise = null; if (this.renderTimer !== null) { this.containerEl.win.clearTimeout(this.renderTimer); @@ -90,28 +91,33 @@ export class CodexThreadsView extends ItemView { } async refresh(): Promise { - const generation = this.connectionGeneration; + const connectionGeneration = this.connectionGeneration; + const refreshGeneration = ++this.refreshGeneration; this.loading = true; this.status = this.threads.length === 0 ? "Loading threads..." : null; this.render(); try { await this.ensureConnected(); - if (generation !== this.connectionGeneration || !this.client) return; + if (this.isStaleRefresh(connectionGeneration, refreshGeneration) || !this.client) return; const response = await this.client.listThreads(this.plugin.vaultPath); - if (generation !== this.connectionGeneration) return; + if (this.isStaleRefresh(connectionGeneration, refreshGeneration)) return; this.threads = response.data; this.status = response.data.length === 0 ? "No threads" : null; } catch (error) { if (error instanceof StaleConnectionError) return; this.status = error instanceof Error ? error.message : String(error); } finally { - if (generation === this.connectionGeneration) { + if (!this.isStaleRefresh(connectionGeneration, refreshGeneration)) { this.loading = false; this.render(); } } } + private isStaleRefresh(connectionGeneration: number, refreshGeneration: number): boolean { + return connectionGeneration !== this.connectionGeneration || refreshGeneration !== this.refreshGeneration; + } + private async ensureConnected(): Promise { if (this.connection.isConnected()) { this.client = this.connection.currentClient(); @@ -180,12 +186,7 @@ export class CodexThreadsView extends ItemView { } private async openThread(threadId: string): Promise { - const live = threadRows(this.threads, this.plugin.getOpenPanelSnapshots(), this.renameDrafts).find( - (row) => row.thread.id === threadId, - )?.live; - if (live && (await this.plugin.focusOpenPanel(live.viewId))) return; - if (await this.plugin.openThreadInIdleEmptyView(threadId)) return; - await this.plugin.openThreadInNewView(threadId); + await this.plugin.openThreadInAvailableView(threadId); } private startRename(threadId: string, value: string): void { @@ -213,8 +214,7 @@ export class CodexThreadsView extends ItemView { if (!this.client) return; await this.client.setThreadName(threadId, name); this.renameDrafts.delete(threadId); - this.plugin.refreshOpenThreadLists(); - await this.refresh(); + this.plugin.notifyThreadRenamed(threadId, name); } catch (error) { this.status = error instanceof Error ? error.message : String(error); this.render(); @@ -232,8 +232,7 @@ export class CodexThreadsView extends ItemView { } await this.client.archiveThread(threadId); this.renameDrafts.delete(threadId); - this.plugin.refreshOpenThreadLists(); - await this.refresh(); + this.plugin.notifyThreadArchived(threadId); } catch (error) { this.status = error instanceof Error ? error.message : String(error); this.render(); diff --git a/src/main.ts b/src/main.ts index e48a987d..6288420a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,25 @@ import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./fe const BOOT_RESTORED_PANEL_LOAD_DELAY_MS = 1_000; const BOOT_RESTORED_PANEL_LOAD_STAGGER_MS = 250; +type ThreadPanelTarget = + | { + kind: "open"; + leaf: WorkspaceLeaf; + view: CodexChatView; + } + | { + kind: "restored"; + leaf: WorkspaceLeaf; + } + | { + kind: "empty"; + leaf: WorkspaceLeaf; + view: CodexChatView; + } + | { + kind: "new"; + }; + export default class CodexPanelPlugin extends Plugin { settings: CodexPanelSettings = DEFAULT_SETTINGS; vaultPath = ""; @@ -84,32 +103,42 @@ export default class CodexPanelPlugin extends Plugin { return view; } - async activateNewView(): Promise { + async activateNewView(options: { connect?: boolean } = {}): Promise { const leaf = this.createRightSidebarTab(); if (!leaf) throw new Error("Could not create a right sidebar leaf."); await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true }); await this.app.workspace.revealLeaf(leaf); const view = leaf.view as CodexChatView; - await view.connect(); + if (options.connect !== false) await view.connect(); return view; } async openThreadInNewView(threadId: string): Promise { - const view = await this.activateNewView(); + const view = await this.activateThreadResumeView(); await view.openThread(threadId); return view; } + async openThreadInAvailableView(threadId: string): Promise { + const target = this.findThreadPanelTarget(threadId); + await this.openThreadInTarget(target, threadId); + } + + async focusThreadInOpenView(threadId: string): Promise { + const target = this.findOpenThreadPanelTarget(threadId) ?? this.findRestoredThreadPanelTarget(threadId); + if (!target) return false; + + await this.openThreadInTarget(target, threadId); + return true; + } + async openThreadInIdleEmptyView(threadId: string): Promise { - for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { - if (!(leaf.view instanceof CodexChatView)) continue; - if (!isIdleEmptyPanelSnapshot(leaf.view.openPanelSnapshot())) continue; - await this.app.workspace.revealLeaf(leaf); - await leaf.view.openThread(threadId); - return true; - } - return false; + const target = this.findIdleEmptyThreadPanelTarget(); + if (!target) return false; + + await this.openThreadInTarget(target, threadId); + return true; } async activateThreadsView(): Promise { @@ -170,16 +199,31 @@ export default class CodexPanelPlugin extends Plugin { } } + notifyThreadArchived(threadId: string): void { + this.notifyOpenPanels((view) => { + view.notifyThreadArchived(threadId); + }); + this.refreshThreadSurfaces(); + } + + notifyThreadRenamed(threadId: string, name: string): void { + this.notifyOpenPanels((view) => { + view.notifyThreadRenamed(threadId, name); + }); + this.refreshThreadSurfaces(); + } + getOpenPanelSnapshots(): OpenCodexPanelSnapshot[] { return this.app.workspace .getLeavesOfType(VIEW_TYPE_CODEX_PANEL) .flatMap((leaf) => (leaf.view instanceof CodexChatView ? [leaf.view.openPanelSnapshot()] : [])); } - async focusOpenPanel(viewId: string): Promise { + async focusOpenPanel(viewId: string, threadId: string | null = null): Promise { for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { if (leaf.view instanceof CodexChatView && leaf.view.openPanelSnapshot().viewId === viewId) { await this.app.workspace.revealLeaf(leaf); + await leaf.view.focusThread(threadId); return true; } } @@ -206,6 +250,78 @@ export default class CodexPanelPlugin extends Plugin { return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER); } + private activateThreadResumeView(): Promise { + return this.activateNewView({ connect: false }); + } + + private findThreadPanelTarget(threadId: string): ThreadPanelTarget { + return ( + this.findOpenThreadPanelTarget(threadId) ?? + this.findRestoredThreadPanelTarget(threadId) ?? + this.findIdleEmptyThreadPanelTarget() ?? { kind: "new" } + ); + } + + private findOpenThreadPanelTarget(threadId: string): ThreadPanelTarget | null { + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { + if (!(leaf.view instanceof CodexChatView)) continue; + if (leaf.view.openPanelSnapshot().threadId !== threadId) continue; + return { kind: "open", leaf, view: leaf.view }; + } + return null; + } + + private findRestoredThreadPanelTarget(threadId: string): ThreadPanelTarget | null { + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { + if (leaf.view instanceof CodexChatView) continue; + if (restoredPanelThreadId(leaf) !== threadId) continue; + return { kind: "restored", leaf }; + } + return null; + } + + private findIdleEmptyThreadPanelTarget(): ThreadPanelTarget | null { + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { + if (!(leaf.view instanceof CodexChatView)) continue; + if (!isIdleEmptyPanelSnapshot(leaf.view.openPanelSnapshot())) continue; + return { kind: "empty", leaf, view: leaf.view }; + } + return null; + } + + private async openThreadInTarget(target: ThreadPanelTarget, threadId: string): Promise { + switch (target.kind) { + case "open": + await this.app.workspace.revealLeaf(target.leaf); + await target.view.focusThread(threadId); + return; + case "restored": + await this.app.workspace.revealLeaf(target.leaf); + await target.leaf.loadIfDeferred(); + if (target.leaf.view instanceof CodexChatView) { + await target.leaf.view.focusThread(threadId); + } + return; + case "empty": + await this.app.workspace.revealLeaf(target.leaf); + await target.view.openThread(threadId); + return; + case "new": + await this.openThreadInNewView(threadId); + return; + } + } + + private notifyOpenPanels(callback: (view: CodexChatView) => void): void { + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { + if (leaf.view instanceof CodexChatView) callback(leaf.view); + } + } + + private refreshThreadSurfaces(): void { + this.refreshOpenThreadLists(); + } + private scheduleBootRestoredPanelLoads(): void { this.scheduleBootRestoredPanelLoadTimer(() => { const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL); @@ -246,3 +362,10 @@ function isIdleEmptyPanelSnapshot(snapshot: OpenCodexPanelSnapshot): boolean { !snapshot.hasComposerDraft ); } + +function restoredPanelThreadId(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; +} diff --git a/tests/features/chat/chat-message-renderer.test.ts b/tests/features/chat/chat-message-renderer.test.ts new file mode 100644 index 00000000..57f56237 --- /dev/null +++ b/tests/features/chat/chat-message-renderer.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import { ChatMessageRenderer } from "../../../src/features/chat/chat-message-renderer"; +import { createChatState } from "../../../src/features/chat/chat-state"; +import { installObsidianDomShims } from "./ui/dom-test-helpers"; + +installObsidianDomShims(); + +describe("ChatMessageRenderer scroll pinning", () => { + it("does not force the bottom into view when the user is reading older messages", async () => { + const state = createChatState(); + state.activeThreadId = "thread"; + state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Initial message", turnId: "turn" }]; + const parent = document.createElement("div"); + const renderer = chatMessageRenderer(state); + + renderer.render(parent); + const messages = parent.querySelector(".codex-panel__messages"); + expect(messages).not.toBeNull(); + if (!messages) return; + await settleMessageRender(messages); + + Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true }); + messages.scrollTop = 100; + messages.dispatchEvent(new Event("scroll")); + expect(state.messagesPinnedToBottom).toBe(false); + + const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); + scrollIntoView.mockClear(); + state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Updated streaming message", turnId: "turn" }]; + renderer.render(parent); + await settleMessageRender(messages); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(state.messagesPinnedToBottom).toBe(false); + }); + + it("does not run a pending bottom pin after the user scrolls away", async () => { + const state = createChatState(); + state.activeThreadId = "thread"; + state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Streaming message", turnId: "turn" }]; + const parent = document.createElement("div"); + const renderer = chatMessageRenderer(state); + + const messages = parent.createDiv({ cls: "codex-panel__messages" }); + Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true }); + messages.scrollTop = 920; + renderer.render(parent); + + const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); + scrollIntoView.mockClear(); + messages.scrollTop = 100; + messages.dispatchEvent(new Event("scroll")); + await settleMessageRender(messages); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(state.messagesPinnedToBottom).toBe(false); + }); +}); + +function chatMessageRenderer(state = createChatState()): ChatMessageRenderer { + return new ChatMessageRenderer({ + app: { + workspace: { + getActiveFile: vi.fn(() => null), + openLinkText: vi.fn(), + }, + } as never, + owner: {} as never, + state, + vaultPath: "/vault", + blockSignatures: new Map(), + consumeScrollIntent: () => "auto", + loadOlderTurns: vi.fn(), + rollbackThread: vi.fn(), + implementPlan: vi.fn(), + openTurnDiff: vi.fn(), + pendingRequestsSignature: () => "", + renderPendingRequests: () => null, + }); +} + +async function settleMessageRender(element: HTMLElement): Promise { + await Promise.resolve(); + await new Promise((resolve) => { + element.win.requestAnimationFrame(() => { + resolve(); + }); + }); +} diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index daffd112..6f9f0653 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "../../../src/settings/model"; +import type { CodexChatHost } from "../../../src/features/chat/view"; import { notices } from "../../mocks/obsidian"; import { installObsidianDomShims } from "./ui/dom-test-helpers"; @@ -135,6 +136,7 @@ describe("CodexChatView connection lifecycle", () => { await view.onOpen(); await vi.advanceTimersByTimeAsync(1_500); + expect(connectionMock.state.connectCalls).toBe(0); expect(client.resumeThread).not.toHaveBeenCalled(); await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); @@ -144,6 +146,22 @@ describe("CodexChatView connection lifecycle", () => { expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20); }); + it("hydrates a focused restored thread immediately", async () => { + vi.useFakeTimers(); + const client = connectedClient(); + connectionMock.state.client = client; + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + await view.onOpen(); + + expect(client.resumeThread).not.toHaveBeenCalled(); + await view.focusThread("thread-1"); + + 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; @@ -170,6 +188,235 @@ describe("CodexChatView connection lifecycle", () => { expect(requestSaveLayout).toHaveBeenCalledTimes(1); }); + it("resets to an unstarted empty chat without starting a thread", async () => { + const requestSaveLayout = vi.fn(); + const client = connectedClient(); + connectionMock.state.client = client; + const view = await chatView({ requestSaveLayout }); + + await view.openThread("thread-1"); + await view.startNewThread(); + + expect(client.startThread).not.toHaveBeenCalled(); + expect(view.getState()).toEqual({ version: 1 }); + expect(view.openPanelSnapshot()).toMatchObject({ threadId: null, busy: false, activeTurnId: null, hasComposerDraft: false }); + expect(requestSaveLayout).toHaveBeenCalledTimes(2); + }); + + it("starts a thread only when /new includes a message to send", async () => { + const client = connectedClient({ + startThread: vi.fn().mockResolvedValue(startedThread("thread-new")), + }); + connectionMock.state.client = client; + const view = await chatView(); + + await view.onOpen(); + await view.onOpen(); + await view.connect(); + view.setComposerText("/new hello"); + await (view as unknown as { submitComposerAction: () => Promise }).submitComposerAction(); + + expect(client.startThread).toHaveBeenCalledOnce(); + expect(client.startTurn).toHaveBeenCalledWith("thread-new", "/vault", [{ type: "text", text: "hello", text_elements: [] }]); + }); + + it("routes slash resume through the shared panel selection path", async () => { + const openThreadInAvailableView = vi.fn().mockResolvedValue(undefined); + const host = chatHost({ + openThreadInAvailableView, + }); + connectionMock.state.client = connectedClient({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), + }); + const view = await chatView({ host }); + + await view.connect(); + view.setComposerText("/resume thread-1"); + await (view as unknown as { submitComposerAction: () => Promise }).submitComposerAction(); + + expect(openThreadInAvailableView).toHaveBeenCalledWith("thread-1"); + expect(connectionMock.state.client["resumeThread"]).not.toHaveBeenCalled(); + }); + + it("keeps resumed messages pinned to bottom after slash resume in the same empty panel", async () => { + const client = connectedClient({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), + threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), + }); + connectionMock.state.client = client; + const view = await chatView({ + host: chatHost({ + openThreadInAvailableView: async (threadId) => { + await view.openThread(threadId); + }, + }), + }); + + await view.onOpen(); + await view.connect(); + 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; + + view.setComposerText("/resume thread-1"); + await (view as unknown as { submitComposerAction: () => Promise }).submitComposerAction(); + await new Promise((resolve) => { + messages.win.requestAnimationFrame(() => { + resolve(); + }); + }); + + const renderedMessages = view.containerEl.querySelector(".codex-panel__messages"); + expect(renderedMessages?.scrollTop).toBe(1000); + }); + + it("routes slash archive through shared panel notifications", async () => { + const notifyThreadArchived = vi.fn(); + const host = chatHost({ + notifyThreadArchived, + }); + const client = connectedClient({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), + }); + connectionMock.state.client = client; + const view = await chatView({ host }); + + await view.connect(); + view.setComposerText("/archive thread-1"); + await (view as unknown as { submitComposerAction: () => Promise }).submitComposerAction(); + + expect(client.archiveThread).toHaveBeenCalledWith("thread-1"); + expect(notifyThreadArchived).toHaveBeenCalledWith("thread-1"); + }); + + it("clears the active thread when another view archives it", async () => { + const requestSaveLayout = vi.fn(); + const client = connectedClient(); + connectionMock.state.client = client; + const view = await chatView({ requestSaveLayout }); + + await view.openThread("thread-1"); + view.notifyThreadArchived("thread-1"); + + expect(view.getState()).toEqual({ version: 1 }); + expect(requestSaveLayout).toHaveBeenCalledTimes(2); + }); + + it("updates restored panel title from shared rename notifications", async () => { + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never); + view.notifyThreadRenamed("thread-1", "After rename"); + + expect(view.getDisplayText()).toBe("Codex: After rename"); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" }); + }); + + it("scrolls resumed messages to the bottom after history hydrates", async () => { + const client = connectedClient({ + threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), + }); + connectionMock.state.client = client; + const view = await chatView(); + + 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; + + await view.openThread("thread-1"); + await new Promise((resolve) => { + messages.win.requestAnimationFrame(() => { + resolve(); + }); + }); + + const renderedMessages = view.containerEl.querySelector(".codex-panel__messages"); + expect(renderedMessages?.scrollTop).toBe(1000); + }); + + it("renders resumed thread metadata before history hydration completes", async () => { + const history = deferred<{ data: unknown[]; nextCursor: null }>(); + const client = connectedClient({ + threadTurnsList: vi.fn(() => history.promise), + }); + connectionMock.state.client = client; + const view = await chatView(); + + const opening = view.openThread("thread-1"); + await vi.waitFor(() => { + expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20); + }); + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); + expect(view.containerEl.textContent).toContain("Loading thread..."); + + history.resolve({ data: [], nextCursor: null }); + await opening; + }); + + it("ignores stale resume results when another thread is opened first", async () => { + const firstResume = deferred>(); + const secondResume = deferred>(); + const client = connectedClient({ + resumeThread: vi.fn((threadId: string) => (threadId === "thread-1" ? firstResume.promise : secondResume.promise)), + }); + connectionMock.state.client = client; + const view = await chatView(); + + const firstOpen = view.openThread("thread-1"); + await vi.waitFor(() => { + expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault"); + }); + const secondOpen = view.openThread("thread-2"); + await vi.waitFor(() => { + expect(client.resumeThread).toHaveBeenCalledWith("thread-2", "/vault"); + }); + + secondResume.resolve(resumedThread("thread-2")); + await secondOpen; + firstResume.resolve(resumedThread("thread-1")); + await firstOpen; + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); + expect(client.threadTurnsList).toHaveBeenCalledTimes(1); + expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20); + }); + + it("invalidates stale history hydration when a second resume starts", async () => { + const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>(); + const client = connectedClient({ + resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))), + threadTurnsList: vi.fn((threadId: string) => + threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }), + ), + }); + connectionMock.state.client = client; + const view = await chatView(); + + const firstOpen = view.openThread("thread-1"); + await vi.waitFor(() => { + expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20); + }); + const secondOpen = view.openThread("thread-2"); + await vi.waitFor(() => { + expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20); + }); + + firstHistory.resolve({ data: [turnWithUserMessage("first prompt")], nextCursor: null }); + await firstOpen; + await secondOpen; + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); + expect(view.containerEl.textContent).not.toContain("first prompt"); + }); + it("scrolls restored messages to the bottom when the panel is focused", async () => { const activeLeafChangeListeners: ((leaf: unknown) => void)[] = []; const view = await chatView({ activeLeafChangeListeners }); @@ -209,9 +456,28 @@ function baseClient() { listSkills: vi.fn().mockResolvedValue({ data: [] }), readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: null }), listThreads: vi.fn().mockResolvedValue({ data: [] }), + startThread: vi.fn().mockResolvedValue(startedThread("thread-new")), resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")), threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }), startTurn: vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }), + archiveThread: vi.fn().mockResolvedValue({}), + }; +} + +function startedThread(threadId: string) { + return { + thread: { + id: threadId, + name: null, + preview: "", + cwd: "/vault", + cliVersion: "0.0.0", + }, + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, }; } @@ -232,7 +498,71 @@ function resumedThread(threadId: string) { }; } -async function chatView(options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; requestSaveLayout?: () => void } = {}) { +function threadFixture(threadId: string) { + return { + id: threadId, + sessionId: "session", + forkedFromId: null, + preview: "Restored thread", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "0.0.0", + source: "appServer", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} + +function turnWithUserMessage(text: string) { + return { + id: "turn-1", + startedAt: 1, + completedAt: 2, + items: [{ type: "userMessage", id: "user-1", content: [{ type: "text", text, text_elements: [] }] }], + }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function chatHost(overrides: Partial = {}): CodexChatHost { + return { + settings: { + ...DEFAULT_SETTINGS, + codexPath: "codex", + sendShortcut: "enter", + }, + vaultPath: "/vault", + openThreadInNewView: vi.fn(), + openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), + openTurnDiff: vi.fn(), + notifyThreadArchived: vi.fn(), + notifyThreadRenamed: vi.fn(), + refreshOpenThreadLists: vi.fn(), + refreshThreadsViewLiveState: vi.fn(), + refreshThreadsViewThreadList: vi.fn(), + ...overrides, + }; +} + +async function chatView( + options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; host?: CodexChatHost; requestSaveLayout?: () => void } = {}, +) { + const host = options.host ?? chatHost(); const { CodexChatView } = await import("../../../src/features/chat/view"); const containerEl = document.createElement("div"); containerEl.createDiv(); @@ -256,16 +586,6 @@ async function chatView(options: { activeLeafChangeListeners?: ((leaf: unknown) }, containerEl, } as never, - { - settings: { - ...DEFAULT_SETTINGS, - codexPath: "codex", - sendShortcut: "enter", - }, - vaultPath: "/vault", - openThreadInNewView: vi.fn(), - openTurnDiff: vi.fn(), - refreshOpenThreadLists: vi.fn(), - }, + host, ); } diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 8bcbd231..6c7c2619 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -92,32 +92,53 @@ describe("CodexThreadsView", () => { expect(view.containerEl.textContent).not.toContain("Late thread"); }); - it("reuses an idle empty panel before opening a new panel", async () => { + it("ignores stale refresh results when a newer refresh completes first", async () => { + let resolveFirst!: (value: unknown) => void; + let resolveSecond!: (value: unknown) => void; + const listThreads = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); connectionMock.state.client = clientFixture({ - listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + listThreads, }); - const host = threadsHost({ - openThreadInIdleEmptyView: vi.fn().mockResolvedValue(true), - openThreadInNewView: vi.fn(), - }); - const view = await threadsView(host); - - await view.refresh(); - view.containerEl.querySelector(".codex-panel-threads__row")?.click(); + const view = await threadsView(); + const firstRefresh = view.refresh(); await vi.waitFor(() => { - expect(host.openThreadInIdleEmptyView).toHaveBeenCalledWith("thread"); + expect(listThreads).toHaveBeenCalledTimes(1); }); - expect(host.openThreadInNewView).not.toHaveBeenCalled(); + const secondRefresh = view.refresh(); + await vi.waitFor(() => { + expect(listThreads).toHaveBeenCalledTimes(2); + }); + + resolveSecond({ data: [threadFixture({ id: "second", preview: "Second thread" })] }); + await secondRefresh; + expect(view.containerEl.textContent).toContain("Second thread"); + + resolveFirst({ data: [threadFixture({ id: "first", preview: "First thread" })] }); + await firstRefresh; + expect(view.containerEl.textContent).toContain("Second thread"); + expect(view.containerEl.textContent).not.toContain("First thread"); }); - it("opens a new panel when no idle empty panel can be reused", async () => { + it("opens selected threads through the shared panel selection path", async () => { connectionMock.state.client = clientFixture({ listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), }); const host = threadsHost({ - openThreadInIdleEmptyView: vi.fn().mockResolvedValue(false), - openThreadInNewView: vi.fn(), + openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), }); const view = await threadsView(host); @@ -125,8 +146,52 @@ describe("CodexThreadsView", () => { view.containerEl.querySelector(".codex-panel-threads__row")?.click(); await vi.waitFor(() => { - expect(host.openThreadInIdleEmptyView).toHaveBeenCalledWith("thread"); - expect(host.openThreadInNewView).toHaveBeenCalledWith("thread"); + expect(host.openThreadInAvailableView).toHaveBeenCalledWith("thread"); + }); + }); + + it("notifies open panels after archiving a thread", async () => { + const archiveThread = vi.fn().mockResolvedValue({}); + connectionMock.state.client = clientFixture({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + archiveThread, + }); + const host = threadsHost({ + notifyThreadArchived: vi.fn(), + }); + const view = await threadsView(host); + + await view.refresh(); + view.containerEl.querySelector('[aria-label="Archive thread"]')?.click(); + + await vi.waitFor(() => { + expect(archiveThread).toHaveBeenCalledWith("thread"); + expect(host.notifyThreadArchived).toHaveBeenCalledWith("thread"); + }); + }); + + it("notifies open panels after renaming a thread", async () => { + const setThreadName = vi.fn().mockResolvedValue({}); + connectionMock.state.client = clientFixture({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + setThreadName, + }); + const host = threadsHost({ + notifyThreadRenamed: vi.fn(), + }); + const view = await threadsView(host); + + await view.refresh(); + view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); + expect(input).not.toBeNull(); + if (!input) return; + input.value = "Renamed thread"; + view.containerEl.querySelector('[aria-label="Save thread name"]')?.click(); + + await vi.waitFor(() => { + expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread"); + expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread"); }); }); }); @@ -134,6 +199,8 @@ describe("CodexThreadsView", () => { function clientFixture(overrides: Record = {}): Record { return { listThreads: vi.fn().mockResolvedValue({ data: [] }), + archiveThread: vi.fn().mockResolvedValue({}), + setThreadName: vi.fn().mockResolvedValue({}), rejectServerRequest: vi.fn(), ...overrides, }; @@ -146,10 +213,10 @@ function threadsHost(overrides: Record = {}) { codexPath: "codex", }, vaultPath: "/vault", - openThreadInNewView: vi.fn(), - openThreadInIdleEmptyView: vi.fn().mockResolvedValue(false), + openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), getOpenPanelSnapshots: vi.fn(() => []), - focusOpenPanel: vi.fn(), + notifyThreadArchived: vi.fn(), + notifyThreadRenamed: vi.fn(), refreshOpenThreadLists: vi.fn(), ...overrides, }; diff --git a/tests/main.test.ts b/tests/main.test.ts index 808b0d06..1bcd3412 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { FileSystemAdapter } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../src/constants"; +import { DEFAULT_SETTINGS } from "../src/settings/model"; +import type { CodexChatView } from "../src/features/chat/view"; +import { installObsidianDomShims } from "./features/chat/ui/dom-test-helpers"; + +installObsidianDomShims(); describe("CodexPanelPlugin boot restored panel loading", () => { beforeEach(() => { @@ -41,6 +46,120 @@ describe("CodexPanelPlugin boot restored panel loading", () => { expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled(); }); + + it("loads and focuses a deferred restored panel before opening another panel", async () => { + const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); + const plugin = await pluginWithLeaves([restoredLeaf]); + const { CodexChatView } = await import("../src/features/chat/view"); + restoredLeaf.loadIfDeferred.mockImplementation(async () => { + restoredLeaf.view = chatView(CodexChatView, restoredLeaf); + }); + + await plugin.openThreadInAvailableView("thread-1"); + + expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledTimes(1); + expect(restoredLeaf.view).toBeInstanceOf(CodexChatView); + }); + + it("focuses an already open thread before reusing an empty panel", async () => { + const { CodexChatView } = await import("../src/features/chat/view"); + const openLeaf = leaf(); + openLeaf.view = chatView(CodexChatView, openLeaf); + const openView = openLeaf.view as CodexChatView; + vi.spyOn(openView, "openPanelSnapshot").mockReturnValue({ + viewId: "open-view", + threadId: "thread-1", + busy: false, + activeTurnId: null, + pendingApprovals: 0, + pendingUserInputs: 0, + hasComposerDraft: false, + connected: true, + }); + vi.spyOn(openView, "focusThread").mockResolvedValue(undefined); + const emptyLeaf = leaf(); + emptyLeaf.view = chatView(CodexChatView, emptyLeaf); + const emptyView = emptyLeaf.view as CodexChatView; + const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]); + + await plugin.openThreadInAvailableView("thread-1"); + + expect((plugin.app.workspace.revealLeaf as ReturnType).mock.calls).toContainEqual([openLeaf]); + expect(openEmptyThread).not.toHaveBeenCalled(); + }); + + it("reuses an idle empty panel before opening a new panel", async () => { + const { CodexChatView } = await import("../src/features/chat/view"); + const busyLeaf = leaf(); + busyLeaf.view = chatView(CodexChatView, busyLeaf); + const busyView = busyLeaf.view as CodexChatView; + vi.spyOn(busyView, "openPanelSnapshot").mockReturnValue({ + viewId: "busy-view", + threadId: "other-thread", + busy: false, + activeTurnId: null, + pendingApprovals: 0, + pendingUserInputs: 0, + hasComposerDraft: false, + connected: true, + }); + const emptyLeaf = leaf(); + emptyLeaf.view = chatView(CodexChatView, emptyLeaf); + const emptyView = emptyLeaf.view as CodexChatView; + vi.spyOn(emptyView, "openPanelSnapshot").mockReturnValue({ + viewId: "empty-view", + threadId: null, + busy: false, + activeTurnId: null, + pendingApprovals: 0, + pendingUserInputs: 0, + hasComposerDraft: false, + connected: true, + }); + const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([busyLeaf, emptyLeaf]); + + await plugin.openThreadInAvailableView("thread-1"); + + expect(openEmptyThread).toHaveBeenCalledWith("thread-1"); + }); + + it("opens a thread in a new panel without a separate pre-connect", async () => { + const newLeaf = leaf(); + const plugin = await pluginWithLeaves([]); + (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); + const { CodexChatView } = await import("../src/features/chat/view"); + const view = chatView(CodexChatView, newLeaf); + newLeaf.setViewState.mockImplementation(async () => { + newLeaf.view = view; + }); + const connect = vi.spyOn(view, "connect").mockResolvedValue(undefined); + const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined); + + await plugin.openThreadInNewView("thread-1"); + + expect(connect).not.toHaveBeenCalled(); + expect(openThread).toHaveBeenCalledWith("thread-1"); + }); + + it("refreshes open thread lists after archive lifecycle notifications", async () => { + const plugin = await pluginWithLeaves([]); + const refreshOpenThreadLists = vi.spyOn(plugin, "refreshOpenThreadLists").mockImplementation(() => undefined); + + plugin.notifyThreadArchived("thread-1"); + + expect(refreshOpenThreadLists).toHaveBeenCalledOnce(); + }); + + it("refreshes open thread lists after rename lifecycle notifications", async () => { + const plugin = await pluginWithLeaves([]); + const refreshOpenThreadLists = vi.spyOn(plugin, "refreshOpenThreadLists").mockImplementation(() => undefined); + + plugin.notifyThreadRenamed("thread-1", "Renamed thread"); + + expect(refreshOpenThreadLists).toHaveBeenCalledOnce(); + }); }); async function pluginWithLeaves(leaves: ReturnType[]) { @@ -54,14 +173,57 @@ async function pluginWithLeaves(leaves: ReturnType[]) { }, workspace: { getLeavesOfType: vi.fn((type: string) => (type === VIEW_TYPE_CODEX_PANEL ? leaves : [])), + revealLeaf: vi.fn().mockResolvedValue(undefined), + getRightLeaf: vi.fn(() => null), }, } as never, {} as never, ); } -function leaf() { +type TestLeaf = ReturnType; + +function leaf(options: { state?: Record } = {}) { return { + view: null as unknown, + getViewState: vi.fn(() => ({ type: VIEW_TYPE_CODEX_PANEL, state: options.state ?? {} })), + setViewState: vi.fn().mockResolvedValue(undefined), loadIfDeferred: vi.fn().mockResolvedValue(undefined), }; } + +function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) { + const containerEl = document.createElement("div"); + containerEl.createDiv(); + containerEl.createDiv(); + return new CodexChatViewCtor( + { + ...leaf, + app: { + workspace: { + getActiveFile: vi.fn(() => null), + on: vi.fn(() => ({})), + openLinkText: vi.fn(), + requestSaveLayout: vi.fn(), + }, + vault: { + on: vi.fn(() => ({})), + getMarkdownFiles: vi.fn(() => []), + }, + }, + containerEl, + } as never, + { + settings: { ...DEFAULT_SETTINGS, codexPath: "codex", sendShortcut: "enter" }, + vaultPath: "/vault", + openThreadInNewView: vi.fn(), + openThreadInAvailableView: vi.fn(), + openTurnDiff: vi.fn(), + notifyThreadArchived: vi.fn(), + notifyThreadRenamed: vi.fn(), + refreshOpenThreadLists: vi.fn(), + refreshThreadsViewLiveState: vi.fn(), + refreshThreadsViewThreadList: vi.fn(), + }, + ); +}