From 4787f4d151bbb7434c86162873877fb9bc033c7f Mon Sep 17 00:00:00 2001 From: 0x1355 <0x1355@gmail.com> Date: Thu, 19 Mar 2026 12:25:00 +0100 Subject: [PATCH] feat(ghostwriter): branching conversations with edit and per-message regenerate (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn Ghostwriter's flat message list into a tree structure, enabling Claude.ai/ChatGPT-style branching conversations. **Data model**: Add `parentMessageId` and `activeChildId` to messages, `activeRootMessageId` to threads. Migration backfills existing messages into a linear chain and links regenerated messages as siblings. **Backend**: Tree-walking queries (getActivePathFromRoot, getAncestorPath, getSiblingsOf), rewritten history builder that follows the ancestor path, new editMessage and switchBranch services, regenerate now works on any assistant message (not just the latest). **Frontend**: BranchNavigator component (← 2/3 → arrows), inline edit on user messages, per-message regenerate on assistant messages, regenerate button removed from composer (now per-message). **Infra**: Pin Node 22 via Volta to prevent ABI mismatches with better-sqlite3 across environments. --- orchestrator/src/client/api/client.ts | 36 ++- .../ghostwriter/BranchNavigator.tsx | 44 +++ .../components/ghostwriter/Composer.tsx | 19 +- .../ghostwriter/GhostwriterPanel.tsx | 163 +++++++++--- .../components/ghostwriter/MessageList.tsx | 112 +++++++- .../src/server/api/routes/ghostwriter.test.ts | 178 ++++++++----- .../src/server/api/routes/ghostwriter.ts | 130 ++++++++- orchestrator/src/server/db/migrate.ts | 50 +++- orchestrator/src/server/db/schema.ts | 3 + .../src/server/repositories/ghostwriter.ts | 187 +++++++++++++ .../src/server/services/ghostwriter.test.ts | 79 +++++- .../src/server/services/ghostwriter.ts | 250 +++++++++++++++--- package.json | 3 + shared/src/types/chat.ts | 12 + 14 files changed, 1085 insertions(+), 181 deletions(-) create mode 100644 orchestrator/src/client/components/ghostwriter/BranchNavigator.tsx diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index fd81062..6977ee5 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -9,6 +9,7 @@ import type { ApplicationTask, AppSettings, BackupInfo, + BranchInfo, DemoInfoResponse, Job, JobActionRequest, @@ -559,7 +560,7 @@ export async function listJobChatThreads(jobId: string): Promise<{ export async function listJobGhostwriterMessages( jobId: string, options?: { limit?: number; offset?: number }, -): Promise<{ messages: JobChatMessage[] }> { +): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> { const params = new URLSearchParams(); if (typeof options?.limit === "number") { params.set("limit", String(options.limit)); @@ -568,7 +569,7 @@ export async function listJobGhostwriterMessages( params.set("offset", String(options.offset)); } const query = params.toString(); - return fetchApi<{ messages: JobChatMessage[] }>( + return fetchApi<{ messages: JobChatMessage[]; branches: BranchInfo[] }>( `/jobs/${jobId}/chat/messages${query ? `?${query}` : ""}`, ); } @@ -747,6 +748,37 @@ export async function streamRegenerateJobGhostwriterMessage( ); } +export async function editJobGhostwriterMessage( + jobId: string, + messageId: string, + input: { content: string; signal?: AbortSignal }, + handlers: { + onEvent: (event: JobChatStreamEvent) => void; + }, +): Promise { + return streamSseEvents( + `/jobs/${jobId}/chat/messages/${messageId}/edit`, + { content: input.content, stream: true }, + { + onEvent: handlers.onEvent, + signal: input.signal, + }, + ); +} + +export async function switchJobGhostwriterBranch( + jobId: string, + messageId: string, +): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> { + return fetchApi<{ messages: JobChatMessage[]; branches: BranchInfo[] }>( + `/jobs/${jobId}/chat/messages/${messageId}/switch-branch`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + function toJobIdList(idOrIds: string | string[]): string[] { return Array.isArray(idOrIds) ? idOrIds : [idOrIds]; } diff --git a/orchestrator/src/client/components/ghostwriter/BranchNavigator.tsx b/orchestrator/src/client/components/ghostwriter/BranchNavigator.tsx new file mode 100644 index 0000000..8127b68 --- /dev/null +++ b/orchestrator/src/client/components/ghostwriter/BranchNavigator.tsx @@ -0,0 +1,44 @@ +import type { BranchInfo } from "@shared/types"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type React from "react"; + +type BranchNavigatorProps = { + branchInfo: BranchInfo; + onSwitch: (messageId: string) => void; +}; + +export const BranchNavigator: React.FC = ({ + branchInfo, + onSwitch, +}) => { + const { siblingIds, activeIndex } = branchInfo; + const total = siblingIds.length; + const canGoLeft = activeIndex > 0; + const canGoRight = activeIndex < total - 1; + + return ( +
+ + + {activeIndex + 1}/{total} + + +
+ ); +}; diff --git a/orchestrator/src/client/components/ghostwriter/Composer.tsx b/orchestrator/src/client/components/ghostwriter/Composer.tsx index f5cf0d1..913a5fb 100644 --- a/orchestrator/src/client/components/ghostwriter/Composer.tsx +++ b/orchestrator/src/client/components/ghostwriter/Composer.tsx @@ -1,5 +1,5 @@ import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key"; -import { Eraser, RefreshCcw, Send, Square } from "lucide-react"; +import { Eraser, Send, Square } from "lucide-react"; import type React from "react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; @@ -8,9 +8,7 @@ import { Textarea } from "@/components/ui/textarea"; type ComposerProps = { disabled?: boolean; isStreaming: boolean; - canRegenerate: boolean; canReset: boolean; - onRegenerate: () => Promise; onStop: () => Promise; onSend: (content: string) => Promise; onReset: () => void; @@ -19,9 +17,7 @@ type ComposerProps = { export const Composer: React.FC = ({ disabled, isStreaming, - canRegenerate, canReset, - onRegenerate, onStop, onSend, onReset, @@ -67,7 +63,7 @@ export const Composer: React.FC = ({ - {isStreaming ? ( + {isStreaming && ( - ) : ( - )} + )} + {!isUser && !isStreaming && !isActiveStreaming && ( + + )} + - {isActiveStreaming ? ( + + {isEditing ? ( +
+