@saadnvd1/agent-os 0.3.520 → 0.3.523

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 (45) hide show
  1. package/README.md +11 -0
  2. package/app/api/welcome/route.ts +22 -0
  3. package/app/page.tsx +6 -0
  4. package/app/welcome/page.tsx +6 -0
  5. package/components/Chat/BackgroundTasks.tsx +76 -10
  6. package/components/Chat/ChatPanel.tsx +90 -34
  7. package/components/Chat/Subagent.tsx +25 -20
  8. package/components/Chat/Tools.tsx +70 -39
  9. package/components/Chat/background.tsx +114 -0
  10. package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
  11. package/components/Settings/SettingsDialog.tsx +9 -0
  12. package/components/Settings/SetupSection.tsx +37 -0
  13. package/components/Welcome/AgentList.tsx +73 -0
  14. package/components/Welcome/AgentRow.tsx +102 -0
  15. package/components/Welcome/ProjectStep.tsx +100 -0
  16. package/components/Welcome/SetupTerminal.tsx +64 -0
  17. package/components/Welcome/WelcomeDialog.tsx +153 -0
  18. package/data/agents/index.ts +16 -1
  19. package/data/chat/useChat.ts +40 -0
  20. package/data/welcome/index.ts +36 -0
  21. package/hooks/useWelcome.ts +70 -0
  22. package/lib/agents/probe.ts +27 -0
  23. package/lib/agents/setup-commands.ts +54 -0
  24. package/lib/chat/activity.ts +4 -2
  25. package/lib/chat/background.ts +77 -0
  26. package/lib/chat/driver.ts +6 -0
  27. package/lib/chat/drivers/claude-mapper.ts +44 -6
  28. package/lib/chat/drivers/claude.ts +4 -0
  29. package/lib/chat/events.ts +14 -0
  30. package/lib/chat/registry.ts +2 -0
  31. package/lib/chat/runner.ts +20 -0
  32. package/lib/chat/settings.ts +1 -0
  33. package/lib/chat/socket.ts +9 -0
  34. package/lib/chat/worker/client.ts +33 -0
  35. package/lib/chat/worker/host.ts +13 -0
  36. package/lib/chat/worker/main.ts +1 -1
  37. package/lib/chat/worker/protocol.ts +2 -0
  38. package/lib/db/migrations.ts +14 -0
  39. package/lib/security/auth.ts +11 -1
  40. package/lib/settings-url.ts +1 -0
  41. package/lib/welcome-url.ts +33 -0
  42. package/lib/welcome.ts +88 -0
  43. package/package.json +1 -1
  44. package/stores/addProject.ts +19 -1
  45. package/stores/welcomeUi.ts +26 -0
package/README.md CHANGED
@@ -213,6 +213,17 @@ its WSL note, and Windows reached it at both `localhost:3011` and
213
213
  `wslview`/`explorer.exe`, mirrored networking, `agent-os enable` under
214
214
  systemd, the idle-timeout settings above, and Debian or other distros.
215
215
 
