Keep fork-and-archive in the current panel

This commit is contained in:
murashit 2026-06-02 21:36:40 +09:00
parent 4a4e1053fe
commit 646a877691
6 changed files with 84 additions and 28 deletions

View file

@ -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.

View file

@ -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,

View file

@ -22,12 +22,12 @@ export interface ChatThreadActionControllerHost {
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
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));
}

View file

@ -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;

View file

@ -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<typeof clientMo
setStatus: vi.fn(),
setComposerText: vi.fn(),
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
openThreadInCurrentPanel: vi.fn().mockResolvedValue(undefined),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
notifyActiveThreadIdentityChanged: vi.fn(),
refreshThreads: vi.fn().mockResolvedValue(undefined),
refreshSharedThreadListFromOpenSurface: vi.fn(),
closePanel: vi.fn(),
} satisfies ChatThreadActionControllerHost;
}

View file

@ -540,6 +540,31 @@ describe("CodexChatView connection lifecycle", () => {
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<void> } })
.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<ServerNotification, { method: "turn/started" }> {
return {
method: "turn/started",