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..258ab9dea --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/BranchTaskControl.tsx @@ -0,0 +1,42 @@ +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"; +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)} + > + + +
+
+ {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..210058a43 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/BranchTaskDialog.tsx @@ -0,0 +1,130 @@ +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"; + +interface BranchModeOptionProps { + icon: ReactNode; + label: string; + description: string; + selected?: boolean; + disabled?: boolean; + disabledReason?: string; +} + +function BranchModeOption({ + icon, + label, + description, + selected = false, + disabled = false, + disabledReason, +}: BranchModeOptionProps) { + const row = ( + + + + {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 [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 + /> + } + label="Branch with context + code" + description="Also carry over the current code changes." + disabled + disabledReason="Available in a future update" + /> + + + ); +} 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..243ba64d5 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/BranchedFromChip.tsx @@ -0,0 +1,48 @@ +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 { 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); + } 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 =