216
+ ### First run
217
+
218
+ On a fresh install (no sessions, no projects of your own) the web app opens a
219
+ short setup at `/?welcome`: it shows each agent CLI as ready, needing a
220
+ sign-in, or missing, installs or signs one in from an embedded terminal, adds
221
+ a first project (a folder, a clone or an empty one), then opens a session in
222
+ it. Every step after the first can be skipped. It is remembered on the server,
223
+ so a phone you pair later doesn't see it, and an install that already has work
224
+ never does. Reopen it from Settings → Setup (**Run setup again**) or at
225
+ `/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off.
226
+
216
227
  ### Supported agents
217
228
 
218
229
  | Agent | Chat | Resume | Fork | Auto-approve |
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { demoMode } from "@/lib/security/demo";
3
+ import { completeWelcome, readWelcomeGate } from "@/lib/welcome";
4
+
5
+ // Whether the first-run welcome should open on this server. An install
6
+ // already in use is marked done here, so emptying it later doesn't bring it back.
7
+ export async function GET() {
8
+ return NextResponse.json(readWelcomeGate(demoMode()));
9
+ }
10
+
11
+ // { action: "complete" }: setup finished or skipped.
12
+ export async function POST(request: NextRequest) {
13
+ try {
14
+ const body = await request.json();
15
+ if (body?.action !== "complete") throw new Error("Unknown action");
16
+ completeWelcome(new Date().toISOString(), undefined, true);
17
+ return NextResponse.json(readWelcomeGate(demoMode()));
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return NextResponse.json({ error: message }, { status: 400 });
21
+ }
22
+ }
package/app/page.tsx CHANGED
@@ -78,6 +78,11 @@ const SettingsDialog = dynamic(
78
78
  ),
79
79
  { ssr: false }
80
80
  );
81
+ const WelcomeDialog = dynamic(
82
+ () =>
83
+ import("@/components/Welcome/WelcomeDialog").then((m) => m.WelcomeDialog),
84
+ { ssr: false }
85
+ );
81
86
  const MessagesDialog = dynamic(
82
87
  () => import("@/components/Bus/MessagesDialog").then((m) => m.MessagesDialog),
83
88
  { ssr: false }
@@ -122,6 +127,7 @@ const AppDialogs = memo(function AppDialogs() {
122
127
  return (
123
128
  <>
124
129
  <TasksDialog />
130
+ <WelcomeDialog />
125
131
  <AddProjectDialog />
126
132
  <MessagesDialog />
127
133
  <ArchivedDialog />
@@ -0,0 +1,6 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ // Reopens the first-run welcome.
4
+ export default function WelcomePage() {
5
+ redirect("/?welcome=agents");
6
+ }
@@ -13,6 +13,7 @@ import {
13
13
  X,
14
14
  } from "lucide-react";
15
15
  import type { ChatItem } from "@/lib/chat/events";
16
+ import { outputTail } from "@/lib/chat/background";
16
17
  import { formatElapsed } from "@/lib/chat/elapsed";
17
18
  import { cn } from "@/lib/utils";
18
19
  import { useNow } from "./useNow";
@@ -22,6 +23,8 @@ type Task = Extract<ChatItem, { kind: "task" }>;
22
23
  // Finished tasks stay listed this long, then the chip clears.
23
24
  const RECENT_MS = 5 * 60 * 1000;
24
25
  const POLL_MS = 2000;
26
+ // Running tasks whose last line of output is followed while the tray is open.
27
+ const TAILED = 5;
25
28
 
26
29
  function TaskIcon({ task }: { task: Task }) {
27
30
  if (task.status === "running")
@@ -49,16 +52,23 @@ function TaskRow({
49
52
  task,
50
53
  now,
51
54
  output,
55
+ tail,
56
+ highlight,
52
57
  onStop,
53
58
  onLoadOutput,
54
59
  }: {
55
60
  task: Task;
56
61
  now: number;
57
62
  output: string | null | undefined;
63
+ // Follows its output for the line under its name.
64
+ tail: boolean;
65
+ // Brought here from the call it came from.
66
+ highlight: boolean;
58
67
  onStop: () => void;
59
68
  onLoadOutput: (taskId: string) => void;
60
69
  }) {
61
- const [open, setOpen] = useState(false);
70
+ const [open, setOpen] = useState(highlight);
71
+ const rowRef = useRef<HTMLDivElement>(null);
62
72
  const running = task.status === "running";
63
73
  const { taskId } = task;
64
74
  const outputRef = useRef<HTMLPreElement>(null);
@@ -69,18 +79,32 @@ function TaskRow({
69
79
  if (el) el.scrollTop = el.scrollHeight;
70
80
  }, [output]);
71
81
 
72
- // While open, keep the output fresh for as long as the task runs.
73
82
  useEffect(() => {
74
- if (!open) return;
83
+ if (!highlight) return;
84
+ setOpen(true);
85
+ rowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
86
+ }, [highlight]);
87
+
88
+ // While open (or tailed), keep the output fresh for as long as it runs.
89
+ useEffect(() => {
90
+ if (!open && !(tail && running)) return;
75
91
  onLoadOutput(taskId);
76
92
  if (!running) return;
77
93
  const t = setInterval(() => onLoadOutput(taskId), POLL_MS);
78
94
  return () => clearInterval(t);
79
- }, [open, running, taskId, onLoadOutput]);
95
+ }, [open, tail, running, taskId, onLoadOutput]);
80
96
 
81
97
  const took = formatElapsed((task.endedAt ?? now) - task.createdAt);
98
+ const line =
99
+ running && !open ? (outputTail(output) ?? task.lastToolName) : null;
82
100
  return (
83
- <div className="rounded-lg">
101
+ <div
102
+ ref={rowRef}
103
+ className={cn(
104
+ "rounded-lg transition-colors duration-700",
105
+ highlight && "bg-foreground/[0.05]"
106
+ )}
107
+ >
84
108
  <div className="flex min-h-11 items-center gap-2 px-1.5 md:min-h-9">
85
109
  <button
86
110
  type="button"
@@ -113,6 +137,11 @@ function TaskRow({
113
137
  </button>
114
138
  )}
115
139
  </div>
140
+ {line && (
141
+ <p className="text-muted-foreground truncate pr-2 pb-1.5 pl-9 font-mono text-[11px]">
142
+ {line}
143
+ </p>
144
+ )}
116
145
  {open && (
117
146
  <div className="space-y-1 pb-2 pl-7">
118
147
  {task.summary && task.summary !== task.description && (
@@ -143,16 +172,24 @@ function TaskRow({
143
172
  export function BackgroundTasks({
144
173
  items,
145
174
  outputs,
175
+ reveal,
146
176
  onStop,
147
177
  onLoadOutput,
148
178
  }: {
149
179
  items: ChatItem[];
180
+ // A task to open the tray on, from the call that became it.
181
+ reveal?: { taskId: string; at: number } | null;
150
182
  outputs: Record<string, string | null>;
151
183
  onStop: (taskId: string) => void;
152
184
  onLoadOutput: (taskId: string) => void;
153
185
  }) {
154
186
  const [open, setOpen] = useState(false);
155
- const tasks = items.filter((i): i is Task => i.kind === "task");
187
+ const [pulse, setPulse] = useState(false);
188
+ const [lit, setLit] = useState<string | null>(null);
189
+ // One still blocking its call isn't in the background yet.
190
+ const tasks = items.filter(
191
+ (i): i is Task => i.kind === "task" && i.backgrounded !== false
192
+ );
156
193
  const running = tasks.filter((t) => t.status === "running" && !t.ambient);
157
194
  const now = useNow(running.length > 0);
158
195
  const shown = tasks
@@ -162,6 +199,29 @@ export function BackgroundTasks({
162
199
  Number(b.status === "running") - Number(a.status === "running") ||
163
200
  b.createdAt - a.createdAt
164
201
  );
202
+ const failed = shown.filter((t) => t.status === "failed").length;
203
+
204
+ // A task arriving gives the chip one soft pulse.
205
+ const count = running.length;
206
+ const seen = useRef(count);
207
+ useEffect(() => {
208
+ const more = count > seen.current;
209
+ seen.current = count;
210
+ if (!more) return;
211
+ setPulse(true);
212
+ const t = setTimeout(() => setPulse(false), 700);
213
+ return () => clearTimeout(t);
214
+ }, [count]);
215
+
216
+ // Asked to show a task: open the tray on it for a moment.
217
+ useEffect(() => {
218
+ if (!reveal) return;
219
+ setOpen(true);
220
+ setLit(reveal.taskId);
221
+ const t = setTimeout(() => setLit(null), 1200);
222
+ return () => clearTimeout(t);
223
+ }, [reveal]);
224
+
165
225
  if (!shown.length) return null;
166
226
 
167
227
  return (
@@ -170,24 +230,30 @@ export function BackgroundTasks({
170
230
  type="button"
171
231
  onClick={() => setOpen(!open)}
172
232
  className={cn(
173
- "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-9 items-center gap-1.5 rounded-lg px-2 text-xs md:h-7",
174
- running.length && "text-primary"
233
+ "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-9 items-center gap-1.5 rounded-lg px-2 text-xs transition-colors duration-500 md:h-7",
234
+ running.length && "text-primary",
235
+ !running.length && failed > 0 && "text-destructive",
236
+ pulse && "bg-foreground/[0.08]"
175
237
  )}
176
238
  >
177
239
  <Layers className="h-3.5 w-3.5" />
178
240
  {running.length
179
241
  ? `${running.length} running in background`
180
- : `${shown.length} background task${shown.length === 1 ? "" : "s"}`}
242
+ : failed
243
+ ? `${failed} failed in background`
244
+ : `${shown.length} finished in background`}
181
245
  <ChevronRight
182
246
  className={cn("h-3 w-3 transition-transform", open && "rotate-90")}
183
247
  />
184
248
  </button>
185
249
  {open && (
186
250
  <div className="bg-foreground/[0.025] mt-1 max-h-80 overflow-y-auto rounded-xl px-1 py-1">
187
- {shown.map((t) => (
251
+ {shown.map((t, i) => (
188
252
  <TaskRow
189
253
  key={t.id}
190
254
  task={t}
255
+ tail={i < TAILED}
256
+ highlight={lit === t.taskId}
191
257
  now={now}
192
258
  output={outputs[t.taskId]}
193
259
  onStop={() => onStop(t.taskId)}
@@ -42,6 +42,8 @@ import { useFreshStartQuery } from "@/data/fresh-start";
42
42
  import { QuoteButton } from "./QuoteButton";
43
43
  import { ToolBodies, ToolGroup } from "./Tools";
44
44
  import { RowState, RowStore } from "./rowState";
45
+ import { Background, type BackgroundValue } from "./background";
46
+ import { backgroundKey } from "@/lib/chat/background";
45
47
  import { FindBar } from "./FindBar";
46
48
  import { FIND_EVENT, isFindKey, useFind } from "./useFind";
47
49
  import { McpServers } from "./McpServers";
@@ -253,6 +255,9 @@ export function ChatPanel({
253
255
  undoPreview,
254
256
  clearUndo,
255
257
  stopTask,
258
+ canBackground,
259
+ backgroundTask,
260
+ movingToBackground,
256
261
  loadTaskOutput,
257
262
  taskOutputs,
258
263
  queue,
@@ -380,6 +385,54 @@ export function ChatPanel({
380
385
  }),
381
386
  [toolBodies, requestToolBody, pinToolBody, connected]
382
387
  );
388
+ // The calls that block the turn and can be moved to the background.
389
+ const [reveal, setReveal] = useState<{ taskId: string; at: number } | null>(
390
+ null
391
+ );
392
+ const foregroundTasks = useMemo(() => {
393
+ const byTool = new Map<string, Extract<ChatItem, { kind: "task" }>>();
394
+ const byId = new Map<string, Extract<ChatItem, { kind: "task" }>>();
395
+ for (const i of tasks)
396
+ if (i.kind === "task") {
397
+ byId.set(i.taskId, i);
398
+ if (i.toolUseId) byTool.set(i.toolUseId, i);
399
+ }
400
+ return { byTool, byId };
401
+ }, [tasks]);
402
+ const background = useMemo<BackgroundValue>(
403
+ () => ({
404
+ supported: canBackground,
405
+ taskOfTool: (id) => foregroundTasks.byTool.get(id),
406
+ taskById: (id) => foregroundTasks.byId.get(id),
407
+ moving: movingToBackground,
408
+ send: backgroundTask,
409
+ reveal: (taskId) => setReveal({ taskId, at: Date.now() }),
410
+ }),
411
+ [canBackground, foregroundTasks, movingToBackground, backgroundTask]
412
+ );
413
+ // Ctrl+B moves every call that blocks the turn, from anywhere in this panel.
414
+ const canMove = useMemo(
415
+ () =>
416
+ canBackground &&
417
+ [...foregroundTasks.byId.values()].some(
418
+ (t) => t.status === "running" && t.backgrounded === false
419
+ ),
420
+ [canBackground, foregroundTasks]
421
+ );
422
+ const moveRef = useRef({ canMove, backgroundTask });
423
+ useEffect(() => {
424
+ moveRef.current = { canMove, backgroundTask };
425
+ });
426
+ useEffect(() => {
427
+ const onKey = (e: KeyboardEvent) => {
428
+ if (!rootRef.current?.contains(e.target as Node)) return;
429
+ if (!backgroundKey(e, moveRef.current.canMove)) return;
430
+ e.preventDefault();
431
+ moveRef.current.backgroundTask();
432
+ };
433
+ window.addEventListener("keydown", onKey, true);
434
+ return () => window.removeEventListener("keydown", onKey, true);
435
+ }, []);
383
436
  const itemsById = useMemo(
384
437
  () => new Map(items.map((i) => [i.id, i])),
385
438
  [items]
@@ -482,41 +535,43 @@ export function ChatPanel({
482
535
  {orchestratorOf && <OrchestratorBar workspaceId={orchestratorOf} />}
483
536
  <div ref={scrollRef} className="relative min-h-0 flex-1">
484
537
  <RowState.Provider value={rowState}>
485
- <ToolBodies.Provider value={bodies}>
486
- <div
487
- // A new conversation starts over, scrolled to the end.
488
- key={sessionId}
489
- ref={listRef}
490
- onScroll={onListScroll}
491
- onCopy={onCopy}
492
- className="h-full overflow-y-auto overscroll-y-contain"
493
- >
494
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
495
- {hasMore && (
496
- <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
497
- {loadingOlder ? (
498
- <Loader2 className="h-4 w-4 animate-spin" />
499
- ) : (
500
- <button
501
- type="button"
502
- onClick={loadOlder}
503
- className="hover:text-foreground min-h-11 px-3"
504
- >
505
- Show earlier messages
506
- </button>
507
- )}
508
- </div>
509
- )}
510
- <SetupCard sessionId={sessionId} />
511
- {blocks.length === 0 && !queue.length && (
512
- <p className="text-muted-foreground py-16 text-center text-sm">
513
- Ask anything to start.
514
- </p>
515
- )}
538
+ <Background.Provider value={background}>
539
+ <ToolBodies.Provider value={bodies}>
540
+ <div
541
+ // A new conversation starts over, scrolled to the end.
542
+ key={sessionId}
543
+ ref={listRef}
544
+ onScroll={onListScroll}
545
+ onCopy={onCopy}
546
+ className="h-full overflow-y-auto overscroll-y-contain"
547
+ >
548
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
549
+ {hasMore && (
550
+ <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
551
+ {loadingOlder ? (
552
+ <Loader2 className="h-4 w-4 animate-spin" />
553
+ ) : (
554
+ <button
555
+ type="button"
556
+ onClick={loadOlder}
557
+ className="hover:text-foreground min-h-11 px-3"
558
+ >
559
+ Show earlier messages
560
+ </button>
561
+ )}
562
+ </div>
563
+ )}
564
+ <SetupCard sessionId={sessionId} />
565
+ {blocks.length === 0 && !queue.length && (
566
+ <p className="text-muted-foreground py-16 text-center text-sm">
567
+ Ask anything to start.
568
+ </p>
569
+ )}
570
+ </div>
571
+ <TimelineRows blocks={blocks} actions={actions} />
516
572
  </div>
517
- <TimelineRows blocks={blocks} actions={actions} />
518
- </div>
519
- </ToolBodies.Provider>
573
+ </ToolBodies.Provider>
574
+ </Background.Provider>
520
575
  </RowState.Provider>
521
576
  {find.open && (
522
577
  <FindBar
@@ -551,6 +606,7 @@ export function ChatPanel({
551
606
  />
552
607
  <BackgroundTasks
553
608
  items={tasks}
609
+ reveal={reveal}
554
610
  outputs={taskOutputs}
555
611
  onStop={stopTask}
556
612
  onLoadOutput={loadTaskOutput}
@@ -4,7 +4,8 @@ import { Bot, ChevronRight } from "lucide-react";
4
4
  import type { ToolItem } from "@/lib/chat/group";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { Markdown } from "./Markdown";
7
- import { StatusIcon, useToolBody } from "./Tools";
7
+ import { ToolIcon, useToolBody } from "./Tools";
8
+ import { BackgroundButton, BackgroundState } from "./background";
8
9
  import { useRowState } from "./rowState";
9
10
 
10
11
  // Its report, loaded when the card opens if the page left it out.
@@ -23,26 +24,30 @@ export function SubagentCard({ item }: { item: ToolItem }) {
23
24
  };
24
25
  return (
25
26
  <div className="bg-foreground/[0.025] rounded-xl px-2 py-1">
26
- <button
27
- type="button"
28
- onClick={() => setOpen(!open)}
29
- className="flex min-h-10 w-full items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
30
- >
31
- <ChevronRight
32
- className={cn(
33
- "text-muted-foreground h-3 w-3 shrink-0 transition-transform",
34
- open && "rotate-90"
27
+ <div className="flex items-center gap-1">
28
+ <button
29
+ type="button"
30
+ onClick={() => setOpen(!open)}
31
+ className="flex min-h-10 min-w-0 flex-1 items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
32
+ >
33
+ <ChevronRight
34
+ className={cn(
35
+ "text-muted-foreground h-3 w-3 shrink-0 transition-transform",
36
+ open && "rotate-90"
37
+ )}
38
+ />
39
+ <Bot className="text-primary h-3.5 w-3.5 shrink-0" />
40
+ <span className="min-w-0 flex-1 truncate">{item.title}</span>
41
+ {input.subagent_type && (
42
+ <span className="text-muted-foreground hidden font-mono sm:inline">
43
+ {input.subagent_type}
44
+ </span>
35
45
  )}
36
- />
37
- <Bot className="text-primary h-3.5 w-3.5 shrink-0" />
38
- <span className="min-w-0 flex-1 truncate">{item.title}</span>
39
- {input.subagent_type && (
40
- <span className="text-muted-foreground hidden font-mono sm:inline">
41
- {input.subagent_type}
42
- </span>
43
- )}
44
- <StatusIcon status={item.status} />
45
- </button>
46
+ {item.backgroundTaskId ? <BackgroundState tool={item} /> : null}
47
+ <ToolIcon tool={item} />
48
+ </button>
49
+ <BackgroundButton tool={item} />
50
+ </div>
46
51
  {open && (
47
52
  <div className="space-y-3 px-1.5 pt-1 pb-2 pl-7">
48
53
  {input.prompt && (
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, useContext, useEffect } from "react";
4
- import { Check, ChevronRight, Loader2, Square, X } from "lucide-react";
4
+ import { Check, ChevronRight, Layers, Loader2, Square, X } from "lucide-react";
5
5
  import type { FileDiff, ToolBody } from "@/lib/chat/events";
6
6
  import type { ToolItem } from "@/lib/chat/group";
7
7
  import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
@@ -9,6 +9,7 @@ import { needsBody } from "@/lib/chat/bodyCache";
9
9
  import { cn } from "@/lib/utils";
10
10
  import { formatElapsed } from "@/lib/chat/elapsed";
11
11
  import { Highlighted } from "./Code";
12
+ import { BackgroundButton, BackgroundState, useBackground } from "./background";
12
13
  import { useNow } from "./useNow";
13
14
  import { useRowState } from "./rowState";
14
15
 
@@ -48,6 +49,18 @@ export function StatusIcon({ status }: { status: ToolItem["status"] }) {
48
49
  return <Check className="text-muted-foreground/70 h-3.5 w-3.5 shrink-0" />;
49
50
  }
50
51
 
52
+ // The status of a call; one moved to the background and still running there
53
+ // shows as handed off instead of done.
54
+ export function ToolIcon({ tool }: { tool: ToolItem }) {
55
+ const bg = useBackground();
56
+ const task = tool.backgroundTaskId
57
+ ? bg.taskById(tool.backgroundTaskId)
58
+ : undefined;
59
+ if (tool.backgroundTaskId && (!task || task.status === "running"))
60
+ return <Layers className="text-muted-foreground h-3.5 w-3.5 shrink-0" />;
61
+ return <StatusIcon status={tool.status} />;
62
+ }
63
+
51
64
  const TONE: Record<DiffLine["op"], string> = {
52
65
  " ": "text-muted-foreground",
53
66
  "-": "bg-red-500/10 text-red-700 dark:text-red-300",
@@ -139,17 +152,24 @@ function ToolRow({ tool }: { tool: ToolItem }) {
139
152
  );
140
153
  return (
141
154
  <div data-item={tool.id}>
142
- <button
143
- type="button"
144
- onClick={() => setOpen(!open)}
145
- className="hover:bg-foreground/[0.04] flex min-h-9 w-full items-center gap-2 rounded-lg px-1.5 text-left md:min-h-7"
146
- >
147
- <StatusIcon status={tool.status} />
148
- <span className="min-w-0 flex-1 truncate font-mono text-xs">
149
- {tool.title}
150
- </span>
151
- <Elapsed tool={tool} />
152
- </button>
155
+ <div className="hover:bg-foreground/[0.04] flex items-center gap-1 rounded-lg pr-1">
156
+ <button
157
+ type="button"
158
+ onClick={() => setOpen(!open)}
159
+ className="flex min-h-9 min-w-0 flex-1 items-center gap-2 px-1.5 text-left md:min-h-7"
160
+ >
161
+ <ToolIcon tool={tool} />
162
+ <span className="min-w-0 flex-1 truncate font-mono text-xs">
163
+ {tool.title}
164
+ </span>
165
+ {tool.backgroundTaskId ? (
166
+ <BackgroundState tool={tool} />
167
+ ) : (
168
+ <Elapsed tool={tool} />
169
+ )}
170
+ </button>
171
+ <BackgroundButton tool={tool} />
172
+ </div>
153
173
  {open && <ToolDetail tool={tool} />}
154
174
  </div>
155
175
  );
@@ -161,39 +181,50 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
161
181
  const failed = tools.filter((t) => t.status === "error").length;
162
182
  const stopped = tools.filter((t) => t.status === "stopped").length;
163
183
  const edits = tools.filter((t) => t.diff || t.hasDiff).length;
184
+ const moved = tools.filter((t) => t.backgroundTaskId).length;
164
185
  const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
165
186
  const latest =
166
187
  tools.findLast((t) => t.status === "running") ?? tools[tools.length - 1];
167
188
  return (
168
189
  <div className="bg-foreground/[0.025] rounded-xl px-2 py-1">
169
- <button
170
- type="button"
171
- onClick={() => setOpen(!open)}
172
- className="text-muted-foreground hover:text-foreground flex min-h-10 w-full items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
173
- >
174
- <ChevronRight
175
- className={cn(
176
- "h-3 w-3 shrink-0 transition-transform",
177
- open && "rotate-90"
190
+ <div className="flex items-center gap-1">
191
+ <button
192
+ type="button"
193
+ onClick={() => setOpen(!open)}
194
+ className="text-muted-foreground hover:text-foreground flex min-h-10 min-w-0 flex-1 items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
195
+ >
196
+ <ChevronRight
197
+ className={cn(
198
+ "h-3 w-3 shrink-0 transition-transform",
199
+ open && "rotate-90"
200
+ )}
201
+ />
202
+ {running ? (
203
+ <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
204
+ ) : null}
205
+ <span className="min-w-0 flex-1 truncate">
206
+ {running
207
+ ? latest.title
208
+ : `${tools.length} step${tools.length === 1 ? "" : "s"}`}
209
+ {!running &&
210
+ edits > 0 &&
211
+ ` · ${edits} edit${edits === 1 ? "" : "s"}`}
212
+ </span>
213
+ {running && <Elapsed tool={latest} />}
214
+ {failed > 0 && (
215
+ <span className="text-destructive">{failed} failed</span>
178
216
  )}
179
- />
180
- {running ? (
181
- <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
182
- ) : null}
183
- <span className="min-w-0 flex-1 truncate">
184
- {running
185
- ? latest.title
186
- : `${tools.length} step${tools.length === 1 ? "" : "s"}`}
187
- {!running && edits > 0 && ` · ${edits} edit${edits === 1 ? "" : "s"}`}
188
- </span>
189
- {running && <Elapsed tool={latest} />}
190
- {failed > 0 && (
191
- <span className="text-destructive">{failed} failed</span>
192
- )}
193
- {stopped > 0 && failed === 0 && (
194
- <span className="text-muted-foreground/70">stopped</span>
195
- )}
196
- </button>
217
+ {stopped > 0 && failed === 0 && (
218
+ <span className="text-muted-foreground/70">stopped</span>
219
+ )}
220
+ {!running && moved > 0 && (
221
+ <span className="text-muted-foreground/70">
222
+ {moved} in background
223
+ </span>
224
+ )}
225
+ </button>
226
+ {running && <BackgroundButton tool={latest} />}
227
+ </div>
197
228
  {open && (
198
229
  <div className="pb-1">
199
230
  {tools.map((t) => (