@saadnvd1/agent-os 0.3.821 → 0.3.825

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.
Files changed (70) hide show
  1. package/app/api/fork/cancel/route.ts +19 -0
  2. package/app/api/fork/children/route.ts +52 -0
  3. package/app/api/fork/result/route.ts +29 -0
  4. package/app/api/fork/return/route.ts +20 -0
  5. package/app/api/fork/route.ts +32 -0
  6. package/app/api/fork/wait/route.ts +27 -0
  7. package/app/api/load/heavy/lease/route.ts +26 -0
  8. package/app/api/sessions/[id]/archive/route.ts +17 -4
  9. package/app/api/sessions/[id]/route.ts +2 -3
  10. package/app/page.tsx +0 -5
  11. package/components/Chat/ChatPanel.tsx +33 -206
  12. package/components/Chat/Forks.tsx +278 -0
  13. package/components/Chat/ReplyingTo.tsx +24 -0
  14. package/components/Chat/TimelineRows.tsx +194 -0
  15. package/components/Pane/DesktopTabBar.tsx +0 -6
  16. package/components/Pane/MobileTabBar.tsx +0 -6
  17. package/components/Pane/MobileViewPicker.test.ts +1 -8
  18. package/components/Pane/MobileViewPicker.tsx +0 -11
  19. package/components/Pane/PaneViewToggle.test.ts +0 -2
  20. package/components/Pane/PaneViewToggle.tsx +0 -17
  21. package/components/Pane/index.tsx +0 -50
  22. package/components/SessionList/useDoneAction.ts +18 -3
  23. package/content/whats-new.json +16 -0
  24. package/contexts/PaneContext.tsx +1 -1
  25. package/data/done/index.ts +26 -2
  26. package/data/fork/index.ts +83 -0
  27. package/lib/chat/commands.ts +15 -0
  28. package/lib/chat/drivers/copilot-ask.ts +38 -5
  29. package/lib/chat/runner.ts +8 -2
  30. package/lib/chat/worker/extras.ts +7 -1
  31. package/lib/chat/worker/host.ts +2 -0
  32. package/lib/config/client-config-types.ts +7 -0
  33. package/lib/config/client-config.ts +2 -0
  34. package/lib/db/migrations/20261102T0100_forks.ts +87 -0
  35. package/lib/done/archive.ts +21 -1
  36. package/lib/experiments/registry.ts +8 -0
  37. package/lib/fork/api.ts +10 -0
  38. package/lib/fork/brief.ts +63 -0
  39. package/lib/fork/conversation.ts +75 -0
  40. package/lib/fork/deliver.ts +91 -0
  41. package/lib/fork/extras.ts +53 -0
  42. package/lib/fork/guard.ts +323 -0
  43. package/lib/fork/heavy-lease.ts +104 -0
  44. package/lib/fork/held.ts +26 -0
  45. package/lib/fork/ledger.ts +324 -0
  46. package/lib/fork/options.ts +46 -0
  47. package/lib/fork/point.ts +41 -0
  48. package/lib/fork/scratch.ts +67 -0
  49. package/lib/fork/settle.ts +215 -0
  50. package/lib/fork/snapshot.ts +69 -0
  51. package/lib/fork/start.ts +273 -0
  52. package/lib/fork/tick.ts +54 -0
  53. package/lib/fork/types.ts +79 -0
  54. package/lib/fork/view.ts +116 -0
  55. package/lib/fork/waits.ts +83 -0
  56. package/lib/fork/worktree.ts +115 -0
  57. package/lib/orchestrator/shell-guard.ts +6 -3
  58. package/lib/orchestrator/write-guard.ts +1 -1
  59. package/lib/security/demo.ts +1 -2
  60. package/lib/sessions/end.ts +5 -0
  61. package/package.json +1 -1
  62. package/server.ts +2 -0
  63. package/app/api/orchestrate/spawn/route.ts +0 -54
  64. package/app/api/orchestrate/workers/[id]/route.ts +0 -89
  65. package/app/api/orchestrate/workers/route.ts +0 -31
  66. package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
  67. package/components/ConductorPanel.tsx +0 -258
  68. package/lib/mcp-config.ts +0 -80
  69. package/lib/orchestration.ts +0 -463
  70. package/mcp/orchestration-server.ts +0 -441
