@@ -60,6 +66,7 @@ export function TaskGraphDiagram(props: {
y={y + 8}
onOpenPullRequest={props.onOpenPullRequest}
titleOf={titleOf}
+ formatTime={formatTime}
onEdit={() => props.onEditNode(node.key)}
/>
))}
@@ -75,17 +82,20 @@ function TaskGraphDiagramNode(props: {
readonly y: number;
readonly onOpenPullRequest: (url: string) => void;
readonly titleOf: (key: string) => string;
+ /** Renders an upcoming instant, such as when a wait ends. */
+ readonly formatTime: (iso: string) => string;
readonly onEdit: () => void;
}) {
const { node } = props;
const pullRequestUrl = node.pullRequestResult?.url ?? null;
const machine = props.labels.nodeMachineLabel(node);
+ const status = taskGraphNodeStatusText(node, props.formatTime);
const title =
{
if (event.key === "Enter" && event.target === event.currentTarget) props.onEdit();
@@ -114,8 +124,7 @@ function TaskGraphDiagramNode(props: {
- {TASK_GRAPH_NODE_STATUS_LABEL[node.status]} ·{" "}
- {machine === "Auto" ? machine : `on ${machine}`}
+ {status} · {machine === "Auto" ? machine : `on ${machine}`}
{pullRequestUrl !== null ? (
@@ -155,10 +165,12 @@ function TaskGraphNodeDetails(props: {
readonly node: TaskGraphNode;
readonly labels: TaskGraphLabels;
readonly titleOf: (key: string) => string;
+ readonly formatTime: (iso: string) => string;
}) {
const { node } = props;
const pullRequestError =
node.pullRequestResult?.status === "failed" ? node.pullRequestResult.error : null;
+ const wait = taskGraphNodeWaitDetail(node, props.formatTime);
return (
@@ -177,6 +189,7 @@ function TaskGraphNodeDetails(props: {
{node.prompt}
+ {wait !== null ?
{wait} : null}
{node.dependsOn.length > 0 ? (
{node.dependsOn.map(props.titleOf).join(", ")}
diff --git a/apps/web/src/components/chat/TaskGraphEditor.tsx b/apps/web/src/components/chat/TaskGraphEditor.tsx
index 5bff9e466697..1fa6dbcf1443 100644
--- a/apps/web/src/components/chat/TaskGraphEditor.tsx
+++ b/apps/web/src/components/chat/TaskGraphEditor.tsx
@@ -10,9 +10,15 @@ import type {
} from "@t3tools/contracts";
import {
isActiveTaskGraphNodeStatus,
+ isUnstartedTaskGraphNode,
isTerminalTaskGraphNodeStatus,
taskGraphNodeOpensPullRequest,
} from "@t3tools/shared/taskGraph";
+import {
+ localSnoozeDate,
+ localSnoozeTime,
+ resolveCustomSnooze,
+} from "@t3tools/client-runtime/state/thread-settled";
import { Link } from "@tanstack/react-router";
import {
Background,
@@ -24,6 +30,7 @@ import {
type NodeProps,
} from "@xyflow/react";
import {
+ CalendarIcon,
MessageSquareTextIcon,
PlayIcon,
PlusIcon,
@@ -37,12 +44,16 @@ import { useEffect, useId, useMemo, useState } from "react";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
+import { usePrimarySettings } from "../../hooks/useSettings";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import { NO_PROVIDER_MODEL_SELECTION } from "../../providerInstances";
+import { formatUpcomingTimestamp, weekStartsOn } from "../../timestampFormat";
import { Button } from "../ui/button";
+import { Calendar } from "../ui/calendar";
import { DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
+import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { ScrollArea } from "../ui/scroll-area";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Textarea } from "../ui/textarea";
@@ -64,6 +75,7 @@ import {
TASK_GRAPH_NODE_STATUS_LABEL,
TASK_GRAPH_STATUS_LABEL,
taskGraphNodeKeyFromTitle,
+ taskGraphNodeWaitDetail,
taskGraphProgressLabel,
} from "./taskGraphView";
import type { TaskGraphCommands } from "./useTaskGraphCommands";
@@ -106,7 +118,7 @@ function TaskCanvasNode({ data, selected }: NodeProps) {
@@ -178,11 +190,11 @@ export default function TaskGraphEditor(props: {
const canEdit = commands.canEdit && !commands.busy;
const finished = isTaskGraphFinished(graph.status);
- const pendingNode = (key: string) =>
- graph.nodes.find((node) => node.key === key && node.status === "pending");
+ const unstartedNode = (key: string) =>
+ graph.nodes.find((node) => node.key === key && isUnstartedTaskGraphNode(node));
const connect = (connection: Connection) => {
- const dependent = pendingNode(connection.target);
+ const dependent = unstartedNode(connection.target);
if (dependent === undefined || dependent.dependsOn.includes(connection.source)) return;
void commands.edit([
{
@@ -258,7 +270,7 @@ export default function TaskGraphEditor(props: {
minZoom={0.2}
isValidConnection={(connection) =>
connection.source !== connection.target &&
- pendingNode(connection.target) !== undefined
+ unstartedNode(connection.target) !== undefined
}
onConnect={connect}
onNodeClick={(_event, node) => setSelection({ kind: "node", key: node.id })}
@@ -326,6 +338,7 @@ function SelectionPanel(props: {
modelSelection: null,
environmentId: null,
workspace: "worktree",
+ startAt: null,
}}
placement={{ graph, labels: props.labels, dependency: null }}
opensByDefault
@@ -357,6 +370,7 @@ function SelectionPanel(props: {
: { modelSelection: values.modelSelection }),
...(values.environmentId === null ? {} : { environmentId: values.environmentId }),
...(values.workspace === "worktree" ? {} : { workspace: values.workspace }),
+ ...(values.startAt === null ? {} : { startAt: values.startAt }),
},
},
]);
@@ -377,7 +391,7 @@ function SelectionPanel(props: {
{dependent.title} starts after{" "}
{dependency.title} succeeds.
- {dependent.status === "pending" ? (
+ {isUnstartedTaskGraphNode(dependent) ? (
+
{props.onCancel ? (
);
}
From d4c8878eb51bf51c4deb8cad46644ff5a7c2bfdc Mon Sep 17 00:00:00 2001
From: Astrid Gealer
Date: Sat, 10 Oct 2026 18:47:55 +0000
Subject: [PATCH 14/25] feat(web): draft a task graph on a new chat without
creating a thread
The graph button on a new chat used to create an empty thread straight
away. It now opens the editor on a draft held in the client, checked by
the same edit rules as the server. Run creates the thread and the graph
together and opens the thread; closing the editor keeps the draft while
the new chat stays open, and Discard draft resets it.
Co-Authored-By: Claude Opus 5.5
---
apps/web/src/components/ChatView.tsx | 144 ++++++++++--------
.../web/src/components/chat/TaskGraphCard.tsx | 75 ++++++++-
.../components/chat/useTaskGraphCommands.ts | 124 +++++++++++----
3 files changed, 255 insertions(+), 88 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 1c115b1c45af..04b1ba1a6755 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -83,6 +83,7 @@ import {
resolveEnvironmentMachineKind,
RuntimeMode,
TerminalOpenInput,
+ type TaskGraphNode,
type WorktreeSetupSnapshot,
} from "@t3tools/contracts";
import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
@@ -485,7 +486,7 @@ import {
type QueuedRunsControlHandle,
type EditQueuedRunRequest,
} from "./chat/QueuedRunsControl";
-import { TaskGraphCards } from "./chat/TaskGraphCard";
+import { DraftTaskGraphDialog, TaskGraphCards } from "./chat/TaskGraphCard";
import { useNewTaskGraph } from "./chat/useTaskGraphCommands";
import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";
import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
@@ -10681,70 +10682,81 @@ export default function ChatView(props: ChatViewProps) {
]);
const newTaskGraph = useNewTaskGraph(environmentId);
- // A graph hangs off a server thread. On a new chat there is none yet, so
- // this creates an empty thread for the graph and opens it.
+ // On a new chat the graph is drafted in the client; its thread and the
+ // graph are only created when the user runs it.
+ const [draftTaskGraphOpen, setDraftTaskGraphOpen] = useState(false);
+ // The composer's model when the draft opened: shown as the graph default and used on Run.
+ const [draftTaskGraphModel, setDraftTaskGraphModel] = useState(null);
const onNewTaskGraph = useCallback(async () => {
if (!activeThread) return;
- if (isServerThread) {
- const error = await newTaskGraph.start(activeThread.id);
- if (error !== null) {
- toastManager.add({
- type: "error",
- title: "Could not start a task graph",
- description: error,
- });
- }
+ if (!isServerThread) {
+ setDraftTaskGraphModel(composerRef.current?.getSendContext()?.selectedModelSelection ?? null);
+ setDraftTaskGraphOpen(true);
return;
}
- const modelSelection = composerRef.current?.getSendContext()?.selectedModelSelection;
- if (!activeProject || !modelSelection) return;
- const nextThreadId = newThreadId();
- const createResult = await createThread({
- environmentId,
- input: {
- threadId: nextThreadId,
- projectId: activeProject.id,
- title: "New task graph",
- modelSelection,
- runtimeMode,
- interactionMode: "default",
- branch: activeThreadBranch,
- worktreePath: activeThread.worktreePath,
- createdAt: new Date().toISOString(),
- },
- });
- let error: string | null = null;
- if (createResult._tag === "Failure") {
- const failure = squashAtomCommandFailure(createResult);
- error = failure instanceof Error ? failure.message : "Could not create the thread.";
- } else {
- const threadRef = scopeThreadRef(environmentId, nextThreadId);
- error =
- (await newTaskGraph.start(nextThreadId)) ??
- ((await waitForServerThreadShell(threadRef)) ? null : "The new thread did not load.");
- if (error === null) {
- await navigate({
- to: "/$environmentId/$threadId",
- params: { environmentId, threadId: nextThreadId },
- });
- return;
- }
- await deleteThread({ environmentId, input: { threadId: nextThreadId } });
+ const error = await newTaskGraph.start(activeThread.id);
+ if (error !== null) {
+ toastManager.add({
+ type: "error",
+ title: "Could not start a task graph",
+ description: error,
+ });
}
- toastManager.add({ type: "error", title: "Could not start a task graph", description: error });
- }, [
- activeProject,
- activeThread,
- activeThreadBranch,
- composerRef,
- createThread,
- deleteThread,
- environmentId,
- isServerThread,
- navigate,
- newTaskGraph,
- runtimeMode,
- ]);
+ }, [activeThread, composerRef, isServerThread, newTaskGraph]);
+ const onRunDraftTaskGraph = useCallback(
+ async (nodes: ReadonlyArray): Promise => {
+ const modelSelection = draftTaskGraphModel;
+ if (!activeThread || !activeProject || !modelSelection) {
+ return "Pick a project and model before running the graph.";
+ }
+ const nextThreadId = newThreadId();
+ const createResult = await createThread({
+ environmentId,
+ input: {
+ threadId: nextThreadId,
+ projectId: activeProject.id,
+ title: "New task graph",
+ modelSelection,
+ runtimeMode,
+ interactionMode: "default",
+ branch: activeThreadBranch,
+ worktreePath: activeThread.worktreePath,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ if (createResult._tag === "Failure") {
+ const failure = squashAtomCommandFailure(createResult);
+ return failure instanceof Error ? failure.message : "Could not create the thread.";
+ }
+ const error =
+ (await newTaskGraph.createAndRun(nextThreadId, nodes)) ??
+ ((await waitForServerThreadShell(scopeThreadRef(environmentId, nextThreadId)))
+ ? null
+ : "The new thread did not load.");
+ if (error !== null) {
+ await deleteThread({ environmentId, input: { threadId: nextThreadId } });
+ return error;
+ }
+ setDraftTaskGraphOpen(false);
+ await navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId, threadId: nextThreadId },
+ });
+ return null;
+ },
+ [
+ activeProject,
+ activeThread,
+ activeThreadBranch,
+ createThread,
+ deleteThread,
+ draftTaskGraphModel,
+ environmentId,
+ navigate,
+ newTaskGraph,
+ runtimeMode,
+ ],
+ );
const getModelDisabledReason = useCallback(
(instanceId: ProviderInstanceId, model: string): string | null => {
@@ -12149,6 +12161,18 @@ export default function ChatView(props: ChatViewProps) {
+ {!isServerThread && activeThread && activeProject ? (
+
+ ) : null}
{expandedImage && (
);
}
+
+/**
+ * The editor for a graph drafted on a new chat. Nothing reaches the server
+ * until Run, which creates the chat's thread and the graph together; closing
+ * keeps the draft for as long as the new chat stays open.
+ */
+export function DraftTaskGraphDialog(props: {
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+ readonly threadId: ThreadId;
+ readonly baseRef: string | null;
+ readonly modelSelection: ModelSelection | null;
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly onRun: (nodes: ReadonlyArray) => Promise;
+}) {
+ const draft = useDraftTaskGraph({
+ canRun: true,
+ onRun: props.onRun,
+ onDiscard: () => props.onOpenChange(false),
+ });
+ const [createdAt] = useState(() => new Date().toISOString());
+ const graph: TaskGraph = {
+ id: TaskGraphId.make("draft"),
+ projectId: props.projectId,
+ threadId: props.threadId,
+ title: "New task graph",
+ baseRef: props.baseRef ?? "HEAD",
+ modelSelection: props.modelSelection,
+ status: "draft",
+ nodes: draft.nodes,
+ createdAt,
+ updatedAt: createdAt,
+ };
+ const labels = useTaskGraphLabels(props.environmentId, graph);
+ return (
+
+ }
+ >
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/chat/useTaskGraphCommands.ts b/apps/web/src/components/chat/useTaskGraphCommands.ts
index 5921bdcf75b1..ba438f989ac3 100644
--- a/apps/web/src/components/chat/useTaskGraphCommands.ts
+++ b/apps/web/src/components/chat/useTaskGraphCommands.ts
@@ -9,9 +9,11 @@ import {
type EnvironmentId,
type TaskGraph,
type TaskGraphEdit,
+ type TaskGraphNode,
+ type TaskGraphNodeInput,
type ThreadId,
} from "@t3tools/contracts";
-import { applyTaskGraphEdits } from "@t3tools/shared/taskGraph";
+import { applyTaskGraphEdits, newTaskGraphNode } from "@t3tools/shared/taskGraph";
import { useState } from "react";
import { create } from "zustand";
@@ -75,8 +77,13 @@ export function useTaskGraphCommands(environmentId: EnvironmentId, graph: TaskGr
export type TaskGraphCommands = ReturnType
;
-/** The first task of a graph started from the composer; the editor opens on it. */
-const FIRST_TASK_KEY = "task-1";
+/** The task a graph started from the composer begins with; the editor opens on it. */
+const FIRST_TASK: TaskGraphNodeInput = {
+ key: "task-1",
+ title: "New task",
+ prompt: "Describe what this task should do.",
+ dependsOn: [],
+};
/**
* A graph whose editor should open as soon as its card mounts. Set when the
@@ -95,38 +102,103 @@ export const useTaskGraphEditorRequest = create<{
}));
/**
- * Starts an empty draft graph on a thread and opens its editor, for the
- * composer's New task graph button. Resolves to an error message or null.
+ * Creates graphs from the composer. `start` puts an empty draft on an
+ * existing thread and opens its editor; `create` sends a finished graph.
+ * Both resolve to an error message or null.
*/
export function useNewTaskGraph(environmentId: EnvironmentId) {
const canCreate = useAtomValue(serverEnvironment.createTaskGraph.permissionAtom(environmentId));
const createGraph = useAtomCommand(serverEnvironment.createTaskGraph, "task graph create");
const request = useTaskGraphEditorRequest((state) => state.request);
- const start = async (threadId: ThreadId): Promise => {
+ const send = async (
+ threadId: ThreadId,
+ nodes: ReadonlyArray,
+ run: boolean,
+ ): Promise<{ readonly graphId: string } | { readonly error: string | null }> => {
if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
- return "You don't have permission to start task graphs here.";
+ return { error: "You don't have permission to start task graphs here." };
}
const result = await createGraph({
environmentId,
- input: {
- threadId,
- title: "New task graph",
- nodes: [
- {
- key: FIRST_TASK_KEY,
- title: "New task",
- prompt: "Describe what this task should do.",
- dependsOn: [],
- },
- ],
- run: false,
- },
+ input: { threadId, title: "New task graph", nodes, run },
});
- if (result._tag === "Success") {
- request(result.value.graph.id, FIRST_TASK_KEY);
- return null;
- }
- return failureMessage(result);
+ return result._tag === "Success"
+ ? { graphId: result.value.graph.id }
+ : { error: failureMessage(result) };
+ };
+ const start = async (threadId: ThreadId): Promise => {
+ const result = await send(threadId, [FIRST_TASK], false);
+ if ("error" in result) return result.error;
+ request(result.graphId, FIRST_TASK.key);
+ return null;
+ };
+ const createAndRun = async (
+ threadId: ThreadId,
+ nodes: ReadonlyArray,
+ ): Promise => {
+ const result = await send(threadId, nodes.map(taskGraphNodeInput), true);
+ return "error" in result ? result.error : null;
+ };
+ return { canCreate, start, createAndRun };
+}
+
+function taskGraphNodeInput(node: TaskGraphNode): TaskGraphNodeInput {
+ return {
+ key: node.key,
+ title: node.title,
+ prompt: node.prompt,
+ dependsOn: node.dependsOn,
+ ...(node.pullRequest === null ? {} : { pullRequest: node.pullRequest }),
+ ...(node.modelSelection === null ? {} : { modelSelection: node.modelSelection }),
+ environmentId: node.environmentId,
+ workspace: node.workspace,
+ startAt: node.startAt,
+ };
+}
+
+/**
+ * Commands for a graph drafted on a new chat, before its thread exists. Edits
+ * stay in the client, checked by the same rules the server applies; `run`
+ * hands the nodes to `onRun`, which creates the thread and the graph together.
+ */
+export function useDraftTaskGraph(input: {
+ readonly canRun: boolean;
+ readonly onRun: (nodes: ReadonlyArray) => Promise;
+ readonly onDiscard: () => void;
+}) {
+ const [nodes, setNodes] = useState>(() => [
+ newTaskGraphNode(FIRST_TASK),
+ ]);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const commands: TaskGraphCommands = {
+ canEdit: true,
+ canRun: input.canRun,
+ canCancel: true,
+ busy,
+ error,
+ clearError: () => setError(null),
+ edit: (edits) => {
+ const checked = applyTaskGraphEdits(nodes, edits, new Date().toISOString());
+ setError(checked.ok ? null : checked.error);
+ if (checked.ok) setNodes(checked.nodes);
+ return Promise.resolve(checked.ok);
+ },
+ run: async () => {
+ if (busy) return false;
+ setBusy(true);
+ setError(null);
+ const message = await input.onRun(nodes);
+ setBusy(false);
+ setError(message);
+ return message === null;
+ },
+ cancel: () => {
+ setNodes([newTaskGraphNode(FIRST_TASK)]);
+ setError(null);
+ input.onDiscard();
+ return Promise.resolve(true);
+ },
};
- return { canCreate, start };
+ return { nodes, commands, firstNodeKey: FIRST_TASK.key };
}
From f5c6e5f654cc6e84a5082981290f1a71bac88520 Mon Sep 17 00:00:00 2001
From: Astrid Gealer
Date: Sat, 10 Oct 2026 19:56:12 +0000
Subject: [PATCH 15/25] feat(web): Add task puts a task on the graph straight
away
Add task used to open a side form and only drew the task once it was
submitted. It now adds a "New task" to the graph at once, after the
selected task or as the parent of the unstarted starting tasks, and
selects it for editing like any other task. Also hides the React Flow
attribution on the canvas.
Co-Authored-By: Claude Opus 5.5
---
.../src/components/chat/TaskGraphEditor.css | 1 -
.../src/components/chat/TaskGraphEditor.tsx | 145 +++++-------------
2 files changed, 38 insertions(+), 108 deletions(-)
diff --git a/apps/web/src/components/chat/TaskGraphEditor.css b/apps/web/src/components/chat/TaskGraphEditor.css
index dd67dba72a6f..fa980dbcb4b7 100644
--- a/apps/web/src/components/chat/TaskGraphEditor.css
+++ b/apps/web/src/components/chat/TaskGraphEditor.css
@@ -10,5 +10,4 @@
--xy-connectionline-stroke: var(--ring);
--xy-handle-background-color: var(--muted-foreground);
--xy-background-pattern-dots-color: var(--border);
- --xy-attribution-background-color: transparent;
}
diff --git a/apps/web/src/components/chat/TaskGraphEditor.tsx b/apps/web/src/components/chat/TaskGraphEditor.tsx
index 25c815b5f1e9..b6fd5bebc276 100644
--- a/apps/web/src/components/chat/TaskGraphEditor.tsx
+++ b/apps/web/src/components/chat/TaskGraphEditor.tsx
@@ -84,10 +84,7 @@ import { TASK_GRAPH_PEER_STATUS_HINT, type TaskGraphLabels } from "./useTaskGrap
type Selection =
| { readonly kind: "none" }
| { readonly kind: "node"; readonly key: string }
- | { readonly kind: "edge"; readonly dependency: string; readonly dependent: string }
- // `after` is the task selected when Add task was clicked; the new task branches off it.
- // With nothing selected, the new task becomes the parent of the current top tasks.
- | { readonly kind: "new"; readonly after: string | null };
+ | { readonly kind: "edge"; readonly dependency: string; readonly dependent: string };
const NO_SELECTION: Selection = { kind: "none" };
@@ -183,8 +180,7 @@ export default function TaskGraphEditor(props: {
const layout = graphLayout(graph.nodes);
const selection = resolveSelection(selectionState, graph);
const { nodes, edges } = taskGraphCanvasElements(graph.nodes, layout.positions, {
- nodeKey:
- selection.kind === "node" ? selection.key : selection.kind === "new" ? selection.after : null,
+ nodeKey: selection.kind === "node" ? selection.key : null,
edgeId:
selection.kind === "edge"
? taskGraphCanvasEdgeId(selection.dependency, selection.dependent)
@@ -208,6 +204,40 @@ export default function TaskGraphEditor(props: {
]);
};
+ // Adds a placeholder task to the graph and selects it for editing: after the
+ // selected task, or with nothing selected, as the parent of the unstarted
+ // tasks at the start (a started task can no longer be made to wait).
+ const addTask = async () => {
+ const after = selection.kind === "node" ? selection.key : null;
+ const key = taskGraphNodeKeyFromTitle(
+ "New task",
+ graph.nodes.map((node) => node.key),
+ );
+ const children =
+ after === null
+ ? graph.nodes.filter(
+ (node) => node.dependsOn.length === 0 && isUnstartedTaskGraphNode(node),
+ )
+ : [];
+ const accepted = await commands.edit([
+ {
+ type: "add_node",
+ node: {
+ key,
+ title: "New task",
+ prompt: "Describe what this task should do.",
+ dependsOn: after === null ? [] : [after],
+ },
+ },
+ ...children.map((child) => ({
+ type: "update_node" as const,
+ key: child.key,
+ dependsOn: [key],
+ })),
+ ]);
+ if (accepted) setSelection({ kind: "node", key });
+ };
+
return (
<>
@@ -220,17 +250,7 @@ export default function TaskGraphEditor(props: {
@@ -815,11 +751,6 @@ function TaskNodeForm(props: {
onChange={setStart}
/>
- {props.onCancel ? (
-
- Cancel
-
- ) : null}
{props.submitLabel}
From 81d81d6349d41391e909e50bd9b7034bafcdd651 Mon Sep 17 00:00:00 2001
From: Astrid Gealer
Date: Sat, 10 Oct 2026 20:17:19 +0000
Subject: [PATCH 16/25] feat(web): continue the parent's worktree and reconcile
joins across machines
A task added after a selected task now continues that task's worktree by
default, when it has one and no other task continues it already.
A task that merges branches known to be on different machines gets a
"Bring branches together on" choice: one dependency's machine, continuing
its worktree there and pulling the other branches in through origin, or any
machine in a new worktree. It only shows when the joined branches are on
two or more known machines.
Co-Authored-By: Claude Opus 5.5
---
.../src/components/chat/TaskGraphEditor.tsx | 105 +++++++++++++++++-
.../src/components/chat/taskGraphView.test.ts | 51 +++++++++
apps/web/src/components/chat/taskGraphView.ts | 49 ++++++++
docs/user/task-graphs.md | 16 ++-
4 files changed, 212 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/components/chat/TaskGraphEditor.tsx b/apps/web/src/components/chat/TaskGraphEditor.tsx
index b6fd5bebc276..929009303f6c 100644
--- a/apps/web/src/components/chat/TaskGraphEditor.tsx
+++ b/apps/web/src/components/chat/TaskGraphEditor.tsx
@@ -69,11 +69,14 @@ import {
type TaskGraphCanvasPoint,
} from "./taskGraphCanvasLayout";
import {
+ canContinueTaskGraphWorktree,
isTaskGraphFinished,
pullRequestLabel,
TASK_GRAPH_NODE_STATUS_DOT_CLASS,
TASK_GRAPH_NODE_STATUS_LABEL,
TASK_GRAPH_STATUS_LABEL,
+ type TaskGraphJoinMachine,
+ taskGraphJoinMachines,
taskGraphNodeKeyFromTitle,
taskGraphNodeWaitDetail,
taskGraphProgressLabel,
@@ -205,10 +208,12 @@ export default function TaskGraphEditor(props: {
};
// Adds a placeholder task to the graph and selects it for editing: after the
- // selected task, or with nothing selected, as the parent of the unstarted
- // tasks at the start (a started task can no longer be made to wait).
+ // selected task, continuing its worktree where it can, or with nothing
+ // selected, as the parent of the unstarted tasks at the start (a started
+ // task can no longer be made to wait).
const addTask = async () => {
const after = selection.kind === "node" ? selection.key : null;
+ const parent = graph.nodes.find((node) => node.key === after);
const key = taskGraphNodeKeyFromTitle(
"New task",
graph.nodes.map((node) => node.key),
@@ -227,6 +232,9 @@ export default function TaskGraphEditor(props: {
title: "New task",
prompt: "Describe what this task should do.",
dependsOn: after === null ? [] : [after],
+ ...(parent !== undefined && canContinueTaskGraphWorktree(graph.nodes, parent)
+ ? { workspace: "dependency" as const }
+ : {}),
},
},
...children.map((child) => ({
@@ -418,8 +426,66 @@ function SelectionPanel(props: {
Tasks that nothing depends on open a pull request unless you turn it off.
Select a task or dependency to change it.
- Add task puts the new task after the selected one. With nothing selected, it becomes the
- parent of the tasks at the start.
+ Add task puts the new task after the selected one, continuing its worktree. With nothing
+ selected, it becomes the parent of the tasks at the start.
+
+
+ );
+}
+
+const JOIN_AUTO = "auto";
+
+/**
+ * Where a task that merges branches from several machines brings them
+ * together: on one dependency's machine, continuing its worktree and pulling
+ * the other branches in through origin, or in a new worktree wherever load
+ * balancing puts it.
+ */
+function TaskJoinMachineField(props: {
+ readonly node: TaskGraphNode;
+ readonly options: ReadonlyArray
;
+ readonly labels: TaskGraphLabels;
+ readonly disabled: boolean;
+ readonly onChoose: (dependencyKey: string | null) => void;
+}) {
+ const id = useId();
+ const current =
+ props.node.workspace === "dependency" ? (props.node.dependsOn[0] ?? JOIN_AUTO) : JOIN_AUTO;
+ const optionLabel = (option: TaskGraphJoinMachine) =>
+ `${props.labels.machineLabel(option.machine)}, continuing ${option.dependency.title}`;
+ const chosen = props.options.find((option) => option.dependency.key === current);
+ return (
+
+
+
+
+ The branches this task merges are on different machines. The other branches are pushed and
+ pulled in through origin.
);
@@ -480,11 +546,42 @@ function NodePanel(props: {
);
+ const joinMachines = taskGraphJoinMachines(graph.nodes, node);
+
if (isUnstartedTaskGraphNode(node)) {
return (