From 19507be848130b0e4927b3ca1385ebfa6dd1b6a4 Mon Sep 17 00:00:00 2001 From: clr182 Date: Fri, 22 May 2026 15:31:47 +0100 Subject: [PATCH 1/3] branching tasks --- .../src/renderer/components/HeaderRow.tsx | 6 + .../sessions/components/BranchTaskControl.tsx | 35 +++++ .../sessions/components/BranchTaskDialog.tsx | 140 ++++++++++++++++++ .../sessions/components/BranchedFromChip.tsx | 49 ++++++ .../features/sessions/service/branchTask.ts | 123 +++++++++++++++ .../sessions/stores/branchLineageStore.ts | 44 ++++++ .../sessions/utils/branchContext.test.ts | 87 +++++++++++ .../features/sessions/utils/branchContext.ts | 80 ++++++++++ .../src/renderer/sagas/task/task-creation.ts | 6 +- .../utils/generateBranchSummary.test.ts | 81 ++++++++++ .../renderer/utils/generateBranchSummary.ts | 78 ++++++++++ apps/code/src/shared/types.ts | 16 ++ 12 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx create mode 100644 apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx create mode 100644 apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx create mode 100644 apps/code/src/renderer/features/sessions/service/branchTask.ts create mode 100644 apps/code/src/renderer/features/sessions/stores/branchLineageStore.ts create mode 100644 apps/code/src/renderer/features/sessions/utils/branchContext.test.ts create mode 100644 apps/code/src/renderer/features/sessions/utils/branchContext.ts create mode 100644 apps/code/src/renderer/utils/generateBranchSummary.test.ts create mode 100644 apps/code/src/renderer/utils/generateBranchSummary.ts diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index bad22ee33..98320b8dd 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -3,6 +3,8 @@ import { DiffStatsBadge } from "@features/code-review/components/DiffStatsBadge" import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader"; import { TaskActionsMenu } from "@features/git-interaction/components/TaskActionsMenu"; +import { BranchedFromChip } from "@features/sessions/components/BranchedFromChip"; +import { BranchTaskControl } from "@features/sessions/components/BranchTaskControl"; import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog"; import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks"; @@ -181,6 +183,7 @@ export function HeaderRow() { pl="1" className="h-full max-w-[50%] shrink-0 overflow-hidden" > +
@@ -209,6 +212,9 @@ export function HeaderRow() { ) : ( )} + {activeWorkspace && ( + + )} )} diff --git a/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx b/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx new file mode 100644 index 000000000..017d65c42 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx @@ -0,0 +1,35 @@ +import { BranchTaskDialog } from "@features/sessions/components/BranchTaskDialog"; +import type { Workspace } from "@main/services/workspace/schemas"; +import { GitFork } from "@phosphor-icons/react"; +import { Button as QuillButton } from "@posthog/quill"; +import type { Task } from "@shared/types"; +import { useState } from "react"; + +interface BranchTaskControlProps { + task: Task; + workspace: Workspace | null; +} + +/** Header button that opens the "Branch task" dialog. */ +export function BranchTaskControl({ task, workspace }: BranchTaskControlProps) { + const [open, setOpen] = useState(false); + + return ( + <> +
+ setOpen(true)}> + + Branch + +
+ {open && ( + + )} + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx b/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx new file mode 100644 index 000000000..bb3405d3e --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx @@ -0,0 +1,140 @@ +import { Tooltip } from "@components/ui/Tooltip"; +import { GitDialog } from "@features/git-interaction/components/GitInteractionDialogs"; +import { branchTask } from "@features/sessions/service/branchTask"; +import { useCreateTask } from "@features/tasks/hooks/useTasks"; +import type { Workspace } from "@main/services/workspace/schemas"; +import { ChatCircleText, Code, GitFork } from "@phosphor-icons/react"; +import { CheckIcon } from "@radix-ui/react-icons"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { Task } from "@shared/types"; +import { useNavigationStore } from "@stores/navigationStore"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +type BranchMode = "context" | "context+code"; + +interface BranchModeOptionProps { + icon: ReactNode; + label: string; + description: string; + selected: boolean; + disabled?: boolean; + disabledReason?: string; + onSelect: () => void; +} + +function BranchModeOption({ + icon, + label, + description, + selected, + disabled = false, + disabledReason, + onSelect, +}: BranchModeOptionProps) { + const row = ( + !disabled && onSelect()} + className={`flex items-start justify-between gap-2 rounded-(--radius-2) border px-[8px] py-[6px] ${ + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" + } ${selected ? "border-(--accent-7) bg-(--accent-3)" : "border-(--gray-6) bg-(--gray-2)"}`} + > + + + {icon} + {label} + + + {description} + + + {selected && } + + ); + + if (disabled && disabledReason) { + return {row}; + } + return row; +} + +interface BranchTaskDialogProps { + task: Task; + workspace: Workspace | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BranchTaskDialog({ + task, + workspace, + open, + onOpenChange, +}: BranchTaskDialogProps) { + const [mode, setMode] = useState("context"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { invalidateTasks } = useCreateTask(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + + const handleSubmit = async () => { + setError(null); + setIsSubmitting(true); + try { + const result = await branchTask( + { task, workspace, mode: "context" }, + (newTask) => { + invalidateTasks(newTask); + navigateToTask(newTask); + }, + ); + if (result.success) { + onOpenChange(false); + } else { + setError(result.error ?? "Failed to branch task"); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( + } + title="Branch task" + error={error} + buttonLabel="Branch" + isSubmitting={isSubmitting} + onSubmit={handleSubmit} + maxWidth="420px" + > + + Start a new task from this moment. It begins with a summary of the + current conversation as context. + + + + } + label="Branch with context" + description="New task starts from a clean tree with the summarised conversation." + selected={mode === "context"} + onSelect={() => setMode("context")} + /> + } + label="Branch with context + code" + description="Also carry over the current code changes." + selected={mode === "context+code"} + disabled + disabledReason="Available in a future update" + onSelect={() => setMode("context+code")} + /> + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx b/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx new file mode 100644 index 000000000..d6f704d81 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx @@ -0,0 +1,49 @@ +import { Tooltip } from "@components/ui/Tooltip"; +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useBranchLineage } from "@features/sessions/stores/branchLineageStore"; +import { GitFork } from "@phosphor-icons/react"; +import type { Task } from "@shared/types"; +import { useNavigationStore } from "@stores/navigationStore"; +import { logger } from "@utils/logger"; + +const log = logger.scope("branched-from-chip"); + +interface BranchedFromChipProps { + taskId: string; +} + +/** Chip on a branched task linking back to the task it came from. */ +export function BranchedFromChip({ taskId }: BranchedFromChipProps) { + const lineage = useBranchLineage(taskId); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + + if (!lineage) return null; + + const label = lineage.parentTaskNumber + ? `#${lineage.parentTaskNumber}` + : "parent"; + + const handleClick = async () => { + const client = await getAuthenticatedClient(); + if (!client) return; + try { + const parent = await client.getTask(lineage.parentTaskId); + navigateToTask(parent as unknown as Task); + } catch (error) { + log.warn("Failed to open parent task", { error }); + } + }; + + return ( + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/service/branchTask.ts b/apps/code/src/renderer/features/sessions/service/branchTask.ts new file mode 100644 index 000000000..27d3c22e8 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/service/branchTask.ts @@ -0,0 +1,123 @@ +/** + * Branches a task: creates a new task seeded with an LLM summary of the + * source task's conversation. + */ +import { useBranchLineageStore } from "@features/sessions/stores/branchLineageStore"; +import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; +import { buildBranchTranscript } from "@features/sessions/utils/branchContext"; +import type { + TaskCreationInput, + TaskService, +} from "@features/task-detail/service/service"; +import type { Workspace } from "@main/services/workspace/schemas"; +import { get } from "@renderer/di/container"; +import { RENDERER_TOKENS } from "@renderer/di/tokens"; +import type { BranchLineage, Task } from "@shared/types"; +import { generateBranchSummary } from "@utils/generateBranchSummary"; +import { logger } from "@utils/logger"; + +const log = logger.scope("branch-task"); + +export interface BranchTaskParams { + task: Task; + workspace: Workspace | null; + /** Phase 1 only supports carrying summarised context. */ + mode: "context"; +} + +export interface BranchTaskResult { + success: boolean; + error?: string; +} + +function buildBranchPrompt(task: Task, context: string): string { + const ref = task.task_number ? `task #${task.task_number}` : "another task"; + return `This task was branched from ${ref} ("${task.title}"). The summary below captures everything decided and done so far — continue the work from this point. + + +${context} +`; +} + +/** `onTaskCreated` fires once the new task exists. */ +export async function branchTask( + params: BranchTaskParams, + onTaskCreated: (task: Task) => void, +): Promise { + const { task, workspace } = params; + + const isCloud = workspace?.mode === "cloud"; + if (!isCloud && !workspace?.folderPath) { + return { success: false, error: "Source task has no local workspace" }; + } + + // Gather the conversation so far from the live session, if any. + const events = sessionStoreSetters.getSessionByTaskId(task.id)?.events ?? []; + const { transcript, turnCount } = buildBranchTranscript(events); + + const summary = await generateBranchSummary( + transcript || + "(No conversation yet — only the task description is available.)", + task.description, + ); + + // Fall back to the raw transcript if summarisation failed. + const context = summary?.context ?? transcript; + if (!context.trim()) { + return { success: false, error: "Nothing to branch — no context found" }; + } + const title = summary?.title ?? `Branch of ${task.title}`; + + const content = buildBranchPrompt(task, context); + + const input: TaskCreationInput = { + content, + taskDescription: context, + taskTitle: title, + workspaceMode: isCloud ? "cloud" : (workspace?.mode ?? "worktree"), + repoPath: isCloud ? undefined : (workspace?.folderPath ?? undefined), + repository: task.repository ?? undefined, + githubIntegrationId: isCloud + ? (task.github_integration ?? undefined) + : undefined, + githubUserIntegrationId: isCloud + ? (task.github_user_integration ?? undefined) + : undefined, + // Keep the branch consistent with the source run's agent setup. + adapter: task.latest_run?.runtime_adapter ?? undefined, + model: task.latest_run?.model ?? undefined, + reasoningLevel: task.latest_run?.reasoning_effort ?? undefined, + }; + + const lineage: BranchLineage = { + parentTaskId: task.id, + parentTaskNumber: task.task_number, + parentTaskTitle: task.title, + parentRunId: task.latest_run?.id ?? null, + branchedAtTurn: turnCount, + branchedAt: new Date().toISOString(), + mode: "context", + }; + + try { + const taskService = get(RENDERER_TOKENS.TaskService); + const result = await taskService.createTask(input, (output) => { + useBranchLineageStore.getState().setLineage(output.task.id, lineage); + onTaskCreated(output.task); + }); + + if (!result.success) { + log.error("Branch task creation failed", { + failedStep: result.failedStep, + error: result.error, + }); + return { success: false, error: result.error }; + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + log.error("Unexpected error while branching task", { error }); + return { success: false, error: message }; + } +} diff --git a/apps/code/src/renderer/features/sessions/stores/branchLineageStore.ts b/apps/code/src/renderer/features/sessions/stores/branchLineageStore.ts new file mode 100644 index 000000000..dbd438a2c --- /dev/null +++ b/apps/code/src/renderer/features/sessions/stores/branchLineageStore.ts @@ -0,0 +1,44 @@ +/** + * Tracks branch lineage client-side (keyed by child task id) until the + * `Task` model persists it. See `BranchLineage` in shared types. + */ +import type { BranchLineage } from "@shared/types"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface BranchLineageStoreState { + lineageByTaskId: Record; +} + +interface BranchLineageStoreActions { + setLineage: (taskId: string, lineage: BranchLineage) => void; + getLineage: (taskId: string) => BranchLineage | undefined; +} + +type BranchLineageStore = BranchLineageStoreState & BranchLineageStoreActions; + +export const useBranchLineageStore = create()( + persist( + (set, get) => ({ + lineageByTaskId: {}, + setLineage: (taskId, lineage) => + set((state) => ({ + lineageByTaskId: { ...state.lineageByTaskId, [taskId]: lineage }, + })), + getLineage: (taskId) => get().lineageByTaskId[taskId], + }), + { + name: "branch-lineage-storage", + partialize: (state) => ({ lineageByTaskId: state.lineageByTaskId }), + }, + ), +); + +/** Subscribe to the lineage for a single task. */ +export function useBranchLineage( + taskId: string | undefined, +): BranchLineage | undefined { + return useBranchLineageStore((state) => + taskId ? state.lineageByTaskId[taskId] : undefined, + ); +} diff --git a/apps/code/src/renderer/features/sessions/utils/branchContext.test.ts b/apps/code/src/renderer/features/sessions/utils/branchContext.test.ts new file mode 100644 index 000000000..c73fa9f24 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/utils/branchContext.test.ts @@ -0,0 +1,87 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { describe, expect, it } from "vitest"; +import { buildBranchTranscript } from "./branchContext"; + +function userPromptMsg(ts: number, id: number, text: string): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + id, + method: "session/prompt", + params: { prompt: [{ type: "text", text }] }, + }, + }; +} + +function agentMessageMsg(ts: number, text: string): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + }, + }, + }; +} + +describe("buildBranchTranscript", () => { + it("returns empty result for no events", () => { + expect(buildBranchTranscript([])).toEqual({ + transcript: "", + turnCount: 0, + truncated: false, + }); + }); + + it("includes user and assistant turns", () => { + const events = [ + userPromptMsg(1, 1, "Fix the login bug"), + agentMessageMsg(2, "I found the issue in auth.ts"), + ]; + + const result = buildBranchTranscript(events); + + expect(result.turnCount).toBe(1); + expect(result.truncated).toBe(false); + expect(result.transcript).toContain("## User"); + expect(result.transcript).toContain("Fix the login bug"); + expect(result.transcript).toContain("## Assistant"); + expect(result.transcript).toContain("I found the issue in auth.ts"); + }); + + it("counts multiple user turns", () => { + const events = [ + userPromptMsg(1, 1, "First request"), + agentMessageMsg(2, "Done"), + userPromptMsg(3, 2, "Second request"), + ]; + + expect(buildBranchTranscript(events).turnCount).toBe(2); + }); + + it("truncates oldest turns when over the character budget", () => { + const huge = "x".repeat(5_000); + const events: AcpMessage[] = []; + for (let i = 0; i < 20; i++) { + events.push(userPromptMsg(i * 2 + 1, i + 1, `prompt ${i}`)); + events.push(agentMessageMsg(i * 2 + 2, huge)); + } + + const result = buildBranchTranscript(events); + + expect(result.truncated).toBe(true); + expect(result.transcript.startsWith("_(earlier turns omitted)_")).toBe( + true, + ); + // Most recent turn must survive truncation. + expect(result.transcript).toContain("prompt 19"); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/utils/branchContext.ts b/apps/code/src/renderer/features/sessions/utils/branchContext.ts new file mode 100644 index 000000000..09c2cba93 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/utils/branchContext.ts @@ -0,0 +1,80 @@ +/** Builds a plain-text transcript of a task's conversation for summarisation. */ +import { buildConversationItems } from "@features/sessions/components/buildConversationItems"; +import type { AcpMessage } from "@shared/types/session-events"; + +/** Transcript budget handed to the summariser (~6k tokens). */ +const MAX_TRANSCRIPT_CHARS = 24_000; +/** Per-message cap so one huge answer can't crowd out the rest. */ +const MAX_BLOCK_CHARS = 2_000; + +export interface BranchTranscript { + transcript: string; + turnCount: number; + /** Whether older turns were dropped to fit the budget. */ + truncated: boolean; +} + +function truncateBlock(text: string): string { + if (text.length <= MAX_BLOCK_CHARS) return text; + return `${text.slice(0, MAX_BLOCK_CHARS)}… (truncated)`; +} + +/** Includes user messages and agent replies; tool calls become a one-liner. */ +export function buildBranchTranscript(events: AcpMessage[]): BranchTranscript { + const { items } = buildConversationItems(events, null); + + const blocks: string[] = []; + let turnCount = 0; + + for (const item of items) { + if (item.type === "user_message") { + const content = item.content.trim(); + if (!content) continue; + turnCount++; + blocks.push(`## User\n${truncateBlock(content)}`); + continue; + } + + if (item.type === "session_update") { + const update = item.update as { + sessionUpdate?: string; + content?: { type?: string; text?: string }; + title?: string; + }; + if ( + update.sessionUpdate === "agent_message_chunk" && + update.content?.type === "text" + ) { + const text = update.content.text?.trim(); + if (text) blocks.push(`## Assistant\n${truncateBlock(text)}`); + } else if (update.sessionUpdate === "tool_call") { + blocks.push(`_(used tool: ${update.title ?? "unknown"})_`); + } + } + } + + if (blocks.length === 0) { + return { transcript: "", turnCount: 0, truncated: false }; + } + + // Keep the most recent blocks within the character budget. + const kept: string[] = []; + let total = 0; + let truncated = false; + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (total + block.length > MAX_TRANSCRIPT_CHARS && kept.length > 0) { + truncated = true; + break; + } + kept.push(block); + total += block.length; + } + kept.reverse(); + + const transcript = truncated + ? `_(earlier turns omitted)_\n\n${kept.join("\n\n")}` + : kept.join("\n\n"); + + return { transcript, turnCount, truncated }; +} diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 582c0b94a..c136c1220 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -44,6 +44,7 @@ export interface TaskCreationInput { // For creating new task (required if no taskId) content?: string; taskDescription?: string; + taskTitle?: string; filePaths?: string[]; repoPath?: string; repository?: string | null; @@ -403,7 +404,10 @@ export class TaskCreationSaga extends Saga< const description = input.taskDescription ?? input.content ?? ""; const plainText = xmlToPlainText(description).trim(); const result = await this.deps.posthogClient.createTask({ - title: (plainText || "Untitled").slice(0, 255), + title: (input.taskTitle?.trim() || plainText || "Untitled").slice( + 0, + 255, + ), description, repository: repository ?? undefined, github_integration: diff --git a/apps/code/src/renderer/utils/generateBranchSummary.test.ts b/apps/code/src/renderer/utils/generateBranchSummary.test.ts new file mode 100644 index 000000000..d4d57bf1f --- /dev/null +++ b/apps/code/src/renderer/utils/generateBranchSummary.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetchAuthState = vi.hoisted(() => vi.fn()); +const mockPromptMutate = vi.hoisted(() => vi.fn()); + +vi.mock("@features/auth/hooks/authQueries", () => ({ + fetchAuthState: mockFetchAuthState, +})); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + llmGateway: { prompt: { mutate: mockPromptMutate } }, + }, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { generateBranchSummary } from "./generateBranchSummary"; + +describe("generateBranchSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchAuthState.mockResolvedValue({ status: "authenticated" }); + }); + + it("parses TITLE and CONTEXT from the response", async () => { + mockPromptMutate.mockResolvedValue({ + content: + "TITLE: Continue auth refactor\nCONTEXT:\nYou are continuing a refactor of auth.ts.\n\nNext, update the tests.", + }); + + const result = await generateBranchSummary("transcript", "description"); + + expect(result).toEqual({ + title: "Continue auth refactor", + context: + "You are continuing a refactor of auth.ts.\n\nNext, update the tests.", + }); + }); + + it("returns null when not authenticated", async () => { + mockFetchAuthState.mockResolvedValue({ status: "unauthenticated" }); + + expect(await generateBranchSummary("transcript", "description")).toBeNull(); + expect(mockPromptMutate).not.toHaveBeenCalled(); + }); + + it("returns null when the response has no CONTEXT", async () => { + mockPromptMutate.mockResolvedValue({ content: "TITLE: Just a title" }); + + expect(await generateBranchSummary("transcript", "description")).toBeNull(); + }); + + it("falls back to a default title when none is parsed", async () => { + mockPromptMutate.mockResolvedValue({ + content: "CONTEXT:\nSome briefing text.", + }); + + const result = await generateBranchSummary("transcript", "description"); + + expect(result).toEqual({ + title: "Branched task", + context: "Some briefing text.", + }); + }); + + it("returns null when the LLM call throws", async () => { + mockPromptMutate.mockRejectedValue(new Error("network")); + + expect(await generateBranchSummary("transcript", "description")).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/utils/generateBranchSummary.ts b/apps/code/src/renderer/utils/generateBranchSummary.ts new file mode 100644 index 000000000..58d603b6a --- /dev/null +++ b/apps/code/src/renderer/utils/generateBranchSummary.ts @@ -0,0 +1,78 @@ +/** Summarises an in-progress task so a branched task can pick up from it. */ +import { fetchAuthState } from "@features/auth/hooks/authQueries"; +import { trpcClient } from "@renderer/trpc"; +import { logger } from "@utils/logger"; + +const log = logger.scope("branch-summary"); + +/** Stronger than the title-generator default — handoff fidelity matters. */ +const BRANCH_SUMMARY_MODEL = "claude-sonnet-4-6"; +const BRANCH_SUMMARY_MAX_TOKENS = 1500; + +const SYSTEM_PROMPT = `You are summarising an in-progress task so it can be continued as a new, fresh task ("branching"). The new task starts with no conversation history — your summary is the ONLY context it will have. + +Output using exactly this format: + +TITLE: +CONTEXT: +<briefing> + +Title rules: +- Short (max 6 words), sentence case, action-verb first where natural. +- Reflects what the branched task should continue doing. +- Never wrap in quotes. + +Briefing rules: +- Write a clear, self-contained handoff briefing in the second person ("You are continuing..."). +- Cover, in order: the overall goal; key decisions and constraints established so far; what has already been done; what remains to be done; and any relevant files, commands, or gotchas. +- Use short paragraphs or bullet points. Be specific — include file names, function names, and concrete details mentioned in the conversation. +- Do NOT invent facts. Only summarise what the transcript supports. +- Do NOT address or answer the task yourself — only summarise. + +Never include any text outside the TITLE and CONTEXT sections.`; + +export interface BranchSummary { + title: string; + context: string; +} + +/** Returns `null` on failure so callers can fall back to the raw transcript. */ +export async function generateBranchSummary( + transcript: string, + originalDescription: string, +): Promise<BranchSummary | null> { + try { + const authState = await fetchAuthState(); + if (authState.status !== "authenticated") return null; + + const result = await trpcClient.llmGateway.prompt.mutate({ + system: SYSTEM_PROMPT, + model: BRANCH_SUMMARY_MODEL, + maxTokens: BRANCH_SUMMARY_MAX_TOKENS, + messages: [ + { + role: "user" as const, + content: `Summarise the following task so it can be continued as a fresh task. Original task description:\n\n<description>\n${originalDescription}\n</description>\n\nConversation so far:\n\n<transcript>\n${transcript}\n</transcript>\n\nOutput the TITLE and CONTEXT now:`, + }, + ], + }); + + const text = result.content.trim(); + const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m); + const contextMatch = text.match(/CONTEXT:\s*([\s\S]+)$/m); + + const title = + titleMatch?.[1] + ?.trim() + .replace(/^["']|["']$/g, "") + .slice(0, 255) ?? ""; + const context = contextMatch?.[1]?.trim() ?? ""; + + if (!context) return null; + + return { title: title || "Branched task", context }; + } catch (error) { + log.error("Failed to generate branch summary", { error }); + return null; + } +} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index f062f670c..48419ffdd 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -52,6 +52,22 @@ export interface Task { signal_report?: string | null; internal?: boolean; latest_run?: TaskRun; + /** Set if this task was created by branching another task. Not yet + * persisted by the API — tracked client-side in `branchLineageStore`. */ + branched_from?: BranchLineage | null; +} + +/** Records that a task was created by branching from another task. */ +export interface BranchLineage { + parentTaskId: string; + parentTaskNumber: number | null; + parentTaskTitle: string; + /** Source run that was summarised, if any. */ + parentRunId: string | null; + /** User turns elapsed in the parent at branch time. */ + branchedAtTurn: number; + branchedAt: string; + mode: "context" | "context+code"; } export type TaskRunStatus = From 06fa4ebbc5fca05e15148d4cbe6c8c3acc660e6b Mon Sep 17 00:00:00 2001 From: clr182 <chrustuanr15@gmail.com> Date: Fri, 22 May 2026 15:45:00 +0100 Subject: [PATCH 2/3] gix header overcrowding --- .../sessions/components/BranchTaskControl.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx b/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx index 017d65c42..258ab9dea 100644 --- a/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx +++ b/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx @@ -1,3 +1,4 @@ +import { Tooltip } from "@components/ui/Tooltip"; import { BranchTaskDialog } from "@features/sessions/components/BranchTaskDialog"; import type { Workspace } from "@main/services/workspace/schemas"; import { GitFork } from "@phosphor-icons/react"; @@ -16,12 +17,18 @@ export function BranchTaskControl({ task, workspace }: BranchTaskControlProps) { return ( <> - <div className="no-drag flex items-center"> - <QuillButton variant="outline" size="sm" onClick={() => setOpen(true)}> - <GitFork size={14} weight="regular" className="shrink-0" /> - Branch - </QuillButton> - </div> + <Tooltip content="Branch task"> + <div className="no-drag flex shrink-0 items-center"> + <QuillButton + variant="outline" + size="sm" + aria-label="Branch task" + onClick={() => setOpen(true)} + > + <GitFork size={14} weight="regular" className="shrink-0" /> + </QuillButton> + </div> + </Tooltip> {open && ( <BranchTaskDialog task={task} From 0507b729a8dcd8b67c86f2ebd149e2dfef81e6cb Mon Sep 17 00:00:00 2001 From: clr182 <chrustuanr15@gmail.com> Date: Fri, 22 May 2026 15:59:15 +0100 Subject: [PATCH 3/3] bot suggestions --- .../sessions/components/BranchTaskDialog.tsx | 16 +++------------- .../sessions/components/BranchedFromChip.tsx | 3 +-- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx b/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx index bb3405d3e..210058a43 100644 --- a/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx +++ b/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx @@ -11,31 +11,25 @@ import { useNavigationStore } from "@stores/navigationStore"; import type { ReactNode } from "react"; import { useState } from "react"; -type BranchMode = "context" | "context+code"; - interface BranchModeOptionProps { icon: ReactNode; label: string; description: string; - selected: boolean; + selected?: boolean; disabled?: boolean; disabledReason?: string; - onSelect: () => void; } function BranchModeOption({ icon, label, description, - selected, + selected = false, disabled = false, disabledReason, - onSelect, }: BranchModeOptionProps) { const row = ( <Box - role="button" - onClick={() => !disabled && onSelect()} className={`flex items-start justify-between gap-2 rounded-(--radius-2) border px-[8px] py-[6px] ${ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" } ${selected ? "border-(--accent-7) bg-(--accent-3)" : "border-(--gray-6) bg-(--gray-2)"}`} @@ -72,7 +66,6 @@ export function BranchTaskDialog({ open, onOpenChange, }: BranchTaskDialogProps) { - const [mode, setMode] = useState<BranchMode>("context"); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState<string | null>(null); @@ -122,17 +115,14 @@ export function BranchTaskDialog({ icon={<ChatCircleText size={14} />} label="Branch with context" description="New task starts from a clean tree with the summarised conversation." - selected={mode === "context"} - onSelect={() => setMode("context")} + selected /> <BranchModeOption icon={<Code size={14} />} label="Branch with context + code" description="Also carry over the current code changes." - selected={mode === "context+code"} disabled disabledReason="Available in a future update" - onSelect={() => setMode("context+code")} /> </Flex> </GitDialog> diff --git a/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx b/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx index d6f704d81..243ba64d5 100644 --- a/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx +++ b/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx @@ -2,7 +2,6 @@ import { Tooltip } from "@components/ui/Tooltip"; import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useBranchLineage } from "@features/sessions/stores/branchLineageStore"; import { GitFork } from "@phosphor-icons/react"; -import type { Task } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; @@ -28,7 +27,7 @@ export function BranchedFromChip({ taskId }: BranchedFromChipProps) { if (!client) return; try { const parent = await client.getTask(lineage.parentTaskId); - navigateToTask(parent as unknown as Task); + navigateToTask(parent); } catch (error) { log.warn("Failed to open parent task", { error }); }