@@ -0,0 +1,19 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { fail, text } from "@/lib/fork/api";
3
+ import { cancelChildren } from "@/lib/fork/settle";
4
+
5
+ // Cancels one child (`ref`: its number, label or id) or all of a session's.
6
+ export async function POST(request: NextRequest) {
7
+ try {
8
+ const body = await request.json();
9
+ const parent = text(body.from) ?? text(body.session);
10
+ if (!parent) throw new Error("Which session's forks?");
11
+ const ref = text(body.ref) ?? undefined;
12
+ if (!ref && body.all !== true)
13
+ throw new Error("Which fork, or all of them?");
14
+ const done = await cancelChildren(parent, ref);
15
+ return NextResponse.json({ cancelled: done.map((c) => c.label) });
16
+ } catch (error) {
17
+ return fail(error);
18
+ }
19
+ }
@@ -0,0 +1,52 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { fail } from "@/lib/fork/api";
3
+ import { db } from "@/lib/db";
4
+ import { allChildren, childrenOf, getChild, numberOf } from "@/lib/fork/ledger";
5
+ import { childrenView, statsOf, statsText } from "@/lib/fork/view";
6
+ import { forksOn } from "@/lib/fork/options";
7
+
8
+ // A session's forks, for `aos children` and the chat's strip. `stats` adds
9
+ // the numbers; with no session, the totals over every fork.
10
+ export async function GET(request: NextRequest) {
11
+ try {
12
+ const params = request.nextUrl.searchParams;
13
+ const session = params.get("session");
14
+ if (!session && params.get("stats") !== "1")
15
+ throw new Error("Which session?");
16
+ if (!session) {
17
+ const { db } = await import("@/lib/db");
18
+ const rows = db
19
+ .prepare(`SELECT * FROM session_children`)
20
+ .all() as Parameters<typeof statsOf>[0];
21
+ const stats = statsOf(rows);
22
+ return NextResponse.json({ stats, text: statsText(stats) });
23
+ }
24
+ const view = childrenView(session);
25
+ const own = getChild(session);
26
+ const asChild = own
27
+ ? {
28
+ parentId: own.parent_session_id,
29
+ parentName:
30
+ (
31
+ db
32
+ .prepare(`SELECT name FROM sessions WHERE id = ?`)
33
+ .get(own.parent_session_id) as { name: string } | undefined
34
+ )?.name ?? "its session",
35
+ n: numberOf(own.parent_session_id, session),
36
+ label: own.label,
37
+ state: own.state,
38
+ worktree: !!own.worktree,
39
+ }
40
+ : null;
41
+ const rows = childrenOf(session);
42
+ const stats = params.get("stats") === "1" ? statsOf(rows) : undefined;
43
+ return NextResponse.json({
44
+ enabled: forksOn(),
45
+ asChild,
46
+ ...view,
47
+ ...(stats && { stats, text: statsText(stats) }),
48
+ });
49
+ } catch (error) {
50
+ return fail(error);
51
+ }
52
+ }
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { fail, text } from "@/lib/fork/api";
3
+ import { findChild, markUsed, numberOf } from "@/lib/fork/ledger";
4
+
5
+ // One child's whole result (`aos children <n>`); reading it counts as
6
+ // opening it, for the stats.
7
+ export async function GET(request: NextRequest) {
8
+ try {
9
+ const params = request.nextUrl.searchParams;
10
+ const parent = text(params.get("session"));
11
+ const ref = text(params.get("ref"));
12
+ if (!parent || !ref) throw new Error("session and ref are required");
13
+ const c = findChild(parent, ref);
14
+ markUsed(c.child_session_id, "opened");
15
+ return NextResponse.json({
16
+ id: c.child_session_id,
17
+ n: numberOf(parent, c.child_session_id),
18
+ label: c.label,
19
+ state: c.state,
20
+ outcome: c.outcome,
21
+ kind: c.result_kind,
22
+ summary: c.summary,
23
+ result: c.result,
24
+ instruction: c.instruction,
25
+ });
26
+ } catch (error) {
27
+ return fail(error);
28
+ }
29
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { fail, text } from "@/lib/fork/api";
3
+ import { returnChild } from "@/lib/fork/settle";
4
+
5
+ // `aos return`: a fork child hands in its result.
6
+ export async function POST(request: NextRequest) {
7
+ try {
8
+ const body = await request.json();
9
+ const child = text(body.from);
10
+ if (!child) throw new Error("Only a fork child can return a result");
11
+ const state = await returnChild(
12
+ child,
13
+ String(body.text ?? ""),
14
+ body.failed === true
15
+ );
16
+ return NextResponse.json({ state });
17
+ } catch (error) {
18
+ return fail(error);
19
+ }
20
+ }
@@ -0,0 +1,32 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { randomUUID } from "crypto";
3
+ import { fail, text } from "@/lib/fork/api";
4
+ import { startFork } from "@/lib/fork/start";
5
+ import { DELIVERY_MODES } from "@/lib/fork/options";
6
+
7
+ // Starts forks of a session: `from` is the session asking (an agent's `aos
8
+ // fork`); a person's tap names it as `session`.
9
+ export async function POST(request: NextRequest) {
10
+ try {
11
+ const body = await request.json();
12
+ const agent = text(body.from);
13
+ const parentId = agent ?? text(body.session);
14
+ if (!parentId) throw new Error("Which session to fork?");
15
+ const instructions = Array.isArray(body.instructions)
16
+ ? body.instructions.filter((t: unknown) => typeof t === "string")
17
+ : [];
18
+ const delivery = DELIVERY_MODES.find((d) => d === body.delivery);
19
+ const started = startFork({
20
+ parentId,
21
+ requestId: text(body.requestId) ?? randomUUID(),
22
+ instructions,
23
+ labels: Array.isArray(body.labels) ? body.labels : undefined,
24
+ worktree: body.worktree === true,
25
+ startedBy: agent ? "agent" : "user",
26
+ delivery,
27
+ });
28
+ return NextResponse.json(started, { status: started.repeat ? 200 : 201 });
29
+ } catch (error) {
30
+ return fail(error);
31
+ }
32
+ }
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { fail, text } from "@/lib/fork/api";
3
+ import { FORKS_OFF, forksOn } from "@/lib/fork/options";
4
+ import { registerForkWait } from "@/lib/fork/waits";
5
+ import { parseTimeout } from "@/lib/bus/waits/register";
6
+ import { kickForks } from "@/lib/fork/tick";
7
+
8
+ // `aos wait children --all|--any [--timeout 30m]`: records the wait and
9
+ // returns; the parent is woken with the results.
10
+ export async function POST(request: NextRequest) {
11
+ try {
12
+ if (!forksOn()) throw new Error(FORKS_OFF);
13
+ const body = await request.json();
14
+ const parent = text(body.from);
15
+ if (!parent) throw new Error("Only a session can wait on its forks");
16
+ const mode = body.mode === "any" ? "any" : "all";
17
+ const r = registerForkWait(
18
+ parent,
19
+ mode,
20
+ parseTimeout(body.timeout == null ? null : String(body.timeout))
21
+ );
22
+ kickForks();
23
+ return NextResponse.json(r);
24
+ } catch (error) {
25
+ return fail(error);
26
+ }
27
+ }
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isChild } from "@/lib/fork/ledger";
3
+ import { releaseLease, requestLease } from "@/lib/fork/heavy-lease";
4
+
5
+ // A fork child's `aos heavy` asks for a slot here and waits its turn; any
6
+ // other session isn't queued (it only gets the advice in /api/load/heavy).
7
+ export async function POST(request: NextRequest) {
8
+ try {
9
+ const { session, pid, action = "request" } = await request.json();
10
+ if (typeof session !== "string" || !Number.isInteger(pid) || pid <= 0)
11
+ return NextResponse.json(
12
+ { error: "session and pid are required" },
13
+ { status: 400 }
14
+ );
15
+ if (!isChild(session))
16
+ return NextResponse.json({ granted: true, ahead: 0 });
17
+ if (action === "release") {
18
+ releaseLease(session, pid);
19
+ return NextResponse.json({ released: true });
20
+ }
21
+ return NextResponse.json(requestLease(session, pid));
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 400 });
25
+ }
26
+ }
@@ -1,17 +1,30 @@
1
1
  import { NextResponse } from "next/server";
