From 3434614f72b4205d861496e0ea44f4b9bed02104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 25 Jun 2026 01:25:49 +0800 Subject: [PATCH 1/7] feat(web): add history fork for conversations Let users branch a conversation from any prior user message into a new session that carries the history up to the fork point and pre-fills the forked prompt into the composer for editing and re-sending. - gateway: new fork_session RPC wired through protocol types, in-process and remote gateway clients, ws dispatch, and local gateway impl - server: forkSession truncates the source transcript at the fork turn, writes a new session jsonl with fork lineage metadata, copies aux dirs, and returns the prefill text + carried message count - session: persist parentSessionId/forkedFromTurnId so the sidebar can render the fork lineage tree - ui: hover Fork button on user messages, auto-navigate to the new session, prefill + focus the composer, scroll to bottom, distinctive fork titles, and a friendly empty state for branches with no carried history - tests: forkSession unit test + history-fork e2e spec Co-authored-by: Cursor --- scripts/tui-e2e-permission.tsx | 1 + src/cli/createLocalGateway.ts | 7 + src/cli/pilotdeck.ts | 3 + src/gateway/client/InProcessGateway.ts | 12 + src/gateway/client/RemoteGateway.ts | 6 + src/gateway/protocol/frames.ts | 1 + src/gateway/protocol/types.ts | 8 + src/gateway/server/GatewayWsConnection.ts | 2 + src/session/storage/SessionList.ts | 6 + src/session/transcript/TranscriptEntry.ts | 4 + src/web/client/protocol.ts | 16 ++ src/web/client/webMessage.ts | 2 + src/web/server/forkSession.ts | 217 +++++++++++++++ src/web/server/readSessionMessages.ts | 13 +- tests/web/forkSession.test.ts | 87 ++++++ ui/e2e/history-fork.spec.mjs | 32 +++ ui/server/projects.js | 2 + ui/server/routes/messages.js | 37 +++ ui/src/components/app-shell/SidebarV2.tsx | 255 +++++++++++------- ui/src/components/chat-v2/ChatInterfaceV2.tsx | 74 +++++ ui/src/components/chat-v2/MessageRowV2.tsx | 57 +++- ui/src/components/chat-v2/MessagesPaneV2.tsx | 62 ++++- .../components/chat/hooks/useChatMessages.ts | 1 + ui/src/components/chat/types/types.ts | 1 + ui/src/stores/useSessionStore.ts | 2 + ui/src/utils/api.js | 5 + 26 files changed, 813 insertions(+), 100 deletions(-) create mode 100644 src/web/server/forkSession.ts create mode 100644 tests/web/forkSession.test.ts create mode 100644 ui/e2e/history-fork.spec.mjs diff --git a/scripts/tui-e2e-permission.tsx b/scripts/tui-e2e-permission.tsx index cccdf0e2..ac81e0f0 100644 --- a/scripts/tui-e2e-permission.tsx +++ b/scripts/tui-e2e-permission.tsx @@ -98,6 +98,7 @@ class MockGateway implements Gateway { grantSessionPermission = stub({ granted: false }) as Gateway["grantSessionPermission"]; readSessionMessages = stub({ messages: [], hasMore: false, session: {} as any }) as unknown as Gateway["readSessionMessages"]; readSubagentMessages = stub({ messages: [], total: 0 }) as unknown as Gateway["readSubagentMessages"]; + forkSession = stub({ newSessionKey: "web:s_fork", prefillText: "", carriedMessageCount: 0 }) as unknown as Gateway["forkSession"]; listProjects = stub({ projects: [] }) as Gateway["listProjects"]; describeProject = stub({ projectKey: "", name: "", root: "", fullPath: "", sessionCount: 0 }) as unknown as Gateway["describeProject"]; } diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index af2799d5..42a64344 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -64,6 +64,7 @@ import { DEFAULT_JUDGE_TIMEOUT_MS, DEFAULT_SUBAGENT_MAX_TOKENS, DEFAULT_ALLOWED_ import { createAgentProjectSessionStorage, listProjectSessions, resumeAgentSession } from "../session/index.js"; import { sanitizeSessionIdForPath } from "../session/storage/ProjectSessionStorage.js"; import { readWebSessionMessages, readSubagentWebMessages } from "../web/server/readSessionMessages.js"; +import { forkWebSession } from "../web/server/forkSession.js"; import { describeWebProject, listWebProjects } from "../web/server/listProjects.js"; import { BackgroundTaskRuntime } from "../task/runtime/BackgroundTaskRuntime.js"; import { createBuiltinRegistry, createPlanFileManager } from "../tool/index.js"; @@ -269,6 +270,12 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre pilotHome, now, }), + forkSession: (input) => + forkWebSession(input, { + projectRoot: input.projectKey ? input.projectKey : projectRoot, + pilotHome, + now, + }), listProjects: () => listWebProjects({ pilotHome, defaultProjectRoot: options.skipDefaultProject ? undefined : projectRoot }), describeProject: (input) => diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index 8c6d4226..52c506ca 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -680,6 +680,9 @@ function createFallbackGateway(): Gateway { readSubagentMessages: async () => { throw new Error("read_subagent_messages is not configured."); }, + forkSession: async () => { + throw new Error("fork_session is not configured."); + }, listProjects: async () => ({ projects: [] }), describeProject: async (input) => ({ projectKey: input.projectKey, diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index 6bbdc967..d084e80a 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -40,6 +40,8 @@ import type { WebReadSessionMessagesResult, WebReadSubagentMessagesInput, WebReadSubagentMessagesResult, + WebForkSessionInput, + WebForkSessionResult, } from "../protocol/types.js"; import type { CronCreateInput, @@ -92,6 +94,7 @@ export type InProcessGatewayOptions = { */ readSessionMessages?: (input: WebReadSessionMessagesInput) => Promise; readSubagentMessages?: (input: WebReadSubagentMessagesInput) => Promise; + forkSession?: (input: WebForkSessionInput) => Promise; /** * Web Phase 3 — pluggable project enumerator + describer. */ @@ -616,6 +619,15 @@ export class InProcessGateway implements Gateway { return this.options.readSubagentMessages(input); } + async forkSession(input: WebForkSessionInput): Promise { + if (!this.options.forkSession) { + throw new Error( + "fork_session is not configured. Wire `forkSession` via createLocalGateway.", + ); + } + return this.options.forkSession(input); + } + async listProjects(): Promise { if (!this.options.listProjects) { throw new Error("list_projects is not configured."); diff --git a/src/gateway/client/RemoteGateway.ts b/src/gateway/client/RemoteGateway.ts index 9e423196..a88e6e88 100644 --- a/src/gateway/client/RemoteGateway.ts +++ b/src/gateway/client/RemoteGateway.ts @@ -22,6 +22,8 @@ import type { WebReadSessionMessagesResult, WebReadSubagentMessagesInput, WebReadSubagentMessagesResult, + WebForkSessionInput, + WebForkSessionResult, } from "../protocol/types.js"; import type { SkillAddressInput, @@ -134,6 +136,10 @@ export class RemoteGateway implements Gateway { return (await this.client.request("read_subagent_messages", input)) as WebReadSubagentMessagesResult; } + async forkSession(input: WebForkSessionInput): Promise { + return (await this.client.request("fork_session", input)) as WebForkSessionResult; + } + async listProjects(): Promise { return (await this.client.request("list_projects", {})) as WebListProjectsResult; } diff --git a/src/gateway/protocol/frames.ts b/src/gateway/protocol/frames.ts index e5785c73..8cc5d693 100644 --- a/src/gateway/protocol/frames.ts +++ b/src/gateway/protocol/frames.ts @@ -36,6 +36,7 @@ export type WsGatewayMethod = | "grant_session_permission" | "read_session_messages" | "read_subagent_messages" + | "fork_session" | "list_projects" | "describe_project" | "reload_config" diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index 4fb4e366..37a06486 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -25,6 +25,8 @@ import type { WebReadSessionMessagesResult as WebUiReadSessionMessagesResult, WebReadSubagentMessagesInput as WebUiReadSubagentMessagesInput, WebReadSubagentMessagesResult as WebUiReadSubagentMessagesResult, + WebForkSessionInput as WebUiForkSessionInput, + WebForkSessionResult as WebUiForkSessionResult, } from "../../web/client/protocol.js"; import type { SkillCreateInput, @@ -222,6 +224,8 @@ export type WebReadSessionMessagesInput = WebUiReadSessionMessagesInput; export type WebReadSessionMessagesResult = WebUiReadSessionMessagesResult; export type WebReadSubagentMessagesInput = WebUiReadSubagentMessagesInput; export type WebReadSubagentMessagesResult = WebUiReadSubagentMessagesResult; +export type WebForkSessionInput = WebUiForkSessionInput; +export type WebForkSessionResult = WebUiForkSessionResult; export type WebProjectSummary = WebUiProjectSummary; export type WebListProjectsResult = WebUiListProjectsResult; export type WebDescribeProjectInput = { projectKey: string }; @@ -343,6 +347,10 @@ export interface Gateway { * the Web `WebMessage` DTO. */ readSessionMessages(input: WebReadSessionMessagesInput): Promise; + /** + * Fork a session transcript at a prior user turn into a new session file. + */ + forkSession(input: WebForkSessionInput): Promise; /** * Read a subagent's sidechain transcript and return its messages in WebMessage format. */ diff --git a/src/gateway/server/GatewayWsConnection.ts b/src/gateway/server/GatewayWsConnection.ts index a4932a22..1ac32d49 100644 --- a/src/gateway/server/GatewayWsConnection.ts +++ b/src/gateway/server/GatewayWsConnection.ts @@ -210,6 +210,8 @@ export class GatewayWsConnection { return this.options.gateway.readSessionMessages(frame.params as never); case "read_subagent_messages": return this.options.gateway.readSubagentMessages(frame.params as never); + case "fork_session": + return this.options.gateway.forkSession(frame.params as never); case "list_projects": return this.options.gateway.listProjects(); case "describe_project": diff --git a/src/session/storage/SessionList.ts b/src/session/storage/SessionList.ts index 331bd87c..1cd93589 100644 --- a/src/session/storage/SessionList.ts +++ b/src/session/storage/SessionList.ts @@ -20,6 +20,8 @@ export type SessionInfo = { cwd?: string; tag?: string; createdAt?: number; + parentSessionId?: string; + forkedFromTurnId?: string; }; export type ListProjectSessionsOptions = { @@ -73,6 +75,8 @@ export function parseSessionInfoFromLite( const customTitle = lastMetadataStringField(source, "title"); const aiTitle = lastMetadataStringField(source, "aiTitle"); const tag = lastMetadataStringField(source, "tag"); + const parentSessionId = lastMetadataStringField(source, "parentSessionId"); + const forkedFromTurnId = lastMetadataStringField(source, "forkedFromTurnId"); const firstPrompt = firstAcceptedInputText(lite.head); const lastPrompt = lastAcceptedInputText(lite.tail) ?? firstPrompt; const summary = customTitle ?? aiTitle ?? lastPrompt; @@ -92,6 +96,8 @@ export function parseSessionInfoFromLite( cwd: projectRoot, tag, createdAt: firstCreatedAt ? Date.parse(firstCreatedAt) : undefined, + parentSessionId, + forkedFromTurnId, }; } diff --git a/src/session/transcript/TranscriptEntry.ts b/src/session/transcript/TranscriptEntry.ts index 76e34b70..67590126 100644 --- a/src/session/transcript/TranscriptEntry.ts +++ b/src/session/transcript/TranscriptEntry.ts @@ -97,6 +97,10 @@ export type SessionMetadataValue = { url: string; repository: string; }; + /** Parent session when this transcript was created via history fork. */ + parentSessionId?: string; + /** Turn id of the fork point in the parent session. */ + forkedFromTurnId?: string; updatedAt?: string; }; diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index 2bb7a50b..be09c13d 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -108,6 +108,7 @@ export type WebGatewayMethod = | "permission_decide" | "grant_session_permission" | "read_session_messages" + | "fork_session" | "rename_session" | "delete_session" | "list_projects" @@ -159,6 +160,8 @@ export type WebSessionInfo = { cwd?: string; tag?: string; createdAt?: number; + parentSessionId?: string; + forkedFromTurnId?: string; }; export type WebListSessionsInput = { @@ -251,6 +254,19 @@ export type WebReadSubagentMessagesResult = { total: number; }; +export type WebForkSessionInput = { + sessionKey: string; + projectKey?: string; + /** Transcript entry id of the user turn to fork from (accepted_input entryId). */ + fromEntryId: string; +}; + +export type WebForkSessionResult = { + newSessionKey: string; + prefillText: string; + carriedMessageCount: number; +}; + export type WebActiveTurnSnapshotInput = { sessionKey: string; }; diff --git a/src/web/client/webMessage.ts b/src/web/client/webMessage.ts index 37de31e0..1eebfe22 100644 --- a/src/web/client/webMessage.ts +++ b/src/web/client/webMessage.ts @@ -89,6 +89,8 @@ export type WebMessage = { source: "live" | "history"; finishReason?: string; usage?: Record; + /** Transcript entry id when projected from history (used for history fork). */ + entryId?: string; }; export type WebMessageReducerOptions = { diff --git a/src/web/server/forkSession.ts b/src/web/server/forkSession.ts new file mode 100644 index 00000000..0bf831a5 --- /dev/null +++ b/src/web/server/forkSession.ts @@ -0,0 +1,217 @@ +/** + * Fork a web session transcript at a prior user turn. + * + * Creates a new session file containing all entries strictly before the + * fork turn, copies auxiliary session dirs, and returns the forked user + * message text for composer prefill. + */ + +import { randomUUID } from "node:crypto"; +import { cp, mkdir, writeFile, chmod } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { platform } from "node:process"; +import type { CanonicalContentBlock } from "../../model/index.js"; +import { getPilotProjectChatDir } from "../../pilot/index.js"; +import { readTranscript } from "../../session/transcript/TranscriptReader.js"; +import { + sanitizeSessionIdForPath, +} from "../../session/storage/ProjectSessionStorage.js"; +import type { + AgentAcceptedInputTranscriptEntry, + AgentSessionMetadataTranscriptEntry, + AgentTranscriptEntry, +} from "../../session/transcript/TranscriptEntry.js"; +import type { WebForkSessionInput, WebForkSessionResult } from "../client/protocol.js"; + +export type ForkWebSessionOptions = { + projectRoot: string; + pilotHome: string; + now?: () => Date; +}; + +function newWebSessionKey(): string { + const sep = platform === "win32" ? "-" : ":"; + return `web${sep}s_${randomUUID()}`; +} + +function extractAcceptedInputText(entry: AgentAcceptedInputTranscriptEntry): string { + for (const message of entry.messages) { + for (const block of message.content as CanonicalContentBlock[]) { + if (block.type === "text" && block.text.trim()) { + return block.text.trim(); + } + } + } + return ""; +} + +function buildForkTitle( + prefillText: string, + carriedMessageCount: number, + inheritedTitle: string | undefined, +): string { + const normalized = prefillText.replace(/\s+/g, " ").trim(); + if (normalized) { + const max = 48; + const snippet = normalized.length > max ? `${normalized.slice(0, max).trimEnd()}…` : normalized; + // A leading branch glyph keeps forks scannable even when titles collide. + return `⑂ ${snippet}`; + } + if (inheritedTitle) { + return `⑂ ${inheritedTitle}`; + } + return carriedMessageCount > 0 ? "⑂ Forked session" : "⑂ New branch"; +} + +function findForkTurnAcceptedInput( + entries: AgentTranscriptEntry[], + fromEntryId: string, +): AgentAcceptedInputTranscriptEntry { + const target = entries.find((entry) => entry.entryId === fromEntryId); + if (!target) { + throw new ForkSessionError("fork_entry_not_found", `Transcript entry not found: ${fromEntryId}`); + } + + if (target.type === "accepted_input") { + return target; + } + + const accepted = entries.find( + (entry): entry is AgentAcceptedInputTranscriptEntry => + entry.type === "accepted_input" && entry.turnId === target.turnId, + ); + if (!accepted) { + throw new ForkSessionError( + "fork_turn_not_found", + `No accepted_input found for turn ${target.turnId}`, + ); + } + return accepted; +} + +function lastSessionMetadata(entries: AgentTranscriptEntry[]): Record | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.type === "session_metadata") { + return entry.metadata as Record; + } + } + return undefined; +} + +function countCarriedUserAssistantMessages(entries: AgentTranscriptEntry[]): number { + let count = 0; + for (const entry of entries) { + switch (entry.type) { + case "accepted_input": + count += entry.messages.length; + break; + case "assistant_message": + case "tool_result_message": + case "durable_message": + count += 1; + break; + default: + break; + } + } + return count; +} + +async function copySessionAuxDirs(sourceSessionDir: string, targetSessionDir: string): Promise { + for (const subdir of ["tool-results", "file-history"] as const) { + const source = join(sourceSessionDir, subdir); + const target = join(targetSessionDir, subdir); + try { + await cp(source, target, { recursive: true, force: true }); + } catch { + // Missing auxiliary dirs are normal for early sessions. + } + } +} + +export class ForkSessionError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ForkSessionError"; + } +} + +export async function forkWebSession( + input: WebForkSessionInput, + options: ForkWebSessionOptions, +): Promise { + const effectiveProjectRoot = input.projectKey ?? options.projectRoot; + const chatDir = getPilotProjectChatDir(effectiveProjectRoot, options.pilotHome); + const sourceSafeId = sanitizeSessionIdForPath(input.sessionKey); + const sourceTranscriptPath = resolve(chatDir, `${sourceSafeId}.jsonl`); + const sourceSessionDir = resolve(chatDir, sourceSafeId); + + const { entries } = await readTranscript(sourceTranscriptPath); + if (entries.length === 0) { + throw new ForkSessionError("fork_empty_transcript", "Cannot fork an empty session transcript."); + } + + const forkAcceptedInput = findForkTurnAcceptedInput(entries, input.fromEntryId); + const cutoffSequence = forkAcceptedInput.sequence; + const preserved = entries.filter((entry) => entry.sequence < cutoffSequence); + const prefillText = extractAcceptedInputText(forkAcceptedInput); + const carriedMessageCount = countCarriedUserAssistantMessages(preserved); + + const newSessionKey = newWebSessionKey(); + const newSafeId = sanitizeSessionIdForPath(newSessionKey); + const newTranscriptPath = resolve(chatDir, `${newSafeId}.jsonl`); + const newSessionDir = resolve(chatDir, newSafeId); + + await mkdir(chatDir, { recursive: true, mode: 0o700 }); + await mkdir(newSessionDir, { recursive: true, mode: 0o700 }); + + const preservedLines = preserved.map((entry) => `${JSON.stringify(entry)}\n`).join(""); + const lastPreserved = preserved[preserved.length - 1]; + const lastEntryId = lastPreserved?.entryId ?? null; + const maxSequence = preserved.reduce((max, entry) => Math.max(max, entry.sequence), 0); + + const parentMetadata = lastSessionMetadata(entries); + const inheritedTitle = + (typeof parentMetadata?.title === "string" && parentMetadata.title) || + (typeof parentMetadata?.aiTitle === "string" && parentMetadata.aiTitle) || + undefined; + + // Title the fork by the message it branches from so siblings are + // distinguishable in the lineage tree (the branch icon + "forked from" + // subtitle already convey that it is a fork). + const forkTitle = buildForkTitle(prefillText, carriedMessageCount, inheritedTitle); + + const now = options.now ?? (() => new Date()); + const metadataEntry: AgentSessionMetadataTranscriptEntry = { + type: "session_metadata", + sessionId: newSessionKey, + turnId: `fork-${randomUUID()}`, + sequence: maxSequence + 1, + createdAt: now().toISOString(), + entryId: randomUUID(), + parentEntryId: lastEntryId, + metadata: { + parentSessionId: input.sessionKey, + forkedFromTurnId: forkAcceptedInput.turnId, + title: forkTitle, + firstPrompt: prefillText || undefined, + updatedAt: now().toISOString(), + }, + }; + + const body = preservedLines + `${JSON.stringify(metadataEntry)}\n`; + await writeFile(newTranscriptPath, body, { encoding: "utf8", mode: 0o600 }); + await chmod(dirname(newTranscriptPath), 0o700); + + await copySessionAuxDirs(sourceSessionDir, newSessionDir); + + return { + newSessionKey, + prefillText, + carriedMessageCount, + }; +} diff --git a/src/web/server/readSessionMessages.ts b/src/web/server/readSessionMessages.ts index ad35e1a6..4bb61591 100644 --- a/src/web/server/readSessionMessages.ts +++ b/src/web/server/readSessionMessages.ts @@ -56,6 +56,7 @@ export async function readWebSessionMessages( const { entries } = await readTranscript(transcriptPath); const webReplay = extractWebVisibleMessages(entries); const entryTimestamps = webReplay.timestamps; + const entryIds = webReplay.entryIds; const incompleteTurnIds = extractIncompleteTurnIds(entries); const flattenedPerMessage: WebMessage[][] = webReplay.messages @@ -67,6 +68,7 @@ export async function readWebSessionMessages( projectKey: input.projectKey, now: options.now, entryTimestamp: entryTimestamps[index], + entryId: entryIds[index], }), ); @@ -130,6 +132,8 @@ export async function readWebSessionMessages( cwd: sessionInfo?.cwd, tag: sessionInfo?.tag, createdAt: sessionInfo?.createdAt, + parentSessionId: sessionInfo?.parentSessionId, + forkedFromTurnId: sessionInfo?.forkedFromTurnId, }, }; } @@ -279,6 +283,8 @@ type ProjectionContext = { now?: () => Date; /** Actual transcript entry timestamp — preferred over now(). */ entryTimestamp?: string; + /** Transcript entry id for fork targeting. */ + entryId?: string; }; /** @@ -317,6 +323,7 @@ export function flattenCanonicalMessage( kind: "text", text: textBuffer, ...(pendingImages.length > 0 ? { images: pendingImages } : {}), + ...(context.entryId ? { entryId: context.entryId } : {}), source: "history", }); textBuffer = ""; @@ -532,11 +539,13 @@ type CompactBoundaryInfo = { function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { messages: CanonicalMessage[]; timestamps: string[]; + entryIds: Array; compactBoundaries: CompactBoundaryInfo[]; } { const lastBoundaryIndex = findLastCompactBoundaryIndex(entries); const messages: CanonicalMessage[] = []; const timestamps: string[] = []; + const entryIds: Array = []; const compactBoundaries: CompactBoundaryInfo[] = []; for (let index = 0; index < entries.length; index += 1) { @@ -549,6 +558,7 @@ function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { for (const message of entry.messages) { messages.push(cloneMessage(message)); timestamps.push(entry.createdAt); + entryIds.push(entry.entryId); } } break; @@ -558,6 +568,7 @@ function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { if (!beforeBoundary) { messages.push(cloneMessage(entry.message)); timestamps.push(entry.createdAt); + entryIds.push(entry.entryId); } break; case "control_boundary": { @@ -582,7 +593,7 @@ function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { } } - return { messages, timestamps, compactBoundaries }; + return { messages, timestamps, entryIds, compactBoundaries }; } function extractSubagentExecutionMessages(entries: AgentTranscriptEntry[]): { diff --git a/tests/web/forkSession.test.ts b/tests/web/forkSession.test.ts new file mode 100644 index 00000000..1cf59025 --- /dev/null +++ b/tests/web/forkSession.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { forkWebSession } from "../../src/web/server/forkSession.js"; +import { readTranscript } from "../../src/session/transcript/TranscriptReader.js"; +import { createProjectId } from "../../src/pilot/paths.js"; + +test("forkWebSession copies history before target turn and returns prefill text", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_parent"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await import("node:fs/promises").then((fs) => fs.mkdir(chatDir, { recursive: true })); + + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "first question" }] }], + }, + { + type: "assistant_message", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "entry-2", + parentEntryId: "entry-1", + message: { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.000Z", + entryId: "entry-3", + parentEntryId: "entry-2", + result: { stopReason: "completed", usage: {} }, + }, + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 4, + createdAt: "2026-06-24T00:01:00.000Z", + entryId: "entry-4", + parentEntryId: "entry-3", + messages: [{ role: "user", content: [{ type: "text", text: "second question" }] }], + }, + ]; + + await import("node:fs/promises").then((fs) => + fs.writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"), + ); + + const result = await forkWebSession( + { sessionKey, projectKey: projectRoot, fromEntryId: "entry-4" }, + { projectRoot, pilotHome, now: () => new Date("2026-06-24T01:00:00.000Z") }, + ); + + assert.equal(result.prefillText, "second question"); + assert.equal(result.carriedMessageCount, 2); + assert.match(result.newSessionKey, /^web[:_]s_/); + + const newTranscriptPath = join(chatDir, `${result.newSessionKey}.jsonl`); + const { entries } = await readTranscript(newTranscriptPath); + assert.equal(entries.length, 4); + assert.equal(entries[0].sequence, 1); + assert.equal(entries[2].type, "turn_result"); + assert.equal(entries[3].type, "session_metadata"); + if (entries[3].type === "session_metadata") { + assert.equal(entries[3].metadata.parentSessionId, sessionKey); + assert.equal(entries[3].metadata.forkedFromTurnId, "turn-2"); + } + + await rm(pilotHome, { recursive: true, force: true }); +}); diff --git a/ui/e2e/history-fork.spec.mjs b/ui/e2e/history-fork.spec.mjs new file mode 100644 index 00000000..cdb3f4d5 --- /dev/null +++ b/ui/e2e/history-fork.spec.mjs @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +const API_URL = process.env.PILOTDECK_API_URL || 'http://localhost:3001'; +const PROJECT_PATH = process.env.PILOTDECK_E2E_PROJECT_PATH || '/Users/da/ws/PilotDeck/PilotDeck-20260610'; +const PARENT_SESSION = process.env.PILOTDECK_E2E_PARENT_SESSION || 'web:s_df76542f-2f58-431f-8b74-94c3a33206e2'; + +test('history fork API carries prior transcript and exposes entryId on user messages', async ({ request }) => { + const messagesResponse = await request.get( + `${API_URL}/api/sessions/${encodeURIComponent(PARENT_SESSION)}/messages?projectPath=${encodeURIComponent(PROJECT_PATH)}&limit=200`, + ); + expect(messagesResponse.ok()).toBeTruthy(); + const payload = await messagesResponse.json(); + test.skip(!payload.messages?.length, 'No live session messages available in this environment'); + + const userMessage = payload.messages.find((message) => message.role === 'user' && message.entryId); + expect(userMessage?.entryId).toBeTruthy(); + + const forkResponse = await request.post( + `${API_URL}/api/sessions/${encodeURIComponent(PARENT_SESSION)}/fork`, + { + data: { + projectPath: PROJECT_PATH, + fromEntryId: userMessage.entryId, + }, + }, + ); + expect(forkResponse.ok()).toBeTruthy(); + const forkPayload = await forkResponse.json(); + expect(forkPayload.newSessionId).toMatch(/^web[:_]s_/); + expect(typeof forkPayload.prefillText).toBe('string'); + expect(forkPayload.carriedMessageCount).toBeGreaterThan(0); +}); diff --git a/ui/server/projects.js b/ui/server/projects.js index 04eea58e..cab664e4 100755 --- a/ui/server/projects.js +++ b/ui/server/projects.js @@ -101,6 +101,8 @@ function toLegacySession(session, projectName) { aiTitle: session.aiTitle, firstPrompt: session.firstPrompt, tag: presentation.tag, + parentSessionId: session.parentSessionId, + forkedFromTurnId: session.forkedFromTurnId, __projectName: projectName, }; } diff --git a/ui/server/routes/messages.js b/ui/server/routes/messages.js index d659d948..317c291c 100644 --- a/ui/server/routes/messages.js +++ b/ui/server/routes/messages.js @@ -56,6 +56,42 @@ router.get('/:sessionId/messages', async (req, res) => { } }); +router.post('/:sessionId/fork', async (req, res) => { + try { + const { sessionId } = req.params; + const projectPath = String(req.body?.projectPath || req.body?.projectName || req.query.projectPath || REPO_ROOT); + const fromEntryId = String(req.body?.fromEntryId || ''); + if (!fromEntryId) { + return res.status(400).json({ error: 'fromEntryId is required' }); + } + + const gateway = await getPilotDeckGateway(); + const result = await gateway.forkSession({ + sessionKey: sessionId, + projectKey: projectPath, + fromEntryId, + }); + + return res.json({ + newSessionId: result.newSessionKey, + prefillText: result.prefillText, + carriedMessageCount: result.carriedMessageCount, + }); + } catch (error) { + console.error('[messages] fork_session failed:', error); + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code.startsWith('fork_')) { + return res.status(400).json({ + error: error instanceof Error ? error.message : 'Fork failed', + code, + }); + } + return res.status(500).json({ + error: error instanceof Error ? error.message : 'Fork failed', + }); + } +}); + router.get('/:sessionId/subagent/:subagentId/messages', async (req, res) => { try { const { sessionId, subagentId } = req.params; @@ -89,6 +125,7 @@ function mapWebMessageToNormalized(message, sessionId) { sessionId, timestamp: message.createdAt, provider: message.provider || 'pilotdeck', + ...(message.entryId ? { entryId: message.entryId } : {}), }; switch (message.kind) { case 'text': diff --git a/ui/src/components/app-shell/SidebarV2.tsx b/ui/src/components/app-shell/SidebarV2.tsx index 7cbe6910..063aff81 100644 --- a/ui/src/components/app-shell/SidebarV2.tsx +++ b/ui/src/components/app-shell/SidebarV2.tsx @@ -6,6 +6,7 @@ import { useState, type KeyboardEvent, type MouseEvent, + type ReactNode, } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -18,6 +19,7 @@ import { PanelLeftClose, Pencil, Plus, + GitBranch, Settings as SettingsIcon, Trash2, } from 'lucide-react'; @@ -113,6 +115,46 @@ type FlatSession = { lastActivity: number; }; +type SessionTreeNode = { + flat: FlatSession; + children: SessionTreeNode[]; +}; + +const isForkChildSession = (session: ProjectSession, knownSessionIds: Set): boolean => + Boolean( + session.parentSessionId && + session.sessionKind !== 'background_task' && + knownSessionIds.has(session.parentSessionId), + ); + +const buildSessionTree = (flatSessions: FlatSession[]): SessionTreeNode[] => { + const knownSessionIds = new Set(flatSessions.map((item) => item.sessionId)); + const childrenByParent = new Map(); + const roots: FlatSession[] = []; + + for (const item of flatSessions) { + if (isForkChildSession(item.session, knownSessionIds)) { + const parentId = item.session.parentSessionId as string; + const list = childrenByParent.get(parentId) ?? []; + list.push(item); + childrenByParent.set(parentId, list); + } else { + roots.push(item); + } + } + + const toNode = (flat: FlatSession): SessionTreeNode => ({ + flat, + children: (childrenByParent.get(flat.sessionId) ?? []) + .sort((left, right) => right.lastActivity - left.lastActivity) + .map(toNode), + }); + + return roots + .sort((left, right) => right.lastActivity - left.lastActivity) + .map(toNode); +}; + const collectSessionsForProject = (project: Project): FlatSession[] => { const sessions = Array.isArray(project.sessions) ? project.sessions : []; return sessions @@ -676,6 +718,121 @@ export default function SidebarV2({ // top-level list (no folder ancestor), so the usual ml-6 indent would // leave a weird empty gutter on the left. const containerClass = options.flat ? 'space-y-0.5' : 'ml-6 space-y-0.5'; + const sessionTree = buildSessionTree(sessions); + const sessionTitleById = new Map( + allSessions.map(({ sessionId, session }) => [sessionId, sessionDisplayTitle(session)]), + ); + + const renderSessionTreeNode = ( + node: SessionTreeNode, + depth: number, + isForkChild: boolean, + ): ReactNode => { + const { session, sessionId, lastActivity } = node.flat; + const isSessionActive = + selectedProject?.name === project.name && + selectedSession?.id === sessionId && + activeTab === 'chat'; + const isSessionRenaming = renamingSession === sessionId; + const isOptimisticRow = + typeof sessionId === 'string' && sessionId.startsWith('new-session-'); + const indicatorStatus: SessionIndicatorStatus = isOptimisticRow + ? 'processing' + : processingSessions?.has(sessionId) + ? 'processing' + : unreadSessionIds?.has(sessionId) + ? 'unread' + : 'idle'; + const indicatorLabel = + indicatorStatus === 'processing' + ? t('sidebar:sessions.processing', { defaultValue: 'Agent is running' }) + : indicatorStatus === 'unread' + ? t('sidebar:sessions.unread', { defaultValue: 'Unread messages' }) + : t('sidebar:sessions.idle', { defaultValue: 'No unread messages' }); + const parentTitle = session.parentSessionId + ? sessionTitleById.get(session.parentSessionId) + : undefined; + + return ( +
0 ? 'ml-4 border-l border-neutral-200 pl-2 dark:border-neutral-800' : undefined}> +
+ isOptimisticRow ? undefined : openSessionContextMenu(event, project, session) + } + className={cn( + 'group/session relative w-full rounded-md transition-colors', + isSessionActive + ? 'bg-neutral-200/70 dark:bg-neutral-800' + : 'hover:bg-neutral-100 dark:hover:bg-neutral-800', + )} + > + {isSessionRenaming ? ( +
+ setRenameDraft(event.target.value)} + onBlur={commitSessionRename} + onKeyDown={(event) => handleRenameKey(event, 'session')} + onClick={(event) => event.stopPropagation()} + placeholder={t('sidebar:renamePlaceholder', { defaultValue: 'Rename - empty to reset' }) as string} + className="w-full rounded-sm border border-neutral-300 bg-white px-1.5 py-0.5 text-[12.5px] text-neutral-900 outline-none focus:border-neutral-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100" + /> +
+ ) : ( + + )} +
+ {node.children.length > 0 ? ( +
+ {node.children.map((child) => renderSessionTreeNode(child, depth + 1, true))} +
+ ) : null} +
+ ); + }; return (
@@ -694,102 +851,8 @@ export default function SidebarV2({ ) : null} - {sessions.length > 0 ? ( - sessions.map(({ session, sessionId, lastActivity }) => { - const isSessionActive = - selectedProject?.name === project.name && - selectedSession?.id === sessionId && - activeTab === 'chat'; - const isSessionRenaming = renamingSession === sessionId; - // Optimistic placeholder rows are not yet backed by a real - // session id on the server, so clicking / renaming / deleting - // them is meaningless until the server's `projects_updated` - // swaps in the real id (typically within ~300ms). - const isOptimisticRow = - typeof sessionId === 'string' && sessionId.startsWith('new-session-'); - // Optimistic rows always appear "processing" — the user just - // submitted; the agent is always running for them. - const indicatorStatus: SessionIndicatorStatus = isOptimisticRow - ? 'processing' - : processingSessions?.has(sessionId) - ? 'processing' - : unreadSessionIds?.has(sessionId) - ? 'unread' - : 'idle'; - const indicatorLabel = - indicatorStatus === 'processing' - ? t('sidebar:sessions.processing', { defaultValue: 'Agent is running' }) - : indicatorStatus === 'unread' - ? t('sidebar:sessions.unread', { defaultValue: 'Unread messages' }) - : t('sidebar:sessions.idle', { defaultValue: 'No unread messages' }); - - return ( -
- isOptimisticRow ? undefined : openSessionContextMenu(event, project, session) - } - className={cn( - 'group/session relative w-full rounded-md transition-colors', - isSessionActive - ? 'bg-neutral-200/70 dark:bg-neutral-800' - : 'hover:bg-neutral-100 dark:hover:bg-neutral-800', - )} - > - {isSessionRenaming ? ( -
- setRenameDraft(event.target.value)} - onBlur={commitSessionRename} - onKeyDown={(event) => handleRenameKey(event, 'session')} - onClick={(event) => event.stopPropagation()} - placeholder={t('sidebar:renamePlaceholder', { defaultValue: 'Rename - empty to reset' }) as string} - className="w-full rounded-sm border border-neutral-300 bg-white px-1.5 py-0.5 text-[12.5px] text-neutral-900 outline-none focus:border-neutral-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100" - /> -
- ) : ( - - )} - -
- ); - }) + {sessionTree.length > 0 ? ( + sessionTree.map((node) => renderSessionTreeNode(node, 0, false)) ) : (
{t('sidebar:sessions.noSessions', { defaultValue: 'No sessions yet' })} diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index ace99fd1..acca6ef0 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -1,7 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../contexts/TasksSettingsContext'; +import { useToast } from '../../contexts/ToastContext'; +import { api } from '../../utils/api'; import type { ChatInterfaceProps, ChatRunMode, Provider } from '../chat/types/types'; +import type { ChatMessage } from '../chat/types/types'; import { getSessionRequestParams, isBackgroundTaskSession, @@ -71,6 +74,8 @@ function ChatInterfaceV2({ const pendingViewSessionRef = useRef(null); const [isAbortPending, setIsAbortPending] = useState(false); const [runMode, setRunMode] = useState('agent'); + const [isForkPending, setIsForkPending] = useState(false); + const { addToast } = useToast(); const resetStreamingState = useCallback(() => { if (streamTimerRef.current) { @@ -311,6 +316,73 @@ function ChatInterfaceV2({ setIsAbortPending(true); }, [canAbortSession, handleAbortSession, isAbortPending, isLoading]); + const handleFork = useCallback(async (message: ChatMessage, _carriedPreview: number) => { + if (isForkPending || isLoading || isReadOnlyBackgroundSession) return; + const sessionId = currentSessionId || selectedSession?.id; + const fromEntryId = message.entryId; + if (!sessionId || !fromEntryId || !selectedProject) { + addToast('error', t('fork.missingTarget', { defaultValue: 'Cannot fork this message.' })); + return; + } + + const projectPath = selectedProject.fullPath || selectedProject.path || ''; + setIsForkPending(true); + try { + const response = await api.forkSession(sessionId, { projectPath, fromEntryId }); + let result: { newSessionId?: string; prefillText?: string; error?: string } = {}; + try { + result = await response.json(); + } catch { + result = {}; + } + if (!response.ok) { + throw new Error(result?.error || `Fork failed (${response.status})`); + } + const newSessionId = result?.newSessionId; + if (!newSessionId) { + throw new Error('Fork did not return a new session id'); + } + + if (typeof window.refreshProjects === 'function') { + void window.refreshProjects(); + } + + onNavigateToSession?.(newSessionId); + setInput(result.prefillText || message.content || ''); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + scrollToBottom?.(); + }); + // Messages load asynchronously after the session switch; scroll again + // once the carried history has had a chance to render. + setTimeout(() => scrollToBottom?.(), 400); + addToast( + 'success', + t('fork.ready', { + defaultValue: 'Fork created — edit the prompt and send when ready.', + }), + ); + } catch (error) { + const messageText = error instanceof Error ? error.message : String(error); + addToast('error', messageText || t('fork.failed', { defaultValue: 'Fork failed.' })); + } finally { + setIsForkPending(false); + } + }, [ + addToast, + currentSessionId, + isForkPending, + isLoading, + isReadOnlyBackgroundSession, + onNavigateToSession, + scrollToBottom, + selectedProject, + selectedSession?.id, + setInput, + t, + textareaRef, + ]); + useEffect(() => { if (!isLoading || !canAbortSession) return; const handleGlobalEscape = (event: KeyboardEvent) => { @@ -481,6 +553,8 @@ function ChatInterfaceV2({ workingStatus={claudeStatus || pilotDeckStatus} runMode={runMode} sessionStore={sessionStore} + onFork={isReadOnlyBackgroundSession ? undefined : handleFork} + forkDisabled={isForkPending} /> {composer}
diff --git a/ui/src/components/chat-v2/MessageRowV2.tsx b/ui/src/components/chat-v2/MessageRowV2.tsx index 27376bcb..2da222f4 100644 --- a/ui/src/components/chat-v2/MessageRowV2.tsx +++ b/ui/src/components/chat-v2/MessageRowV2.tsx @@ -1,8 +1,9 @@ import { memo, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import { AlertTriangle, Check, ChevronRight, Copy, FileText, Loader2 } from 'lucide-react'; +import { AlertTriangle, Check, ChevronRight, Copy, FileText, GitBranch, Loader2 } from 'lucide-react'; import { copyTextToClipboard } from '../../utils/clipboard'; +import { cn } from '../../lib/utils.js'; import { useTypewriter } from './useTypewriter'; import type { Project, SessionProvider } from '../../types/app'; import type { @@ -82,6 +83,9 @@ type MessageRowV2Props = { subagentActivityById?: Map; subagentThinkingById?: Map; isSessionRunning?: boolean; + onFork?: (message: ChatMessage, carriedMessageCount: number) => void; + forkCarriedMessageCount?: number; + forkDisabled?: boolean; }; // Fall back to the heavy legacy renderer for anything that isn't a vanilla @@ -120,6 +124,9 @@ function MessageRowV2({ subagentActivityById, subagentThinkingById, isSessionRunning, + onFork, + forkCarriedMessageCount = 0, + forkDisabled = false, }: MessageRowV2Props) { const { t } = useTranslation('chat'); const delegate = useMemo(() => shouldDelegate(message), [message]); @@ -243,7 +250,17 @@ function MessageRowV2({ mimeType: image.mimeType, })); return withProcessRows( -
+
+ {onFork ? ( + { + if (message.entryId) onFork(message, forkCarriedMessageCount); + }} + t={t} + /> + ) : null}
{message.isStreaming && !formattedContent ? ( @@ -421,6 +438,42 @@ function CopyMarkdownButton({ content }: { content: string }) { ); } +function ForkMessageButton({ + carriedMessageCount, + disabled, + onFork, + t, +}: { + carriedMessageCount: number; + disabled?: boolean; + onFork: () => void; + t: TFunction; +}) { + const title = t('fork.fromHere', { + count: carriedMessageCount, + defaultValue: `Fork from here · carries ${carriedMessageCount} messages`, + }); + + return ( + + ); +} + export default memo(MessageRowV2); function ProcessSummaryRow({ diff --git a/ui/src/components/chat-v2/MessagesPaneV2.tsx b/ui/src/components/chat-v2/MessagesPaneV2.tsx index 5dbe35ca..894d5cef 100644 --- a/ui/src/components/chat-v2/MessagesPaneV2.tsx +++ b/ui/src/components/chat-v2/MessagesPaneV2.tsx @@ -1,7 +1,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; -import { XCircle } from 'lucide-react'; +import { XCircle, GitBranch } from 'lucide-react'; import type { ChatMessage, ChatRunMode, @@ -66,6 +66,9 @@ type MessagesPaneV2Props = { workingStatus?: ClaudeWorkStatus | PilotDeckWorkStatus | null; runMode?: ChatRunMode; sessionStore?: SessionStore; + onFork?: (message: ChatMessage, carriedMessageCount: number) => void; + forkDisabled?: boolean; + forkParentSessionTitle?: string | null; }; type KeyedRenderableMessageItem = RenderableMessageItem & { @@ -232,6 +235,23 @@ function MeasuredMessageItem({ ); } +function countCarriedMessagesBefore( + items: KeyedRenderableMessageItem[], + renderIndex: number, +): number { + return items + .slice(0, renderIndex) + .filter(({ message }) => message.type === 'user' || message.type === 'assistant' || message.isToolUse) + .length; +} + +function isForkedChatSession(session: ProjectSession | null): boolean { + return Boolean( + session?.parentSessionId && + session.sessionKind !== 'background_task', + ); +} + export default function MessagesPaneV2({ scrollContainerRef, onWheel, @@ -266,6 +286,9 @@ export default function MessagesPaneV2({ workingStatus, runMode = 'agent', sessionStore, + onFork, + forkDisabled = false, + forkParentSessionTitle = null, }: MessagesPaneV2Props) { const { t } = useTranslation('chat'); const messageKeyMapRef = useRef>(new WeakMap()); @@ -711,6 +734,9 @@ export default function MessagesPaneV2({ ? keyedMessageItems[item.renderIndex + 1].message : null; const isLast = !isAssistantWorking && item.renderIndex === keyedMessageItems.length - 1; + const forkCarriedMessageCount = item.message.type === 'user' + ? countCarriedMessagesBefore(keyedMessageItems, item.renderIndex) + : 0; const anchoredLiveGroups = liveProcessGroupsByAnchor.get(item.originalIndex) || []; const rendersLiveHeaderAfterItem = item.renderIndex === liveProcessHeaderIndex - 1; @@ -758,6 +784,9 @@ export default function MessagesPaneV2({ subagentActivityById={subagentActivityById} subagentThinkingById={subagentThinkingById} isSessionRunning={isAssistantWorking} + onFork={onFork} + forkCarriedMessageCount={forkCarriedMessageCount} + forkDisabled={forkDisabled} /> {rendersLiveHeaderAfterItem ? ( ) : null}
+ ) : isExistingConversationEmpty && isForkedChatSession(selectedSession) ? ( +
+
+ +
+
+ {t('fork.emptyTitle', { defaultValue: 'New branch ready' })} +
+
+ {t('fork.emptyDescription', { + parent: forkParentSessionTitle || selectedSession?.parentSessionId || '', + defaultValue: + 'This branch starts from the beginning of the original conversation. The forked prompt is waiting in the composer — edit it and send to continue here.', + })} +
+
) : isExistingConversationEmpty ? (
@@ -935,6 +983,18 @@ export default function MessagesPaneV2({
) : null} + {isForkedChatSession(selectedSession) ? ( +
+ + + {t('fork.banner', { + parent: forkParentSessionTitle || selectedSession?.parentSessionId || '', + defaultValue: `Forked from ${forkParentSessionTitle || selectedSession?.parentSessionId || 'parent session'}`, + })} + +
+ ) : null} + {shouldVirtualizeMessages && virtualWindow.topPadding > 0 ? (