Add message-level fork actions

This commit is contained in:
murashit 2026-05-29 17:32:28 +09:00
parent b09723594c
commit 7a0ecf6b81
13 changed files with 288 additions and 16 deletions

View file

@ -257,8 +257,8 @@ export class AppServerClient {
return this.request("thread/unarchive", { threadId });
}
rollbackThread(threadId: string): Promise<ThreadRollbackResponse> {
return this.request("thread/rollback", { threadId, numTurns: 1 });
rollbackThread(threadId: string, numTurns = 1): Promise<ThreadRollbackResponse> {
return this.request("thread/rollback", { threadId, numTurns });
}
setThreadName(threadId: string, name: string): Promise<ThreadSetNameResponse> {

View file

@ -8,6 +8,7 @@ import type { ComposerBoundaryScrollAction } from "./composer/boundary-scroll";
import { MessageScrollController, type MessageScrollIntent } from "./ui/scroll";
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { MarkdownMessageRenderer } from "./markdown-message-renderer";
import { forkCandidatesFromItems, isForkCandidateItem } from "./fork";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
import { implementPlanCandidateFromState } from "./plan-implementation";
@ -21,6 +22,7 @@ export interface ChatMessageRendererOptions {
consumeScrollIntent: () => MessageScrollIntent;
loadOlderTurns: () => void;
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (item: DisplayItem) => void;
openTurnDiff: (state: ChatTurnDiffViewState) => void;
pendingRequestsSignature: () => string;
@ -64,6 +66,7 @@ export class ChatMessageRenderer {
const scrollPlan = this.scrollController.prepareRender(messagesEl, this.options.consumeScrollIntent());
const busy = chatTurnBusy(state);
const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.displayItems);
const forkCandidates = busy ? [] : forkCandidatesFromItems(state.displayItems);
const implementPlanCandidate = implementPlanCandidateFromState(state);
const blocks = messageStreamBlocks({
@ -96,6 +99,10 @@ export class ChatMessageRenderer {
onRollbackItem: () => {
if (state.activeThreadId) this.options.rollbackThread(state.activeThreadId);
},
canForkItem: (item: DisplayItem) => isForkCandidateItem(item, forkCandidates),
onForkItem: (item, archiveSource) => {
if (state.activeThreadId && item.turnId) this.options.forkThreadFromTurn(state.activeThreadId, item.turnId, archiveSource);
},
openTurnDiff: (turnDiffState) => {
this.options.openTurnDiff(turnDiffState);
},
@ -131,6 +138,13 @@ export class ChatMessageRenderer {
}
private setOpenDetail(key: string, open: boolean): void {
if (open && key.startsWith("message:fork-actions:")) {
for (const openKey of this.state.openDetails) {
if (openKey.startsWith("message:fork-actions:") && openKey !== key) {
this.dispatch({ type: "ui/detail-open-set", key: openKey, open: false });
}
}
}
this.dispatch({ type: "ui/detail-open-set", key, open });
}
}

View file

@ -108,6 +108,7 @@ 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 {
@ -200,6 +201,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
consumeScrollIntent: () => host.messageScroll.consumeIntent(),
loadOlderTurns: () => void history.loadOlder(),
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (item) => void planImplementation.implement(item),
openTurnDiff: (state) => void host.plugin.openTurnDiff(state),
pendingRequestsSignature: host.pendingRequestsSignature,
@ -397,6 +399,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
refreshSharedThreadListFromOpenSurface: () => {
host.plugin.refreshSharedThreadListFromOpenSurface();
},
closePanel: host.closePanel,
});
toolbarPanels = new ToolbarPanelController({
stateStore: host.stateStore,

View file

@ -1,4 +1,5 @@
import type { DisplayBlock, DisplayItem, DisplayKind } from "./types";
import { isFinalAssistantMessage } from "./final-assistant";
import { pathRelativeToRoot } from "./paths";
import { executionState } from "./state";
@ -73,10 +74,6 @@ function finalAssistantItemsByTurn(items: readonly DisplayItem[]): Map<string, s
return finalAssistantIdByTurn;
}
function isFinalAssistantMessage(item: DisplayItem): boolean {
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
}
function itemWithTurnSummaries(
item: DisplayItem,
editedFilesByTurn: Map<string, string[]>,

View file

@ -0,0 +1,5 @@
import type { DisplayItem } from "./types";
export function isFinalAssistantMessage(item: DisplayItem): boolean {
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
}

37
src/features/chat/fork.ts Normal file
View file

@ -0,0 +1,37 @@
import type { DisplayItem } from "./display/types";
import { isFinalAssistantMessage } from "./display/final-assistant";
export interface ForkCandidate {
itemId: string;
turnId: string;
}
export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly ForkCandidate[] {
const finalAssistantItemsByTurn = new Map<string, ForkCandidate>();
for (const item of items) {
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
finalAssistantItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId });
}
return [...finalAssistantItemsByTurn.values()];
}
export function isForkCandidateItem(item: DisplayItem, candidates: readonly ForkCandidate[]): boolean {
return candidates.some((candidate) => item.id === candidate.itemId && item.turnId === candidate.turnId);
}
export function turnsAfterTurnId(items: readonly DisplayItem[], turnId: string): number | null {
const turnIds = orderedTurnIds(items);
const index = turnIds.indexOf(turnId);
return index === -1 ? null : turnIds.length - index - 1;
}
function orderedTurnIds(items: readonly DisplayItem[]): string[] {
const turnIds: string[] = [];
const seen = new Set<string>();
for (const item of items) {
if (!item.turnId || seen.has(item.turnId)) continue;
seen.add(item.turnId);
turnIds.push(item.turnId);
}
return turnIds;
}

View file

@ -7,6 +7,7 @@ import type { CodexPanelSettings } from "../../settings/model";
import type { ArchiveExportAdapter } from "../../domain/threads/export";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
import type { ThreadHistoryLoader } from "./thread-history";
import { turnsAfterTurnId } from "./fork";
import { rollbackCandidateFromItems } from "./rollback";
export interface ChatThreadActionControllerHost {
@ -26,6 +27,7 @@ export interface ChatThreadActionControllerHost {
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
closePanel: () => void;
}
export class ChatThreadActionController {
@ -40,12 +42,16 @@ export class ChatThreadActionController {
}
async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<void> {
await this.archiveThreadWithResult(threadId, saveMarkdown);
}
private async archiveThreadWithResult(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<boolean> {
if (chatTurnBusy(this.state)) {
this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return;
return false;
}
const client = this.host.currentClient();
if (!client) return;
if (!client) return false;
try {
const settings = this.host.settings();
if (saveMarkdown) {
@ -59,12 +65,18 @@ export class ChatThreadActionController {
}
await client.archiveThread(threadId);
this.host.notifyThreadArchived(threadId);
return true;
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
async forkThread(threadId: string): Promise<void> {
await this.forkThreadFromTurn(threadId, null, false);
}
async forkThreadFromTurn(threadId: string, turnId: string | null, archiveSource: boolean): Promise<void> {
if (chatTurnBusy(this.state)) {
this.host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
@ -73,10 +85,19 @@ export class ChatThreadActionController {
const client = this.host.currentClient();
if (!client) return;
const turnsToDrop = turnId ? turnsAfterTurnId(this.state.displayItems, turnId) : 0;
if (turnsToDrop === null) {
this.host.addSystemMessage("Could not find the selected turn to fork.");
return;
}
try {
const sourceName = inheritedForkThreadName(threadId, this.state.listedThreads);
const response = await client.forkThread(threadId, this.host.vaultPath);
const forkedThreadId = response.thread.id;
if (turnsToDrop > 0) {
await client.rollbackThread(forkedThreadId, turnsToDrop);
}
if (sourceName) {
try {
await client.setThreadName(forkedThreadId, sourceName);
@ -86,12 +107,19 @@ export class ChatThreadActionController {
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
}
}
let openedForkPanel = false;
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;
const archived = await this.archiveThreadWithResult(threadId);
if (archived) this.host.closePanel();
}
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}

View file

@ -1,4 +1,4 @@
import { Fragment, forwardRef, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Fragment, forwardRef, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { displayBlocksForItems } from "../display/blocks";
import { executionState } from "../display/state";
@ -37,6 +37,8 @@ export interface MessageStreamContext {
onImplementPlanItem?: (item: DisplayItem) => void;
canRollbackItem?: (item: DisplayItem) => boolean;
onRollbackItem?: (item: DisplayItem) => void;
canForkItem?: (item: DisplayItem) => boolean;
onForkItem?: (item: DisplayItem, archiveSource: boolean) => void;
openTurnDiff?: (state: ChatTurnDiffViewState) => void;
pendingRequestsSignature?: string;
renderPendingRequests?: () => ReactNode;
@ -221,17 +223,50 @@ function MessageItem({ item, context }: { item: RenderableMessageItem; context:
}
function MessageRole({ item, context }: { item: RenderableMessageItem; context: MessageStreamContext }): ReactNode {
const forkActionsKey = `message:fork-actions:${item.id}`;
const forkActionsOpen = context.openDetails.has(forkActionsKey);
const roleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!forkActionsOpen) return;
const doc = roleRef.current?.ownerDocument;
if (!doc) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (event.target instanceof Node && roleRef.current?.contains(event.target)) return;
context.onDetailsToggle?.(forkActionsKey, false);
};
doc.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
doc.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [context, forkActionsKey, forkActionsOpen]);
const copyAction =
item.kind === "message" && context.copyText && isMessageCopyActionVisible(item, context) && !forkActionsOpen ? (
<MessageAction
icon="copy"
label="Copy message"
className="codex-panel__copy-message"
onClick={() => context.copyText?.(item.copyText ?? item.text)}
/>
) : null;
return (
<div className="codex-panel__message-role">
<div ref={roleRef} className={`codex-panel__message-role${forkActionsOpen ? " codex-panel__message-role--fork-open" : ""}`}>
<span>{displayRoleLabel(item)}</span>
{item.kind === "message" && context.copyText && isMessageCopyActionVisible(item, context) ? (
{forkActionsOpen && context.canForkItem?.(item) ? (
<MessageAction
icon="copy"
label="Copy message"
className="codex-panel__copy-message"
onClick={() => context.copyText?.(item.copyText ?? item.text)}
icon="archive"
label="Fork and archive"
className="codex-panel__fork-and-archive-message"
onClick={() => {
context.onDetailsToggle?.(forkActionsKey, false);
context.onForkItem?.(item, true);
}}
/>
) : null}
) : (
copyAction
)}
{context.canImplementPlanItem?.(item) ? (
<MessageAction
icon="play"
@ -248,6 +283,21 @@ function MessageRole({ item, context }: { item: RenderableMessageItem; context:
onClick={() => context.onRollbackItem?.(item)}
/>
) : null}
{context.canForkItem?.(item) ? (
<MessageAction
icon="git-fork"
label={forkActionsOpen ? "Fork" : "Fork from here"}
className="codex-panel__fork-message"
onClick={() => {
if (forkActionsOpen) {
context.onDetailsToggle?.(forkActionsKey, false);
context.onForkItem?.(item, false);
} else {
context.onDetailsToggle?.(forkActionsKey, true);
}
}}
/>
) : null}
</div>
);
}

View file

@ -161,6 +161,9 @@ 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

@ -1393,6 +1393,7 @@
}
.codex-panel__message:hover .codex-panel__message-action,
.codex-panel__message-role--fork-open .codex-panel__message-action,
.codex-panel__message-action:focus-visible {
opacity: 1;
}

View file

@ -243,6 +243,7 @@ function chatMessageRenderer(
consumeScrollIntent: () => "auto",
loadOlderTurns: vi.fn(),
rollbackThread: vi.fn(),
forkThreadFromTurn: vi.fn(),
implementPlan: vi.fn(),
openTurnDiff: vi.fn(),
pendingRequestsSignature: () => "",

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { forkCandidatesFromItems, isForkCandidateItem, turnsAfterTurnId } from "../../../src/features/chat/fork";
import type { DisplayItem } from "../../../src/features/chat/display/types";
describe("fork candidates", () => {
it("selects final assistant messages and counts later turns", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "first", turnId: "turn-1", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "first answer", turnId: "turn-1", markdown: true },
{ id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" },
{ id: "a2-delta", kind: "message", role: "assistant", text: "draft", turnId: "turn-2", markdown: false },
{ id: "a2", kind: "message", role: "assistant", text: "second answer", turnId: "turn-2", markdown: true },
{ id: "u3", kind: "message", role: "user", text: "third", turnId: "turn-3", markdown: true },
{ id: "a3", kind: "message", role: "assistant", text: "third answer", turnId: "turn-3", markdown: true },
];
const candidates = forkCandidatesFromItems(items);
expect(candidates).toEqual([
{ itemId: "a1", turnId: "turn-1" },
{ itemId: "a2", turnId: "turn-2" },
{ itemId: "a3", turnId: "turn-3" },
]);
expect(isForkCandidateItem(expectPresent(items[4]), candidates)).toBe(true);
expect(isForkCandidateItem(expectPresent(items[3]), candidates)).toBe(false);
expect(turnsAfterTurnId(items, "turn-1")).toBe(2);
expect(turnsAfterTurnId(items, "turn-2")).toBe(1);
expect(turnsAfterTurnId(items, "turn-3")).toBe(0);
expect(turnsAfterTurnId(items, "missing")).toBeNull();
});
});
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}

View file

@ -518,6 +518,102 @@ describe("message stream block identity and message actions", () => {
expect(copyText).toHaveBeenNthCalledWith(2, "# Answer");
});
it("expands assistant fork actions in the copy slot and defaults repeat clicks to plain fork", () => {
const onDetailsToggle = vi.fn();
const onForkItem = vi.fn();
const item: DisplayItem = {
id: "a1",
kind: "message",
role: "assistant",
text: "answer",
copyText: "answer",
turnId: "turn-1",
markdown: true,
};
const closedBlock = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [item],
openDetails: new Set(),
onDetailsToggle,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canForkItem: () => true,
onForkItem,
})[0];
const closedElement = renderMessageBlockElement(closedBlock);
expect(closedElement.querySelector(".codex-panel__copy-message")).not.toBeNull();
const initialFork = expectPresent(closedElement.querySelector<HTMLButtonElement>(".codex-panel__fork-message"));
expect(initialFork.getAttribute("aria-label")).toBe("Fork from here");
initialFork.click();
expect(onDetailsToggle).toHaveBeenCalledWith("message:fork-actions:a1", true);
const openBlock = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [item],
openDetails: new Set(["message:fork-actions:a1"]),
onDetailsToggle,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canForkItem: () => true,
onForkItem,
})[0];
const openElement = renderMessageBlockElement(openBlock);
expect(openElement.querySelector(".codex-panel__copy-message")).toBeNull();
expect(openElement.querySelector<HTMLButtonElement>(".codex-panel__fork-and-archive-message")?.getAttribute("aria-label")).toBe(
"Fork and archive",
);
const openFork = expectPresent(openElement.querySelector<HTMLButtonElement>(".codex-panel__fork-message"));
expect(openFork.getAttribute("aria-label")).toBe("Fork");
openFork.click();
expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), false);
});
it("runs fork and archive from the expanded assistant fork actions", () => {
const onForkItem = vi.fn();
const item: DisplayItem = {
id: "a1",
kind: "message",
role: "assistant",
text: "answer",
copyText: "answer",
turnId: "turn-1",
markdown: true,
};
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [item],
openDetails: new Set(["message:fork-actions:a1"]),
onDetailsToggle: vi.fn(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canForkItem: () => true,
onForkItem,
})[0];
const element = renderMessageBlockElement(block);
expectPresent(element.querySelector<HTMLButtonElement>(".codex-panel__fork-and-archive-message")).click();
expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), true);
});
it("keeps message React actions mounted in the message stream host", () => {
const parent = document.createElement("div");
const copyText = vi.fn();