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:
+
+
+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 {
+ 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\n${originalDescription}\n\n\nConversation so far:\n\n\n${transcript}\n\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 =