mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Keep fork-and-archive in the current panel
This commit is contained in:
parent
4a4e1053fe
commit
646a877691
6 changed files with 84 additions and 28 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue