@saadnvd1/agent-os 0.3.824 → 0.3.826
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/fork/cancel/route.ts +19 -0
- package/app/api/fork/children/route.ts +48 -0
- package/app/api/fork/result/route.ts +29 -0
- package/app/api/fork/return/route.ts +20 -0
- package/app/api/fork/route.ts +32 -0
- package/app/api/fork/wait/route.ts +27 -0
- package/app/api/load/heavy/lease/route.ts +26 -0
- package/app/api/sessions/[id]/archive/route.ts +17 -4
- package/app/api/sessions/[id]/route.ts +2 -3
- package/app/page.tsx +0 -5
- package/components/Chat/ChatPanel.tsx +33 -206
- package/components/Chat/Forks.tsx +278 -0
- package/components/Chat/ReplyingTo.tsx +24 -0
- package/components/Chat/TimelineRows.tsx +194 -0
- package/components/Pane/DesktopTabBar.tsx +0 -6
- package/components/Pane/MobileTabBar.tsx +0 -6
- package/components/Pane/MobileViewPicker.test.ts +1 -8
- package/components/Pane/MobileViewPicker.tsx +0 -11
- package/components/Pane/PaneViewToggle.test.ts +0 -2
- package/components/Pane/PaneViewToggle.tsx +0 -17
- package/components/Pane/index.tsx +0 -50
- package/components/SessionList/useDoneAction.ts +18 -3
- package/contexts/PaneContext.tsx +1 -1
- package/data/done/index.ts +26 -2
- package/data/fork/index.ts +83 -0
- package/lib/chat/commands.ts +15 -0
- package/lib/chat/runner.ts +8 -2
- package/lib/chat/worker/extras.ts +7 -1
- package/lib/chat/worker/host.ts +2 -0
- package/lib/config/client-config-types.ts +7 -0
- package/lib/config/client-config.ts +2 -0
- package/lib/db/migrations/20261102T0100_forks.ts +87 -0
- package/lib/done/archive.ts +21 -1
- package/lib/experiments/registry.ts +8 -0
- package/lib/fork/api.ts +10 -0
- package/lib/fork/brief.ts +63 -0
- package/lib/fork/conversation.ts +75 -0
- package/lib/fork/deliver.ts +91 -0
- package/lib/fork/extras.ts +53 -0
- package/lib/fork/guard.ts +323 -0
- package/lib/fork/heavy-lease.ts +104 -0
- package/lib/fork/held.ts +26 -0
- package/lib/fork/ledger.ts +324 -0
- package/lib/fork/options.ts +46 -0
- package/lib/fork/point.ts +41 -0
- package/lib/fork/scratch.ts +67 -0
- package/lib/fork/settle.ts +215 -0
- package/lib/fork/snapshot.ts +69 -0
- package/lib/fork/start.ts +273 -0
- package/lib/fork/tick.ts +54 -0
- package/lib/fork/types.ts +79 -0
- package/lib/fork/view.ts +116 -0
- package/lib/fork/waits.ts +83 -0
- package/lib/fork/worktree.ts +115 -0
- package/lib/orchestrator/shell-guard.ts +6 -3
- package/lib/orchestrator/write-guard.ts +1 -1
- package/lib/security/demo.ts +1 -2
- package/lib/sessions/end.ts +5 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/app/api/orchestrate/spawn/route.ts +0 -54
- package/app/api/orchestrate/workers/[id]/route.ts +0 -89
- package/app/api/orchestrate/workers/route.ts +0 -31
- package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
- package/components/ConductorPanel.tsx +0 -258
- package/lib/mcp-config.ts +0 -80
- package/lib/orchestration.ts +0 -463
- package/mcp/orchestration-server.ts +0 -441
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { GitFork, Plus, Square } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { useExperiment } from "@/data/experiments";
|
|
7
|
+
import { useClientConfig } from "@/data/client-config/queries";
|
|
8
|
+
import {
|
|
9
|
+
useCancelForks,
|
|
10
|
+
useForkChildren,
|
|
11
|
+
useStartForks,
|
|
12
|
+
type ChildView,
|
|
13
|
+
} from "@/data/fork";
|
|
14
|
+
import { Button } from "@/components/ui/button";
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogDescription,
|
|
19
|
+
DialogFooter,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
} from "@/components/ui/dialog";
|
|
23
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
24
|
+
import { Switch } from "@/components/ui/switch";
|
|
25
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
26
|
+
import { SessionAnchor } from "./SessionAnchor";
|
|
27
|
+
|
|
28
|
+
type Tone = "amber" | "muted" | "green" | "primary" | "red";
|
|
29
|
+
|
|
30
|
+
export function stateLabel(c: ChildView): { text: string; tone: Tone } {
|
|
31
|
+
if (c.state === "queued") return { text: "Waiting", tone: "muted" };
|
|
32
|
+
if (c.state === "starting") return { text: "Starting", tone: "muted" };
|
|
33
|
+
if (c.state === "running") return { text: "Working", tone: "primary" };
|
|
34
|
+
if (c.state === "needs_input") return { text: "Needs you", tone: "amber" };
|
|
35
|
+
if (c.outcome === "failed") return { text: "Failed", tone: "red" };
|
|
36
|
+
if (c.outcome === "cancelled") return { text: "Stopped", tone: "muted" };
|
|
37
|
+
return { text: "Done", tone: "green" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Row({
|
|
41
|
+
c,
|
|
42
|
+
onStop,
|
|
43
|
+
stopping,
|
|
44
|
+
}: {
|
|
45
|
+
c: ChildView;
|
|
46
|
+
onStop: () => void;
|
|
47
|
+
stopping: boolean;
|
|
48
|
+
}) {
|
|
49
|
+
const { text, tone } = stateLabel(c);
|
|
50
|
+
const open = c.state !== "settled";
|
|
51
|
+
const note =
|
|
52
|
+
c.summary ??
|
|
53
|
+
(c.asks
|
|
54
|
+
? `Asks: ${c.asks}`
|
|
55
|
+
: c.state === "queued"
|
|
56
|
+
? "Waiting for room"
|
|
57
|
+
: null);
|
|
58
|
+
return (
|
|
59
|
+
<li className="flex min-h-11 items-center gap-2 px-3 py-1.5">
|
|
60
|
+
<StatusPill tone={tone} className="shrink-0">
|
|
61
|
+
{text}
|
|
62
|
+
</StatusPill>
|
|
63
|
+
<div className="min-w-0 flex-1">
|
|
64
|
+
<p className="truncate text-sm font-medium">
|
|
65
|
+
{c.n}. {c.label.replace(/^\d+\.\s*/, "")}
|
|
66
|
+
</p>
|
|
67
|
+
{note && (
|
|
68
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
69
|
+
{note}
|
|
70
|
+
{c.resultKind === "implicit" && c.outcome === "ok"
|
|
71
|
+
? " · ended without reporting"
|
|
72
|
+
: ""}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
<SessionAnchor
|
|
77
|
+
sessionId={c.id}
|
|
78
|
+
className="text-primary flex min-h-11 shrink-0 items-center px-2 text-sm"
|
|
79
|
+
>
|
|
80
|
+
Open
|
|
81
|
+
</SessionAnchor>
|
|
82
|
+
{open && (
|
|
83
|
+
<Button
|
|
84
|
+
size="sm"
|
|
85
|
+
variant="ghost"
|
|
86
|
+
aria-label={`Stop fork ${c.n}`}
|
|
87
|
+
className="size-11 shrink-0"
|
|
88
|
+
disabled={stopping}
|
|
89
|
+
onClick={onStop}
|
|
90
|
+
>
|
|
91
|
+
<Square aria-hidden className="size-4" />
|
|
92
|
+
</Button>
|
|
93
|
+
)}
|
|
94
|
+
</li>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Above the composer: this session's forks and where each stands.
|
|
99
|
+
export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
100
|
+
const on = useExperiment("forks");
|
|
101
|
+
const { data } = useForkChildren(sessionId, on);
|
|
102
|
+
const cancel = useCancelForks(sessionId);
|
|
103
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
104
|
+
if (!on || !data) return null;
|
|
105
|
+
if (data.asChild) return <ForkOfBanner info={data.asChild} />;
|
|
106
|
+
const { children } = data;
|
|
107
|
+
if (!children.length) return null;
|
|
108
|
+
const open = children.filter((c) => c.state !== "settled").length;
|
|
109
|
+
const stop = (ref?: string) =>
|
|
110
|
+
cancel.mutate(ref ? { ref } : { all: true }, {
|
|
111
|
+
onError: (e) => toast.error(e.message),
|
|
112
|
+
});
|
|
113
|
+
return (
|
|
114
|
+
<section
|
|
115
|
+
aria-label="Forks"
|
|
116
|
+
className="bg-card popover-surface mb-2 overflow-hidden rounded-2xl"
|
|
117
|
+
>
|
|
118
|
+
<div className="flex min-h-11 items-center gap-2 px-3">
|
|
119
|
+
<GitFork
|
|
120
|
+
aria-hidden
|
|
121
|
+
className="text-muted-foreground size-4 shrink-0"
|
|
122
|
+
/>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
aria-expanded={!collapsed}
|
|
126
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
127
|
+
className="min-h-11 min-w-0 flex-1 text-left text-sm font-medium"
|
|
128
|
+
>
|
|
129
|
+
Forks
|
|
130
|
+
<span className="text-muted-foreground ml-2 font-normal">
|
|
131
|
+
{open ? `${open} working` : "all done"} · {children.length} total
|
|
132
|
+
</span>
|
|
133
|
+
</button>
|
|
134
|
+
{open > 0 && (
|
|
135
|
+
<Button
|
|
136
|
+
size="sm"
|
|
137
|
+
variant="ghost"
|
|
138
|
+
className="min-h-11"
|
|
139
|
+
disabled={cancel.isPending}
|
|
140
|
+
onClick={() => stop()}
|
|
141
|
+
>
|
|
142
|
+
Stop all
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
{!collapsed && (
|
|
147
|
+
<ul className="border-border max-h-60 divide-y overflow-y-auto border-t">
|
|
148
|
+
{children.map((c) => (
|
|
149
|
+
<Row
|
|
150
|
+
key={c.id}
|
|
151
|
+
c={c}
|
|
152
|
+
stopping={cancel.isPending}
|
|
153
|
+
onStop={() => stop(String(c.n))}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</ul>
|
|
157
|
+
)}
|
|
158
|
+
</section>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ForkOfBanner({
|
|
163
|
+
info,
|
|
164
|
+
}: {
|
|
165
|
+
info: NonNullable<
|
|
166
|
+
NonNullable<ReturnType<typeof useForkChildren>["data"]>["asChild"]
|
|
167
|
+
>;
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<section
|
|
171
|
+
aria-label="Fork of"
|
|
172
|
+
className="bg-card popover-surface mb-2 flex min-h-11 items-center gap-2 rounded-2xl px-3 py-1.5"
|
|
173
|
+
>
|
|
174
|
+
<GitFork aria-hidden className="text-muted-foreground size-4 shrink-0" />
|
|
175
|
+
<p className="min-w-0 flex-1 text-sm">
|
|
176
|
+
Fork {info.n} of{" "}
|
|
177
|
+
<SessionAnchor sessionId={info.parentId} className="text-primary">
|
|
178
|
+
{info.parentName}
|
|
179
|
+
</SessionAnchor>
|
|
180
|
+
<span className="text-muted-foreground block text-xs">
|
|
181
|
+
{info.worktree
|
|
182
|
+
? "Works in its own checkout. "
|
|
183
|
+
: "Reads the parent's checkout, doesn't change it. "}
|
|
184
|
+
Finishes with a result for the parent.
|
|
185
|
+
</span>
|
|
186
|
+
</p>
|
|
187
|
+
</section>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// "/fork": instructions for N copies of this session.
|
|
192
|
+
export function ForkDialog({
|
|
193
|
+
sessionId,
|
|
194
|
+
open,
|
|
195
|
+
onOpenChange,
|
|
196
|
+
}: {
|
|
197
|
+
sessionId: string;
|
|
198
|
+
open: boolean;
|
|
199
|
+
onOpenChange: (open: boolean) => void;
|
|
200
|
+
}) {
|
|
201
|
+
const start = useStartForks(sessionId);
|
|
202
|
+
const max = useClientConfig().data?.forks?.maxPerFork ?? 8;
|
|
203
|
+
const [texts, setTexts] = useState<string[]>([""]);
|
|
204
|
+
const [worktree, setWorktree] = useState(false);
|
|
205
|
+
const filled = texts.map((t) => t.trim()).filter(Boolean);
|
|
206
|
+
const submit = () =>
|
|
207
|
+
start.mutate(
|
|
208
|
+
{ instructions: filled, worktree },
|
|
209
|
+
{
|
|
210
|
+
onSuccess: (r) => {
|
|
211
|
+
toast.success(
|
|
212
|
+
`Started ${r.children.length} ${r.children.length === 1 ? "fork" : "forks"}`
|
|
213
|
+
);
|
|
214
|
+
setTexts([""]);
|
|
215
|
+
setWorktree(false);
|
|
216
|
+
onOpenChange(false);
|
|
217
|
+
},
|
|
218
|
+
onError: (e) => toast.error(e.message),
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
return (
|
|
222
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
223
|
+
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
|
224
|
+
<DialogHeader>
|
|
225
|
+
<DialogTitle>Fork this session</DialogTitle>
|
|
226
|
+
<DialogDescription>
|
|
227
|
+
Each copy knows what this session knows and works in the background.
|
|
228
|
+
You keep going; results are added to your next message.
|
|
229
|
+
</DialogDescription>
|
|
230
|
+
</DialogHeader>
|
|
231
|
+
<div className="space-y-2">
|
|
232
|
+
{texts.map((t, i) => (
|
|
233
|
+
<Textarea
|
|
234
|
+
key={i}
|
|
235
|
+
value={t}
|
|
236
|
+
rows={2}
|
|
237
|
+
aria-label={`Fork ${i + 1}`}
|
|
238
|
+
placeholder={`What should fork ${i + 1} do?`}
|
|
239
|
+
onChange={(e) =>
|
|
240
|
+
setTexts(texts.map((x, j) => (j === i ? e.target.value : x)))
|
|
241
|
+
}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
{texts.length < max && (
|
|
245
|
+
<Button
|
|
246
|
+
type="button"
|
|
247
|
+
variant="ghost"
|
|
248
|
+
size="sm"
|
|
249
|
+
className="min-h-11"
|
|
250
|
+
onClick={() => setTexts([...texts, ""])}
|
|
251
|
+
>
|
|
252
|
+
<Plus aria-hidden className="size-4" /> Another
|
|
253
|
+
</Button>
|
|
254
|
+
)}
|
|
255
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
256
|
+
<span>
|
|
257
|
+
Own checkout
|
|
258
|
+
<span className="text-muted-foreground block text-xs">
|
|
259
|
+
For forks that edit code. Off: they read this checkout and leave
|
|
260
|
+
it alone.
|
|
261
|
+
</span>
|
|
262
|
+
</span>
|
|
263
|
+
<Switch checked={worktree} onCheckedChange={setWorktree} />
|
|
264
|
+
</label>
|
|
265
|
+
</div>
|
|
266
|
+
<DialogFooter>
|
|
267
|
+
<Button
|
|
268
|
+
className="min-h-11"
|
|
269
|
+
disabled={!filled.length || start.isPending}
|
|
270
|
+
onClick={submit}
|
|
271
|
+
>
|
|
272
|
+
{filled.length > 1 ? `Start ${filled.length} forks` : "Start fork"}
|
|
273
|
+
</Button>
|
|
274
|
+
</DialogFooter>
|
|
275
|
+
</DialogContent>
|
|
276
|
+
</Dialog>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Above the composer while it answers a question from the orchestrator's strip.
|
|
4
|
+
export function ReplyingTo({
|
|
5
|
+
text,
|
|
6
|
+
onStop,
|
|
7
|
+
}: {
|
|
8
|
+
text: string;
|
|
9
|
+
onStop: () => void;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="text-muted-foreground mb-2 flex min-h-11 items-center gap-2 rounded-lg px-2 text-xs">
|
|
13
|
+
<span className="min-w-0 flex-1 truncate">Answering: {text}</span>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={onStop}
|
|
17
|
+
aria-label="Stop answering"
|
|
18
|
+
className="hover:text-foreground flex size-11 shrink-0 items-center justify-center"
|
|
19
|
+
>
|
|
20
|
+
×
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
4
|
+
import { Fragment, memo, useMemo } from "react";
|
|
5
|
+
import {
|
|
6
|
+
groupTimeline,
|
|
7
|
+
sameBlock,
|
|
8
|
+
STANDALONE_TOOLS,
|
|
9
|
+
type TimelineBlock,
|
|
10
|
+
} from "@/lib/chat/group";
|
|
11
|
+
import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
|
|
12
|
+
import { Approval } from "./Approval";
|
|
13
|
+
import { SubagentCard } from "./Subagent";
|
|
14
|
+
import { UndoneBlock } from "./Undo";
|
|
15
|
+
import { ToolGroup } from "./Tools";
|
|
16
|
+
import { McpServers } from "./McpServers";
|
|
17
|
+
import { ArtifactCard } from "./Artifact";
|
|
18
|
+
import { PlanCard } from "./PlanCard";
|
|
19
|
+
import { UserTurn } from "./EventRow";
|
|
20
|
+
import { EventGroup } from "./EventTurns";
|
|
21
|
+
import { NewDivider } from "./NewDivider";
|
|
22
|
+
import {
|
|
23
|
+
AssistantMessage,
|
|
24
|
+
CommandOutput,
|
|
25
|
+
Compacted,
|
|
26
|
+
SkillChip,
|
|
27
|
+
ErrorMessage,
|
|
28
|
+
NoteLine,
|
|
29
|
+
Reasoning,
|
|
30
|
+
Todos,
|
|
31
|
+
TurnEnd,
|
|
32
|
+
} from "./Messages";
|
|
33
|
+
|
|
34
|
+
export interface ItemActions {
|
|
35
|
+
respond: (id: string, answer: ApprovalDecision) => void;
|
|
36
|
+
// Absent inside undone messages; hidden while the agent works (ChatIdle).
|
|
37
|
+
onCarryPlan?: (id: string) => void;
|
|
38
|
+
onKeepPlanning?: () => void;
|
|
39
|
+
// Absent inside undone messages; hidden while the agent works (ChatIdle).
|
|
40
|
+
onUndo?: (id: string) => void;
|
|
41
|
+
onQuote?: (text: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Item({
|
|
45
|
+
item,
|
|
46
|
+
actions,
|
|
47
|
+
steps,
|
|
48
|
+
}: {
|
|
49
|
+
item: ChatItem;
|
|
50
|
+
actions: ItemActions;
|
|
51
|
+
steps?: StepCounts;
|
|
52
|
+
}) {
|
|
53
|
+
switch (item.kind) {
|
|
54
|
+
case "user":
|
|
55
|
+
return (
|
|
56
|
+
<UserTurn
|
|
57
|
+
item={item}
|
|
58
|
+
onUndo={
|
|
59
|
+
item.checkpoint && actions.onUndo
|
|
60
|
+
? () => actions.onUndo?.(item.id)
|
|
61
|
+
: undefined
|
|
62
|
+
}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
case "assistant":
|
|
66
|
+
return <AssistantMessage item={item} onQuote={actions.onQuote} />;
|
|
67
|
+
case "reasoning":
|
|
68
|
+
return <Reasoning item={item} />;
|
|
69
|
+
case "todos":
|
|
70
|
+
return <Todos item={item} />;
|
|
71
|
+
case "turn_end":
|
|
72
|
+
return <TurnEnd item={item} steps={steps} />;
|
|
73
|
+
case "error":
|
|
74
|
+
return <ErrorMessage item={item} />;
|
|
75
|
+
case "command_output":
|
|
76
|
+
return <CommandOutput item={item} />;
|
|
77
|
+
case "mcp":
|
|
78
|
+
return <McpServers item={item} />;
|
|
79
|
+
case "compacted":
|
|
80
|
+
return <Compacted item={item} />;
|
|
81
|
+
case "note":
|
|
82
|
+
return <NoteLine item={item} />;
|
|
83
|
+
case "artifact":
|
|
84
|
+
return <ArtifactCard item={item} />;
|
|
85
|
+
case "approval":
|
|
86
|
+
return <Approval item={item} respond={actions.respond} />;
|
|
87
|
+
case "plan":
|
|
88
|
+
return (
|
|
89
|
+
<PlanCard
|
|
90
|
+
item={item}
|
|
91
|
+
onCarryOut={
|
|
92
|
+
actions.onCarryPlan
|
|
93
|
+
? () => actions.onCarryPlan?.(item.id)
|
|
94
|
+
: undefined
|
|
95
|
+
}
|
|
96
|
+
onKeepPlanning={() => actions.onKeepPlanning?.()}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
case "tool":
|
|
100
|
+
return item.name === "Skill" ? (
|
|
101
|
+
<SkillChip item={item} />
|
|
102
|
+
) : STANDALONE_TOOLS.has(item.name) ? (
|
|
103
|
+
<SubagentCard item={item} />
|
|
104
|
+
) : null;
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Timeline({
|
|
111
|
+
blocks,
|
|
112
|
+
actions,
|
|
113
|
+
}: {
|
|
114
|
+
blocks: TimelineBlock[];
|
|
115
|
+
actions: ItemActions;
|
|
116
|
+
}) {
|
|
117
|
+
return blocks.map((b) => (
|
|
118
|
+
<Block
|
|
119
|
+
key={b.type === "item" ? b.item.id : b.id}
|
|
120
|
+
block={b}
|
|
121
|
+
actions={actions}
|
|
122
|
+
/>
|
|
123
|
+
));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const blockKey = (b: TimelineBlock) => (b.type === "item" ? b.item.id : b.id);
|
|
127
|
+
|
|
128
|
+
// One block, re-rendered only when its items or the actions change: a long
|
|
129
|
+
// conversation doesn't redraw every message for each streamed word.
|
|
130
|
+
const Block = memo(
|
|
131
|
+
function Block({
|
|
132
|
+
block: b,
|
|
133
|
+
actions,
|
|
134
|
+
}: {
|
|
135
|
+
block: TimelineBlock;
|
|
136
|
+
actions: ItemActions;
|
|
137
|
+
}) {
|
|
138
|
+
const respond = actions.respond;
|
|
139
|
+
const undoneActions = useMemo(
|
|
140
|
+
() => ({ respond, onQuote: actions.onQuote }),
|
|
141
|
+
[respond, actions.onQuote]
|
|
142
|
+
);
|
|
143
|
+
return b.type === "tools" ? (
|
|
144
|
+
<ToolGroup tools={b.tools} />
|
|
145
|
+
) : b.type === "events" ? (
|
|
146
|
+
<EventGroup
|
|
147
|
+
id={b.id}
|
|
148
|
+
turns={b.turns}
|
|
149
|
+
renderReplies={(items) => (
|
|
150
|
+
<Timeline blocks={groupTimeline(items)} actions={undoneActions} />
|
|
151
|
+
)}
|
|
152
|
+
/>
|
|
153
|
+
) : b.type === "undone" ? (
|
|
154
|
+
<UndoneBlock
|
|
155
|
+
undo={b.undo}
|
|
156
|
+
count={b.items.filter((i) => i.kind === "user").length}
|
|
157
|
+
>
|
|
158
|
+
<Timeline blocks={groupTimeline(b.items)} actions={undoneActions} />
|
|
159
|
+
</UndoneBlock>
|
|
160
|
+
) : (
|
|
161
|
+
<div data-item={b.item.id}>
|
|
162
|
+
<Item item={b.item} actions={actions} steps={b.steps} />
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
(prev, next) =>
|
|
167
|
+
prev.actions === next.actions && sameBlock(prev.block, next.block)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// The conversation's rows in the page's flow, not a virtualized list: a
|
|
171
|
+
// selection follows the DOM, and a list that recycles and re-orders its rows
|
|
172
|
+
// breaks it (docs/decisions/2026-10-11-chat-timeline-in-flow.md).
|
|
173
|
+
export function TimelineRows({
|
|
174
|
+
blocks,
|
|
175
|
+
actions,
|
|
176
|
+
newFrom,
|
|
177
|
+
}: {
|
|
178
|
+
blocks: TimelineBlock[];
|
|
179
|
+
actions: ItemActions;
|
|
180
|
+
// The block the reader hasn't seen yet begins at, when they were away.
|
|
181
|
+
newFrom?: string | null;
|
|
182
|
+
}) {
|
|
183
|
+
return blocks.map((b) => (
|
|
184
|
+
<Fragment key={blockKey(b)}>
|
|
185
|
+
{newFrom === blockKey(b) && <NewDivider />}
|
|
186
|
+
<div
|
|
187
|
+
data-block={blockKey(b)}
|
|
188
|
+
className="chat-row mx-auto w-full max-w-3xl px-4 pb-4"
|
|
189
|
+
>
|
|
190
|
+
<Block block={b} actions={actions} />
|
|
191
|
+
</div>
|
|
192
|
+
</Fragment>
|
|
193
|
+
));
|
|
194
|
+
}
|
|
@@ -36,10 +36,8 @@ interface DesktopTabBarProps {
|
|
|
36
36
|
sessions: Session[];
|
|
37
37
|
viewMode: ViewMode;
|
|
38
38
|
isFocused: boolean;
|
|
39
|
-
isConductor: boolean;
|
|
40
39
|
hasLog?: boolean;
|
|
41
40
|
hasReport?: boolean;
|
|
42
|
-
workerCount: number;
|
|
43
41
|
canSplit: boolean;
|
|
44
42
|
canClose: boolean;
|
|
45
43
|
hasAttachedTmux: boolean;
|
|
@@ -68,10 +66,8 @@ export function DesktopTabBar({
|
|
|
68
66
|
sessions,
|
|
69
67
|
viewMode,
|
|
70
68
|
isFocused,
|
|
71
|
-
isConductor,
|
|
72
69
|
hasLog,
|
|
73
70
|
hasReport,
|
|
74
|
-
workerCount,
|
|
75
71
|
canSplit,
|
|
76
72
|
canClose,
|
|
77
73
|
hasAttachedTmux,
|
|
@@ -184,10 +180,8 @@ export function DesktopTabBar({
|
|
|
184
180
|
isChat={session.view === "chat"}
|
|
185
181
|
viewMode={viewMode}
|
|
186
182
|
isLocalSession={isLocalSession}
|
|
187
|
-
isConductor={isConductor}
|
|
188
183
|
hasLog={hasLog}
|
|
189
184
|
hasReport={hasReport}
|
|
190
|
-
workerCount={workerCount}
|
|
191
185
|
showGit={showGit}
|
|
192
186
|
gitDrawerOpen={gitDrawerOpen}
|
|
193
187
|
shellDrawerOpen={shellDrawerOpen}
|
|
@@ -54,10 +54,8 @@ interface MobileTabBarProps {
|
|
|
54
54
|
sessions: Session[];
|
|
55
55
|
projects: Project[];
|
|
56
56
|
viewMode: ViewMode;
|
|
57
|
-
isConductor: boolean;
|
|
58
57
|
hasLog?: boolean;
|
|
59
58
|
hasReport?: boolean;
|
|
60
|
-
workerCount: number;
|
|
61
59
|
// Local, and the folder is not known to be outside a git repository.
|
|
62
60
|
showGit: boolean;
|
|
63
61
|
onMenuClick?: () => void;
|
|
@@ -75,10 +73,8 @@ export function MobileTabBar({
|
|
|
75
73
|
sessions,
|
|
76
74
|
projects,
|
|
77
75
|
viewMode,
|
|
78
|
-
isConductor,
|
|
79
76
|
hasLog,
|
|
80
77
|
hasReport,
|
|
81
|
-
workerCount,
|
|
82
78
|
showGit,
|
|
83
79
|
onMenuClick,
|
|
84
80
|
onViewModeChange,
|
|
@@ -403,8 +399,6 @@ export function MobileTabBar({
|
|
|
403
399
|
showGit={showGit}
|
|
404
400
|
hasLog={hasLog}
|
|
405
401
|
hasReport={hasReport}
|
|
406
|
-
isConductor={isConductor}
|
|
407
|
-
workerCount={workerCount}
|
|
408
402
|
/>
|
|
409
403
|
)}
|
|
410
404
|
</div>
|
|
@@ -14,8 +14,6 @@ const all = {
|
|
|
14
14
|
isLocalSession: true,
|
|
15
15
|
showGit: true,
|
|
16
16
|
hasLog: true,
|
|
17
|
-
isConductor: true,
|
|
18
|
-
workerCount: 3,
|
|
19
17
|
};
|
|
20
18
|
|
|
21
19
|
describe("mobileViews", () => {
|
|
@@ -26,9 +24,7 @@ describe("mobileViews", () => {
|
|
|
26
24
|
"Git",
|
|
27
25
|
"Artifacts",
|
|
28
26
|
"Log",
|
|
29
|
-
"Workers",
|
|
30
27
|
]);
|
|
31
|
-
expect(mobileViews(all).at(-1)?.count).toBe(3);
|
|
32
28
|
});
|
|
33
29
|
|
|
34
30
|
it("hides views that do not apply", () => {
|
|
@@ -36,15 +32,13 @@ describe("mobileViews", () => {
|
|
|
36
32
|
mobileViews({
|
|
37
33
|
isLocalSession: false,
|
|
38
34
|
showGit: false,
|
|
39
|
-
isConductor: false,
|
|
40
|
-
workerCount: 0,
|
|
41
35
|
}).map((v) => v.label)
|
|
42
36
|
).toEqual(["Chat", "Artifacts"]);
|
|
43
37
|
});
|
|
44
38
|
|
|
45
39
|
it("drops only Git when the folder is not a repo", () => {
|
|
46
40
|
expect(mobileViews({ ...all, showGit: false }).map((v) => v.label)).toEqual(
|
|
47
|
-
["Chat", "Files", "Artifacts", "Log"
|
|
41
|
+
["Chat", "Files", "Artifacts", "Log"]
|
|
48
42
|
);
|
|
49
43
|
});
|
|
50
44
|
});
|
|
@@ -82,7 +76,6 @@ describe("MobileViewPicker", () => {
|
|
|
82
76
|
"Git",
|
|
83
77
|
"Artifacts",
|
|
84
78
|
"Log",
|
|
85
|
-
"Workers3",
|
|
86
79
|
]);
|
|
87
80
|
expect(items[3].getAttribute("aria-current")).toBe("true");
|
|
88
81
|
});
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
ScrollText,
|
|
10
10
|
ClipboardList,
|
|
11
11
|
Terminal as TerminalIcon,
|
|
12
|
-
Users,
|
|
13
12
|
type LucideIcon,
|
|
14
13
|
} from "lucide-react";
|
|
15
14
|
import {
|
|
@@ -33,8 +32,6 @@ export interface MobileViewAvailability {
|
|
|
33
32
|
showGit: boolean;
|
|
34
33
|
hasLog?: boolean;
|
|
35
34
|
hasReport?: boolean;
|
|
36
|
-
isConductor: boolean;
|
|
37
|
-
workerCount: number;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
// The one list of views on a phone; the picker renders it.
|
|
@@ -43,8 +40,6 @@ export function mobileViews({
|
|
|
43
40
|
showGit,
|
|
44
41
|
hasLog,
|
|
45
42
|
hasReport,
|
|
46
|
-
isConductor,
|
|
47
|
-
workerCount,
|
|
48
43
|
}: MobileViewAvailability): MobileView[] {
|
|
49
44
|
const views: (MobileView | false)[] = [
|
|
50
45
|
{ mode: "terminal", label: "Chat", icon: TerminalIcon },
|
|
@@ -53,12 +48,6 @@ export function mobileViews({
|
|
|
53
48
|
{ mode: "artifacts", label: "Artifacts", icon: Package },
|
|
54
49
|
!!hasReport && { mode: "report", label: "Report", icon: ClipboardList },
|
|
55
50
|
!!hasLog && { mode: "log", label: "Log", icon: ScrollText },
|
|
56
|
-
isConductor && {
|
|
57
|
-
mode: "workers",
|
|
58
|
-
label: "Workers",
|
|
59
|
-
icon: Users,
|
|
60
|
-
count: workerCount,
|
|
61
|
-
},
|
|
62
51
|
];
|
|
63
52
|
return views.filter((v): v is MobileView => !!v);
|
|
64
53
|
}
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
Package,
|
|
8
8
|
ScrollText,
|
|
9
9
|
ClipboardList,
|
|
10
|
-
Users,
|
|
11
10
|
} from "lucide-react";
|
|
12
11
|
import {
|
|
13
12
|
Tooltip,
|
|
@@ -25,11 +24,9 @@ interface PaneViewToggleProps {
|
|
|
25
24
|
isLocalSession: boolean;
|
|
26
25
|
// Local, and the folder is not known to be outside a git repository.
|
|
27
26
|
showGit: boolean;
|
|
28
|
-
isConductor: boolean;
|
|
29
27
|
// The task merged and has a log.
|
|
30
28
|
hasLog?: boolean;
|
|
31
29
|
hasReport?: boolean;
|
|
32
|
-
workerCount: number;
|
|
33
30
|
gitDrawerOpen: boolean;
|
|
34
31
|
shellDrawerOpen: boolean;
|
|
35
32
|
onViewModeChange: (mode: ViewMode) => void;
|
|
@@ -85,10 +82,8 @@ export function PaneViewToggle({
|
|
|
85
82
|
viewMode,
|
|
86
83
|
isLocalSession,
|
|
87
84
|
showGit,
|
|
88
|
-
isConductor,
|
|
89
85
|
hasLog,
|
|
90
86
|
hasReport,
|
|
91
|
-
workerCount,
|
|
92
87
|
gitDrawerOpen,
|
|
93
88
|
shellDrawerOpen,
|
|
94
89
|
onViewModeChange,
|
|
@@ -159,18 +154,6 @@ export function PaneViewToggle({
|
|
|
159
154
|
<ScrollText className="h-3.5 w-3.5" />
|
|
160
155
|
</ToggleButton>
|
|
161
156
|
)}
|
|
162
|
-
{isConductor && (
|
|
163
|
-
<ToggleButton
|
|
164
|
-
label="Workers"
|
|
165
|
-
active={viewMode === "workers"}
|
|
166
|
-
onClick={() => onViewModeChange("workers")}
|
|
167
|
-
>
|
|
168
|
-
<Users className="h-3.5 w-3.5" />
|
|
169
|
-
<span className="bg-primary text-primary-foreground absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full text-[9px] font-medium">
|
|
170
|
-
{workerCount}
|
|
171
|
-
</span>
|
|
172
|
-
</ToggleButton>
|
|
173
|
-
)}
|
|
174
157
|
</div>
|
|
175
158
|
);
|
|
176
159
|
}
|