2
- import { archiveChecked } from "@/lib/done/archive";
2
+ import { archiveChecked, ForkChildrenOpen } from "@/lib/done/archive";
3
3
 
4
4
  // POST /api/sessions/:id/archive - put a session away, out of the sidebar.
5
5
  // 409 with the reason when refused.
6
6
  export async function POST(
7
- _request: Request,
7
+ request: Request,
8
8
  { params }: { params: Promise<{ id: string }> }
9
9
  ) {
10
10
  try {
11
- const session = await archiveChecked((await params).id);
11
+ const body = (await request.json().catch(() => ({}))) as {
12
+ children?: unknown;
13
+ };
14
+ const children =
15
+ body.children === "stop" || body.children === "keep"
16
+ ? body.children
17
+ : undefined;
18
+ const session = await archiveChecked((await params).id, { children });
12
19
  return NextResponse.json({ session });
13
20
  } catch (error) {
14
21
  const message = error instanceof Error ? error.message : String(error);
15
- return NextResponse.json({ error: message }, { status: 409 });
22
+ return NextResponse.json(
23
+ {
24
+ error: message,
25
+ ...(error instanceof ForkChildrenOpen && { children: error.open }),
26
+ },
27
+ { status: 409 }
28
+ );
16
29
  }
17
30
  }
