Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/code/src/renderer/components/HeaderRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -181,6 +183,7 @@ export function HeaderRow() {
pl="1"
className="h-full max-w-[50%] shrink-0 overflow-hidden"
>
<BranchedFromChip taskId={view.data.id} />
<div className="no-drag">
<SkillButtonsMenu taskId={view.data.id} />
</div>
Expand Down Expand Up @@ -209,6 +212,9 @@ export function HeaderRow() {
) : (
<LocalHandoffButton taskId={view.data.id} task={view.data} />
)}
{activeWorkspace && (
<BranchTaskControl task={view.data} workspace={activeWorkspace} />
)}
<TaskActionsMenu taskId={view.data.id} isCloud={isCloudTask} />
</Flex>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<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}
workspace={workspace}
open={open}
onOpenChange={setOpen}
/>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -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 = (
<Box
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)"}`}
>
<Flex direction="column" gap="1" className="min-w-0">
<Flex align="center" gap="2">
{icon}
<Text className="font-medium text-[13px]">{label}</Text>
</Flex>
<Text color="gray" className="text-[12px]">
{description}
</Text>
</Flex>
{selected && <CheckIcon className="mt-[2px] shrink-0" />}
</Box>
);

if (disabled && disabledReason) {
return <Tooltip content={disabledReason}>{row}</Tooltip>;
}
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<string | null>(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 (
<GitDialog
open={open}
onOpenChange={onOpenChange}
icon={<GitFork size={14} />}
title="Branch task"
error={error}
buttonLabel="Branch"
isSubmitting={isSubmitting}
onSubmit={handleSubmit}
maxWidth="420px"
>
<Text color="gray" className="text-[13px]">
Start a new task from this moment. It begins with a summary of the
current conversation as context.
</Text>

<Flex direction="column" gap="2">
<BranchModeOption
icon={<ChatCircleText size={14} />}
label="Branch with context"
description="New task starts from a clean tree with the summarised conversation."
selected
/>
<BranchModeOption
icon={<Code size={14} />}
label="Branch with context + code"
description="Also carry over the current code changes."
disabled
disabledReason="Available in a future update"
/>
</Flex>
</GitDialog>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip content={`Branched from "${lineage.parentTaskTitle}"`}>
<button
type="button"
onClick={handleClick}
className="no-drag flex shrink-0 items-center gap-1 rounded-full border border-(--gray-5) bg-(--gray-2) px-2 py-[2px] text-(--gray-11) text-[11px] hover:bg-(--gray-3)"
>
<GitFork size={10} className="shrink-0" />
Branched from {label}
</button>
</Tooltip>
);
}
123 changes: 123 additions & 0 deletions apps/code/src/renderer/features/sessions/service/branchTask.ts
Original file line number Diff line number Diff line change
@@ -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.

<branch-context>
${context}
</branch-context>`;
}

/** `onTaskCreated` fires once the new task exists. */
export async function branchTask(
params: BranchTaskParams,
onTaskCreated: (task: Task) => void,
): Promise<BranchTaskResult> {
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<TaskService>(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 };
}
}
Loading
Loading