diff --git a/README.md b/README.md index 4a0211de..c3acfeb9 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Codex goal management is not supported. Incoming goal notifications are shown on Codex Panel adds vault-aware behavior where Obsidian benefits from a different surface than the terminal UI: -- Keep Codex next to your notes in Obsidian panes. The Threads view adds a left sidebar list with live status for currently open threads, and forks open in a new pane in the right sidebar so the source thread stays visible. +- Keep Codex next to your notes in Obsidian panes. The Threads view adds a left sidebar list with live status for currently open threads. Regular forks open in a new pane in the right sidebar so the source thread stays visible; fork-and-archive replaces the source panel with the forked thread. - Use Obsidian commands and the ribbon icon for panel and thread navigation. - Compose with Obsidian-friendly shortcuts: `Enter` sends by default, `Shift+Enter` inserts a newline, and you can switch sending to `Cmd/Ctrl+Enter` in Codex Panel settings. You can also opt in to scrolling the thread with `Up`/`Ctrl+P` from the first composer line and `Down`/`Ctrl+N` from the last composer line. - Use vault-aware links while composing and reading messages. Wikilink suggestions use Obsidian file search and recent notes; sent wikilinks resolve to Codex file mentions when the target exists; rendered Markdown links to vault files open in Obsidian. diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 8a4d13bb..9c49fcfa 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -116,7 +116,6 @@ export interface ChatViewControllerAssemblyHost { registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void; isOwnLeaf: (leaf: WorkspaceLeaf | null) => boolean; archiveAdapter: () => ArchiveExportAdapter; - closePanel: () => void; } export function createChatViewControllerAssembly(host: ChatViewControllerAssemblyHost): ChatViewControllerAssembly { @@ -404,6 +403,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl setStatus: host.effects.status.set, setComposerText: host.effects.composer.setText, openThreadInNewView: (threadId) => host.plugin.openThreadInNewView(threadId), + openThreadInCurrentPanel: host.selectThread, notifyThreadArchived: host.plugin.notifyThreadArchived.bind(host.plugin), notifyThreadRenamed: (threadId, name) => { host.plugin.notifyThreadRenamed(threadId, name); @@ -413,7 +413,6 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl refreshSharedThreadListFromOpenSurface: () => { host.plugin.refreshSharedThreadListFromOpenSurface(); }, - closePanel: host.closePanel, }); toolbarPanels = new ToolbarPanelController({ stateStore: host.stateStore, diff --git a/src/features/chat/thread-actions.ts b/src/features/chat/thread-actions.ts index ab35e150..acac1e52 100644 --- a/src/features/chat/thread-actions.ts +++ b/src/features/chat/thread-actions.ts @@ -22,12 +22,12 @@ export interface ChatThreadActionControllerHost { setStatus: (status: string) => void; setComposerText: (text: string) => void; openThreadInNewView: (threadId: string) => Promise; + openThreadInCurrentPanel: (threadId: string) => Promise; notifyThreadArchived: (threadId: string) => void; notifyThreadRenamed: (threadId: string, name: string) => void; notifyActiveThreadIdentityChanged: () => void; refreshThreads: () => Promise; refreshSharedThreadListFromOpenSurface: () => void; - closePanel: () => void; } export class ChatThreadActionController { @@ -108,20 +108,23 @@ export class ChatThreadActionController { this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`); } } - let openedForkPanel = false; + if (archiveSource) { + if (!(await this.archiveThreadOnServer(threadId))) return; + try { + await this.host.openThreadInCurrentPanel(forkedThreadId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`); + } + this.host.notifyThreadArchived(threadId); + return; + } try { await this.host.openThreadInNewView(forkedThreadId); - openedForkPanel = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`); } - if (archiveSource) { - if (!openedForkPanel) return; - if (!(await this.archiveThreadOnServer(threadId))) return; - this.host.closePanel(); - this.host.notifyThreadArchived(threadId); - } } catch (error) { this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); } diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 54384817..7f44daa9 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -162,9 +162,6 @@ export class CodexChatView extends ItemView { }, isOwnLeaf: (candidateLeaf) => candidateLeaf === this.leaf, archiveAdapter: () => this.app.vault.adapter, - closePanel: () => { - this.leaf.detach(); - }, }); this.connection = controllers.connection; this.controller = controllers.controller; diff --git a/tests/features/chat/thread-actions.test.ts b/tests/features/chat/thread-actions.test.ts index b8abb7fc..5ff66b11 100644 --- a/tests/features/chat/thread-actions.test.ts +++ b/tests/features/chat/thread-actions.test.ts @@ -18,24 +18,23 @@ describe("ChatThreadActionController", () => { expect(client.rollbackThread).toHaveBeenCalledWith("forked", 2); expect(host.openThreadInNewView).toHaveBeenCalledWith("forked"); expect(client.archiveThread).not.toHaveBeenCalled(); - expect(host.closePanel).not.toHaveBeenCalled(); + expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled(); }); - it("does not archive the source when fork and archive cannot open the fork panel", async () => { + it("does not open the fork in a new panel before archiving the source", async () => { const client = clientMock(); const host = hostMock({ client, displayItems: turnItems() }); - host.openThreadInNewView.mockRejectedValue(new Error("no panel")); const controller = new ChatThreadActionController(host); await controller.forkThreadFromTurn("source", "turn-2", true); expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); expect(client.rollbackThread).toHaveBeenCalledWith("forked", 1); - expect(client.archiveThread).not.toHaveBeenCalled(); - expect(host.closePanel).not.toHaveBeenCalled(); + expect(host.openThreadInNewView).not.toHaveBeenCalled(); + expect(client.archiveThread).toHaveBeenCalledWith("source"); }); - it("does not close the source panel when fork and archive fails to archive", async () => { + it("keeps the source panel when fork and archive fails to archive", async () => { const client = clientMock(); client.archiveThread.mockRejectedValue(new Error("archive failed")); const host = hostMock({ client, displayItems: turnItems() }); @@ -45,12 +44,12 @@ describe("ChatThreadActionController", () => { expect(client.rollbackThread).not.toHaveBeenCalled(); expect(client.archiveThread).toHaveBeenCalledWith("source"); - expect(host.closePanel).not.toHaveBeenCalled(); + expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled(); expect(host.notifyThreadArchived).not.toHaveBeenCalled(); expect(host.addSystemMessage).toHaveBeenCalledWith("archive failed"); }); - it("closes the source panel before notifying surfaces after fork and archive succeeds", async () => { + it("replaces the source panel before notifying surfaces after fork and archive succeeds", async () => { const client = clientMock(); const host = hostMock({ client, displayItems: turnItems() }); const controller = new ChatThreadActionController(host); @@ -58,12 +57,25 @@ describe("ChatThreadActionController", () => { await controller.forkThreadFromTurn("source", "turn-3", true); expect(client.archiveThread).toHaveBeenCalledWith("source"); - expect(host.closePanel).toHaveBeenCalledOnce(); + expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked"); expect(host.notifyThreadArchived).toHaveBeenCalledWith("source"); - const closeOrder = host.closePanel.mock.invocationCallOrder[0]; + const openOrder = host.openThreadInCurrentPanel.mock.invocationCallOrder[0]; const notifyOrder = host.notifyThreadArchived.mock.invocationCallOrder[0]; - if (closeOrder === undefined || notifyOrder === undefined) throw new Error("Expected close and archive notification calls."); - expect(closeOrder).toBeLessThan(notifyOrder); + if (openOrder === undefined || notifyOrder === undefined) throw new Error("Expected open and archive notification calls."); + expect(openOrder).toBeLessThan(notifyOrder); + }); + + it("notifies surfaces when fork and archive succeeds but the fork cannot replace the source panel", async () => { + const client = clientMock(); + const host = hostMock({ client, displayItems: turnItems() }); + host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed")); + const controller = new ChatThreadActionController(host); + + await controller.forkThreadFromTurn("source", "turn-3", true); + + expect(client.archiveThread).toHaveBeenCalledWith("source"); + expect(host.notifyThreadArchived).toHaveBeenCalledWith("source"); + expect(host.addSystemMessage).toHaveBeenCalledWith("Archived thread source, but could not open forked thread forked: resume failed"); }); }); @@ -103,11 +115,11 @@ function hostMock({ client, displayItems }: { client: ReturnType { expect(notifyThreadArchived).toHaveBeenCalledWith("thread-1"); }); + it("replaces the current panel with the forked thread after fork and archive", async () => { + const notifyThreadArchived = vi.fn(); + const host = chatHost({ notifyThreadArchived }); + const client = connectedClient({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("source")] }), + resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))), + threadTurnsList: vi.fn().mockResolvedValue({ data: [completedTurn("turn-1")], nextCursor: null }), + forkThread: vi.fn().mockResolvedValue({ thread: threadFixture("forked") }), + }); + connectionMock.state.client = client; + const view = await chatView({ host }); + + await view.connect(); + await view.openThread("source"); + await (view as unknown as { threadActions: { forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => Promise } }) + .threadActions.forkThreadFromTurn("source", "turn-1", true); + + expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); + expect(client.archiveThread).toHaveBeenCalledWith("source"); + expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault"); + expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" }); + expect(view.openPanelSnapshot()).toMatchObject({ threadId: "forked" }); + expect(notifyThreadArchived).toHaveBeenCalledWith("source"); + }); + it("clears the active thread when another view archives it", async () => { const requestSaveLayout = vi.fn(); const client = connectedClient(); @@ -778,6 +803,10 @@ function baseClient() { resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")), threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }), startTurn: vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }), + forkThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }), + rollbackThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }), + setThreadName: vi.fn().mockResolvedValue({}), + readThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }), archiveThread: vi.fn().mockResolvedValue({}), }; } @@ -867,6 +896,22 @@ function turnWithUserMessage(text: string) { }; } +function completedTurn(turnId: string) { + return { + id: turnId, + status: "completed", + error: null, + startedAt: 1, + completedAt: 2, + durationMs: 1, + itemsView: "full", + items: [ + { type: "userMessage", id: "user-1", content: [{ type: "text", text: "hello", text_elements: [] }] }, + { type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null }, + ], + }; +} + function turnStartedNotification(threadId: string, turnId: string): Extract { return { method: "turn/started",