@@ -9,7 +9,6 @@ import {
9
9
  } from "@/lib/worktrees";
10
10
  import { releasePort } from "@/lib/ports";
11
11
  import { releaseSessionLeftovers } from "@/lib/sessions/leftovers";
12
- import { killWorker } from "@/lib/orchestration";
13
12
  import { endSessionAgent } from "@/lib/sessions/end";
14
13
  import { endTmuxSession } from "@/lib/tmux/end";
15
14
  import { hostExecFile } from "@/lib/hosts";
@@ -264,11 +263,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
264
263
  );
265
264
  }
266
265
 
267
- // If this is a conductor, delete all its workers first
266
+ // Children nested under this session go with it
268
267
  const workers = queries.getWorkersByConductor(db).all(id) as Session[];
269
268
  for (const worker of workers) {
270
269
  try {
271
- await killWorker(worker.id, false); // false = don't cleanup worktree yet
270
+ await endSessionAgent(worker);
272
271
  } catch (error) {
273
272
  console.error(`Failed to kill worker ${worker.id}:`, error);
274
273
  }
package/app/page.tsx CHANGED
@@ -318,11 +318,6 @@ function HomeContent() {
318
318
  return { sessionName, cwd, hostId, sessionId };
319
319
  }
320
320
 
321
- // TODO: Add explicit "Enable Orchestration" toggle that creates .mcp.json
322
- // for conductor sessions. Removed auto-creation because it pollutes projects
323
- // with .mcp.json files that aren't in their .gitignore.
324
- // See: /api/sessions/[id]/mcp-config, lib/mcp-config.ts
325
-
326
321
  // Get parent session ID for forking
327
322
  let parentSessionId: string | null = null;
328
323
  if (!session.claude_session_id && isFork(session)) {
@@ -1,9 +1,6 @@
1
1
  "use client";
2
2
 
3
- import type { StepCounts } from "@/lib/chat/tool-outcome";
4
3
  import {
5
- Fragment,
6
- memo,
7
4
  useCallback,
8
5
  useEffect,
9
6
  useLayoutEffect,
@@ -15,14 +12,17 @@ import { ArrowDown, Loader2 } from "lucide-react";
15
12
  import type { ClipboardEvent } from "react";
16
13
  import { useChat } from "@/data/chat/useChat";
17
14
  import { PausedBanner } from "./PausedBanner";
15
+ import { groupTimeline } from "@/lib/chat/group";
16
+ import type { ChatItem } from "@/lib/chat/events";
18
17
  import {
19
- groupTimeline,
20
- sameBlock,
21
- STANDALONE_TOOLS,
22
- type TimelineBlock,
23
- } from "@/lib/chat/group";
24
- import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
25
- import { isFreshCommand, withFresh } from "@/lib/chat/commands";
18
+ isForkCommand,
19
+ isFreshCommand,
20
+ withFork,
21
+ withFresh,
22
+ } from "@/lib/chat/commands";
23
+ import { useExperiment } from "@/data/experiments";
24
+ import { ForkDialog, ForksStrip } from "./Forks";
25
+ import { ReplyingTo } from "./ReplyingTo";
26
26
  import { compactsFirst } from "@/lib/chat/pending";
27
27
  import { useFreshStart } from "@/data/sessions";
28
28
  import {
@@ -35,10 +35,8 @@ import { quoteInsert, quoteMarkdown } from "@/lib/chat/quote";
35
35
  import { OrchestratorBar } from "@/components/Orchestrator/OrchestratorBar";
36
36
  import { ActivityLine } from "./Activity";
37
37
  import { modelLabel as pickedModelLabel } from "./ModelPicker";
38
- import { Approval } from "./Approval";
39
38
  import { BackgroundTasks } from "./BackgroundTasks";
40
- import { SubagentCard } from "./Subagent";
41
- import { UndoDialog, UndoneBlock } from "./Undo";
39
+ import { UndoDialog } from "./Undo";
42
40
  import { Composer } from "./Composer";
43
41
  import { Queue } from "./Queue";
44
42
  import { LivePanel } from "@/components/Live/LivePanel";
@@ -48,25 +46,19 @@ import { composerCompacts } from "@/lib/chat/stale";
48
46
  import { useFreshStartQuery } from "@/data/fresh-start";
49
47
  import { QuoteButton } from "./QuoteButton";
50
48
  import { quoteIntoComposer } from "./quote-selection";
51
- import { ToolBodies, ToolGroup } from "./Tools";
49
+ import { ToolBodies } from "./Tools";
52
50
  import { RowState, RowStore } from "./rowState";
53
51
  import { useKeybindings } from "@/hooks/useKeybindings";
54
52
  import { Background, type BackgroundValue } from "./background";
55
53
  import { FindBar } from "./FindBar";
56
54
  import { useFind, useFindShortcut } from "./useFind";
57
- import { McpServers } from "./McpServers";
58
55
  import { ChatSessionId } from "./session";
59
- import { ArtifactCard } from "./Artifact";
60
- import { PlanCard } from "./PlanCard";
61
56
  import { ChatIdle, RetryTurn } from "./idle";
62
57
  import { RETRY_TEXT } from "@/lib/chat/turn-end";
63
58
  import { ContextMeter } from "./ContextMeter";
64
59
  import { SetupCard } from "./SetupCard";
65
- import { UserTurn } from "./EventRow";
66
- import { EventGroup } from "./EventTurns";
67
60
  import { needsMoreHistory } from "./needs-more-history";
68
61
  import { useGoToItem } from "./useGoToItem";
69
- import { NewDivider } from "./NewDivider";
70
62
  import { usePosition } from "./usePosition";
71
63
  import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
72
64
  import { useResolveQuestion } from "@/data/orchestrators";
@@ -83,180 +75,12 @@ import {
83
75
  TurnStarts,
84
76
  type BookmarkChat,
85
77
  } from "@/components/Bookmarks/context";
86
- import {
87
- AssistantMessage,
88
- CommandOutput,
89
- Compacted,
90
- SkillChip,
91
- UserMessage,
92
- ErrorMessage,
93
- NoteLine,
94
- Reasoning,
95
- Todos,
96
- TurnEnd,
97
- } from "./Messages";
98
-
99
- interface ItemActions {
100
- respond: (id: string, answer: ApprovalDecision) => void;
101
- // Absent inside undone messages; hidden while the agent works (ChatIdle).
102
- onCarryPlan?: (id: string) => void;
103
- onKeepPlanning?: () => void;
104
- // Absent inside undone messages; hidden while the agent works (ChatIdle).
105
- onUndo?: (id: string) => void;
106
- onQuote?: (text: string) => void;
107
- }
108
-
109
- function Item({
110
- item,
111
- actions,
112
- steps,
113
- }: {
114
- item: ChatItem;
115
- actions: ItemActions;
116
- steps?: StepCounts;
117
- }) {
118
- switch (item.kind) {
119
- case "user":
120
- return (
121
- <UserTurn
122
- item={item}
123
- onUndo={
124
- item.checkpoint && actions.onUndo
125
- ? () => actions.onUndo?.(item.id)
126
- : undefined
127
- }
128
- />
129
- );
130
- case "assistant":
131
- return <AssistantMessage item={item} onQuote={actions.onQuote} />;
132
- case "reasoning":
133
- return <Reasoning item={item} />;
134
- case "todos":
135
- return <Todos item={item} />;
136
- case "turn_end":
137
- return <TurnEnd item={item} steps={steps} />;
138
- case "error":
139
- return <ErrorMessage item={item} />;
140
- case "command_output":
141
- return <CommandOutput item={item} />;
142
- case "mcp":
143
- return <McpServers item={item} />;
144
- case "compacted":
145
- return <Compacted item={item} />;
146
- case "note":
147
- return <NoteLine item={item} />;
148
- case "artifact":
149
- return <ArtifactCard item={item} />;
150
- case "approval":
151
- return <Approval item={item} respond={actions.respond} />;
152
- case "plan":
153
- return (
154
- <PlanCard
155
- item={item}
156
- onCarryOut={
157
- actions.onCarryPlan
158
- ? () => actions.onCarryPlan?.(item.id)
159
- : undefined
160
- }
161
- onKeepPlanning={() => actions.onKeepPlanning?.()}
162
- />
163
- );
164
- case "tool":
165
- return item.name === "Skill" ? (
166
- <SkillChip item={item} />
167
- ) : STANDALONE_TOOLS.has(item.name) ? (
168
- <SubagentCard item={item} />
169
- ) : null;
170
- default:
171
- return null;
172
- }
173
- }
174
-
175
- function Timeline({
176
- blocks,
177
- actions,
178
- }: {
179
- blocks: TimelineBlock[];
180
- actions: ItemActions;
181
- }) {
182
- return blocks.map((b) => (
183
- <Block
184
- key={b.type === "item" ? b.item.id : b.id}
185
- block={b}
186
- actions={actions}
187
- />
188
- ));
189
- }
190
-
191
- const blockKey = (b: TimelineBlock) => (b.type === "item" ? b.item.id : b.id);
78
+ import { UserMessage } from "./Messages";
192
79
 
193
- // One block, re-rendered only when its items or the actions change: a long
194
- // conversation doesn't redraw every message for each streamed word.
195
- const Block = memo(
196
- function Block({
197
- block: b,
198
- actions,
199
- }: {
200
- block: TimelineBlock;
201
- actions: ItemActions;
202
- }) {
203
- const respond = actions.respond;
204
- const undoneActions = useMemo(
205
- () => ({ respond, onQuote: actions.onQuote }),
206
- [respond, actions.onQuote]
207
- );
208
- return b.type === "tools" ? (
209
- <ToolGroup tools={b.tools} />
210
- ) : b.type === "events" ? (
211
- <EventGroup
212
- id={b.id}
213
- turns={b.turns}
214
- renderReplies={(items) => (
215
- <Timeline blocks={groupTimeline(items)} actions={undoneActions} />
216
- )}
217
- />
218
- ) : b.type === "undone" ? (
219
- <UndoneBlock
220
- undo={b.undo}
221
- count={b.items.filter((i) => i.kind === "user").length}
222
- >
223
- <Timeline blocks={groupTimeline(b.items)} actions={undoneActions} />
224
- </UndoneBlock>
225
- ) : (
226
- <div data-item={b.item.id}>
227
- <Item item={b.item} actions={actions} steps={b.steps} />
228
- </div>
229
- );
230
- },
231
- (prev, next) =>
232
- prev.actions === next.actions && sameBlock(prev.block, next.block)
233
- );
80
+ import type { ItemActions } from "./TimelineRows";
81
+ import { TimelineRows } from "./TimelineRows";
234
82
 
235
- // The conversation's rows in the page's flow, not a virtualized list: a
236
- // selection follows the DOM, and a list that recycles and re-orders its rows
237
- // breaks it (docs/decisions/2026-10-11-chat-timeline-in-flow.md).
238
- export function TimelineRows({
239
- blocks,
240
- actions,
241
- newFrom,
242
- }: {
243
- blocks: TimelineBlock[];
244
- actions: ItemActions;
245
- // The block the reader hasn't seen yet begins at, when they were away.
246
- newFrom?: string | null;
247
- }) {
248
- return blocks.map((b) => (
249
- <Fragment key={blockKey(b)}>
250
- {newFrom === blockKey(b) && <NewDivider />}
251
- <div
252
- data-block={blockKey(b)}
253
- className="chat-row mx-auto w-full max-w-3xl px-4 pb-4"
254
- >
255
- <Block block={b} actions={actions} />
256
- </div>
257
- </Fragment>
258
- ));
259
- }
83
+ export { TimelineRows };
260
84
 
261
85
  export function ChatPanel({
262
86
  sessionId,
@@ -282,6 +106,9 @@ export function ChatPanel({
282
106
  at: number;
283
107
  append?: boolean;
284
108
  }>();
109
+ const forksOn = useExperiment("forks");
110
+ const [forkOpen, setForkOpen] = useState(false);
111
+ const withForkIf = (c: typeof commands) => (forksOn ? withFork(c) : c);
285
112
  const {
286
113
  items,
287
114
  hasMore,
@@ -799,19 +626,7 @@ export function ChatPanel({
799
626
  />
800
627
  )}
801
628
  {replyTo && (
802
- <div className="text-muted-foreground mb-2 flex min-h-11 items-center gap-2 rounded-lg px-2 text-xs">
803
- <span className="min-w-0 flex-1 truncate">
804
- Answering: {replyTo.text}
805
- </span>
806
- <button
807
- type="button"
808
- onClick={() => setReplyTo(null)}
809
- aria-label="Stop answering"
810
- className="hover:text-foreground flex size-11 shrink-0 items-center justify-center"
811
- >
812
- ×
813
- </button>
814
- </div>
629
+ <ReplyingTo text={replyTo.text} onStop={() => setReplyTo(null)} />
815
630
  )}
816
631
  <ActivityLine
817
632
  items={items}
@@ -830,6 +645,7 @@ export function ChatPanel({
830
645
  />
831
646
  <QuoteButton rootRef={scrollRef} onQuote={quote} />
832
647
  <PausedBanner sessionId={sessionId} />
648
+ <ForksStrip sessionId={sessionId} />
833
649
  <Queue
834
650
  queue={queue}
835
651
  running={running}
@@ -872,6 +688,10 @@ export function ChatPanel({
872
688
  startFresh.mutate(sessionId);
873
689
  return;
874
690
  }
691
+ if (forksOn && isForkCommand(text)) {
692
+ setForkOpen(true);
693
+ return;
694
+ }
875
695
  toEnd();
876
696
  const compact = composerCompacts(text, context, {
877
697
  state,
@@ -891,7 +711,7 @@ export function ChatPanel({
891
711
  }
892
712
  }}
893
713
  onStop={interrupt}
894
- commands={freshStart ? withFresh(commands) : commands}
714
+ commands={withForkIf(freshStart ? withFresh(commands) : commands)}
895
715
  onRefreshCommands={refreshCommands}
896
716
  refreshingCommands={refreshingCommands}
897
717
  models={models}
@@ -911,6 +731,13 @@ export function ChatPanel({
911
731
  focusKey={active ? sessionId : null}
912
732
  />
913
733
  </div>
734
+ {forksOn && (
735
+ <ForkDialog
736
+ sessionId={sessionId}
737
+ open={forkOpen}
738
+ onOpenChange={setForkOpen}
739
+ />
740
+ )}
914
741
  {undoPreview && (
915
742
  <UndoDialog
916
743
  preview={undoPreview.preview}