@saadnvd1/agent-os 0.3.597 → 0.3.601

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 (53) hide show
  1. package/app/api/bus/spawn/route.ts +35 -16
  2. package/app/api/lumifyhub/cards/[cardId]/run/route.ts +3 -0
  3. package/app/api/tasks/route.ts +3 -0
  4. package/app/page.tsx +2 -1
  5. package/components/SessionList/LiveLine.tsx +33 -6
  6. package/components/SessionList/QueuedRow.tsx +45 -46
  7. package/components/SessionList/RowParts.tsx +1 -54
  8. package/components/SessionList/RowShell.tsx +13 -6
  9. package/components/SessionList/RowStatusPlacement.test.ts +114 -0
  10. package/components/SessionList/RowSubtitle.tsx +2 -3
  11. package/components/SessionList/SessionRow.tsx +14 -11
  12. package/lib/agents/spawn.ts +16 -4
  13. package/lib/chat/refresh.ts +2 -1
  14. package/lib/chat/runner.ts +29 -0
  15. package/lib/chat/store.ts +53 -0
  16. package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
  17. package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
  18. package/lib/db/types.ts +2 -0
  19. package/lib/learning-cards/apply.ts +7 -1
  20. package/lib/models/plan-step.ts +25 -8
  21. package/lib/models/resolve.ts +1 -1
  22. package/lib/models/resume.ts +1 -1
  23. package/lib/models/roles.ts +0 -16
  24. package/lib/models/session-role.ts +19 -0
  25. package/lib/orchestrator/act.ts +22 -7
  26. package/lib/orchestrator/amendments.ts +10 -3
  27. package/lib/orchestrator/serve.ts +1 -0
  28. package/lib/orchestrator/testing.ts +8 -0
  29. package/lib/orchestrator/tool-schemas.ts +9 -0
  30. package/lib/orchestrator/tools.ts +1 -1
  31. package/lib/plans/claim.ts +131 -0
  32. package/lib/plans/release.ts +5 -2
  33. package/lib/recipes/run.ts +6 -0
  34. package/lib/schedules/start.ts +1 -0
  35. package/lib/sessions/fork.ts +10 -0
  36. package/lib/sessions/launch.ts +5 -1
  37. package/lib/sidebar/live-status.ts +33 -2
  38. package/lib/sidebar/row-badge.ts +26 -2
  39. package/lib/stacks/start.ts +1 -0
  40. package/lib/tasks/held-remote.ts +4 -2
  41. package/lib/tasks/held-replies.ts +5 -2
  42. package/lib/tasks/held.ts +2 -1
  43. package/lib/tasks/index.ts +85 -46
  44. package/lib/tasks/move-carry.ts +12 -1
  45. package/lib/tasks/move-guard.ts +2 -1
  46. package/lib/tasks/move.ts +7 -5
  47. package/lib/tasks/pause.ts +1 -1
  48. package/lib/tasks/queue.ts +54 -31
  49. package/lib/tasks/remote.ts +3 -1
  50. package/lib/tasks/starter.ts +124 -0
  51. package/lib/usage/calls.ts +2 -1
  52. package/lib/usage/turns.ts +2 -1
  53. package/package.json +1 -1
@@ -5,17 +5,14 @@ import { startOrQueue, stampPriority } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
6
  import { hostIdNamed } from "@/lib/hosts";
7
7
  import { startView } from "@/lib/sessions/launch";
8
- import { db } from "@/lib/db";
9
-
10
- // The calling session, when it's an orchestrator: what it starts runs on the
11
- // implementation model. A made-up `from` can only pick that cheaper role.
12
- function orchestratorFrom(from: unknown): string | null {
13
- if (typeof from !== "string" || !from) return null;
14
- const row = db
15
- .prepare(`SELECT 1 FROM sessions WHERE id = ? AND role = 'orchestrator'`)
16
- .get(from);
17
- return row ? from : null;
18
- }
8
+ import { callerSession, starterOf } from "@/lib/tasks/starter";
9
+ import { callingDevice } from "@/lib/security/device-me";
10
+ import {
11
+ findPiece,
12
+ pieceLabel,
13
+ pieceRefusal,
14
+ waitingPiecesFor,
15
+ } from "@/lib/plans/claim";
19
16
 
20
17
  // mode "session" starts an interactive agent; "task" starts one that ends in
21
18
  // a PR, or queues it (over the workspace's limit, or with `after`).
@@ -34,8 +31,15 @@ export async function POST(request: NextRequest) {
34
31
  priority,
35
32
  machine,
36
33
  from,
34
+ cwd,
35
+ piece,
37
36
  } = await request.json();
38
- const startedBy = orchestratorFrom(from);
37
+ // Whoever called: a session (by `from`, else the worktree it runs in) or a person.
38
+ const starter = starterOf(
39
+ callerSession({ from, cwd }),
40
+ callingDevice(request)
41
+ );
42
+ const { startedBy } = starter;
39
43
  const given = typeof name === "string" ? name : undefined;
40
44
  if (after !== undefined && mode !== "task")
41
45
  throw new Error("--after works for tasks only");
@@ -47,16 +51,29 @@ export async function POST(request: NextRequest) {
47
51
  throw new Error("--priority is urgent, blocking, normal or background");
48
52
  if (machine !== undefined && machine !== "mac" && machine !== "any")
49
53
  throw new Error('machine is "mac" or "any"');
54
+ if (piece !== undefined && mode !== "task")
55
+ throw new Error("--piece works for tasks only");
50
56
  if (mode === "task") {
57
+ const projectId = findProject(String(project ?? "")).id;
58
+ // A task whose plan has pieces waiting here must say which it builds.
59
+ const waiting = startedBy ? waitingPiecesFor(startedBy, projectId) : [];
60
+ let pieceId: number | undefined;
61
+ if (piece !== undefined) {
62
+ if (typeof piece !== "string" || !piece.trim())
63
+ throw new Error("--piece needs a piece name");
64
+ pieceId = findPiece(waiting, piece).id;
65
+ } else if (waiting.length)
66
+ throw new Error(pieceRefusal(waiting, projectId));
51
67
  const task = {
52
- projectId: findProject(String(project ?? "")).id,
68
+ projectId,
69
+ ...starter,
70
+ pieceId,
53
71
  prompt: String(prompt ?? ""),
54
72
  name: given,
55
73
  model,
56
74
  hostId: on ? hostIdNamed(String(on)) : undefined,
57
75
  view: startView(view),
58
76
  planOnly: planOnly === true ? true : undefined,
59
- startedBy,
60
77
  };
61
78
  const out = await startOrQueue(
62
79
  {
@@ -71,9 +88,10 @@ export async function POST(request: NextRequest) {
71
88
  return session;
72
89
  }
73
90
  );
91
+ const builds = pieceId ? pieceLabel(pieceId) : undefined;
74
92
  return "queued" in out
75
- ? NextResponse.json({ queued: out.queued }, { status: 202 })
76
- : NextResponse.json({ session: out.started }, { status: 201 });
93
+ ? NextResponse.json({ queued: out.queued, builds }, { status: 202 })
94
+ : NextResponse.json({ session: out.started, builds }, { status: 201 });
77
95
  }
78
96
  const session = await spawnSession({
79
97
  project: String(project ?? ""),
@@ -81,6 +99,7 @@ export async function POST(request: NextRequest) {
81
99
  name: given,
82
100
  model,
83
101
  startedBy,
102
+ parentSessionId: starter.parentSessionId,
84
103
  });
85
104
  return NextResponse.json({ session }, { status: 201 });
86
105
  } catch (error) {
@@ -3,6 +3,7 @@ import { body, respond } from "@/lib/lumifyhub/http";
3
3
  import { lumifyHubId } from "@/lib/lumifyhub/ids";
4
4
  import { cardForTask, promptFromCard } from "@/lib/lumifyhub/task-cards";
5
5
  import { createTask } from "@/lib/tasks";
6
+ import { callingDevice } from "@/lib/security/device-me";
6
7
 
7
8
  type RouteParams = { params: Promise<{ cardId: string }> };
8
9
 
@@ -20,6 +21,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
20
21
  prompt: promptFromCard(card),
21
22
  name: card.title,
22
23
  cardId: card.id,
24
+ startedVia: "person",
25
+ startedByDevice: callingDevice(request) ?? undefined,
23
26
  });
24
27
  return { session };
25
28
  });
@@ -85,6 +85,8 @@ export async function POST(request: NextRequest) {
85
85
  hostId: typeof hostId === "string" && hostId ? hostId : undefined,
86
86
  baseBranch: typeof baseBranch === "string" ? baseBranch : undefined,
87
87
  view: startView(view),
88
+ startedVia: "person" as const,
89
+ startedByDevice: callingDevice(request) ?? undefined,
88
90
  };
89
91
  if (id !== undefined) {
90
92
  // An orchestrator's task is held for the paired device that sent it,
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
105
107
  id,
106
108
  ...opts,
107
109
  held,
110
+ startedVia: "remote",
108
111
  startedByDevice: device ?? undefined,
109
112
  });
110
113
  return NextResponse.json({ session }, { status: 201 });
package/app/page.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isFork } from "@/lib/sessions/fork";
3
4
  import { toast } from "sonner";
4
5
 
5
6
  import { subscribe } from "valtio";
@@ -299,7 +300,7 @@ function HomeContent() {
299
300
 
300
301
  // Get parent session ID for forking
301
302
  let parentSessionId: string | null = null;
302
- if (!session.claude_session_id && session.parent_session_id) {
303
+ if (!session.claude_session_id && isFork(session)) {
303
304
  const parentSession = sessions.find(
304
305
  (s) => s.id === session.parent_session_id
305
306
  );
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { Loader2 } from "lucide-react";
3
+ import { ClipboardList, Loader2, Pencil } from "lucide-react";
4
4
  import { liveParts, liveText, type LiveTone } from "@/lib/sidebar/live-status";
5
+ import type { StatusBadge } from "@/lib/sidebar/row-badge";
5
6
  import type { PrStage } from "@/lib/tasks/pr-stage";
6
7
  import { prStageText } from "@/lib/tasks/pr-stage";
7
8
  import { cn } from "@/lib/utils";
@@ -9,22 +10,35 @@ import { cn } from "@/lib/utils";
9
10
  export interface LiveInput {
10
11
  compacting?: boolean;
11
12
  prStage?: PrStage;
13
+ badge?: StatusBadge | null;
12
14
  }
13
15
 
16
+ // A status badge's pill; the failed need is red text alone.
17
+ const PILL: Record<LiveTone, string> = {
18
+ blue: "bg-blue-500/15",
19
+ amber: "bg-amber-500/15",
20
+ red: "bg-red-500/15",
21
+ green: "bg-green-500/15",
22
+ purple: "bg-purple-500/15",
23
+ primary: "bg-primary/15",
24
+ muted: "bg-muted",
25
+ };
26
+
14
27
  const TONE: Record<LiveTone, string> = {
15
28
  blue: "text-blue-700 dark:text-blue-400",
16
29
  amber: "text-amber-700 dark:text-amber-400",
17
30
  red: "text-red-700 dark:text-red-400",
18
31
  green: "text-green-700 dark:text-green-400",
19
32
  purple: "text-purple-700 dark:text-purple-400",
33
+ primary: "text-primary",
20
34
  muted: "text-muted-foreground",
21
35
  };
22
36
 
23
37
  // The row's live status in small coloured words under the name: "Compacting
24
- // · PR CI". The PR word links to the PR; its number, full wording and sha are
38
+ // · Plan ready · PR CI". The PR word links to the PR; its number, full wording and sha are
25
39
  // in the tooltip. Renders nothing when there is nothing to say.
26
- export function LiveLine({ compacting, prStage }: LiveInput) {
27
- const parts = liveParts({ compacting, prStage });
40
+ export function LiveLine({ compacting, prStage, badge }: LiveInput) {
41
+ const parts = liveParts({ compacting, prStage, badge });
28
42
  if (parts.length === 0) return null;
29
43
  const pr = prStage
30
44
  ? `${prStageText(prStage)}${prStage.sha ? ` · ${prStage.sha}` : ""}`
@@ -35,12 +49,15 @@ export function LiveLine({ compacting, prStage }: LiveInput) {
35
49
  className="flex shrink-0 items-center gap-1 text-xs font-medium whitespace-nowrap"
36
50
  >
37
51
  {parts.map((p, i) => {
38
- const isPr = p.text.startsWith("PR");
52
+ const isPr = !p.badge && p.text.startsWith("PR");
53
+ const PlanIcon = p.badge === "planning" ? Pencil : ClipboardList;
54
+ const isPlan = p.badge === "planning" || p.badge === "plan-ready";
39
55
  const body = (
40
56
  <>
41
57
  {p.busy && (
42
58
  <Loader2 className="h-2.5 w-2.5 animate-spin" aria-hidden />
43
59
  )}
60
+ {isPlan && <PlanIcon className="h-2.5 w-2.5" aria-hidden />}
44
61
  {p.text}
45
62
  </>
46
63
  );
@@ -63,7 +80,17 @@ export function LiveLine({ compacting, prStage }: LiveInput) {
63
80
  {body}
64
81
  </a>
65
82
  ) : (
66
- <span className={cn("flex items-center gap-1", TONE[p.tone])}>
83
+ <span
84
+ data-tip={p.tip}
85
+ data-status-badge={p.badge}
86
+ className={cn(
87
+ "flex items-center gap-1",
88
+ TONE[p.tone],
89
+ p.badge &&
90
+ "rounded-md px-1.5 py-0.5 text-[11px] font-semibold",
91
+ p.badge && PILL[p.tone]
92
+ )}
93
+ >
67
94
  {body}
68
95
  </span>
69
96
  )}
@@ -16,6 +16,8 @@ import { sessionLink } from "@/lib/session-url";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { useRowContext } from "./RowContext";
18
18
  import { RowShell, type RowMenuKind } from "./RowShell";
19
+ import { LiveLine } from "./LiveLine";
20
+ import type { StatusBadge } from "@/lib/sidebar/row-badge";
19
21
 
20
22
  const ITEM = { dropdown: DM.DropdownMenuItem, context: CM.ContextMenuItem };
21
23
  const SEPARATOR = {
@@ -142,19 +144,6 @@ export function QueuedRow({ row }: { row: Row }) {
142
144
  )}
143
145
  />
144
146
  }
145
- trailing={
146
- // Under the Queued heading a waiting one needs no badge.
147
- item.status !== "queued" && (
148
- <span
149
- className={cn(
150
- "rounded-md px-1.5 py-0.5 text-[11px] font-semibold",
151
- failed ? "text-destructive" : "bg-muted text-muted-foreground"
152
- )}
153
- >
154
- {failed ? "Failed" : "Starting"}
155
- </span>
156
- )
157
- }
158
147
  menu={menu}
159
148
  menuLabel="Queued task actions"
160
149
  onClick={open}
@@ -169,43 +158,53 @@ export function QueuedRow({ row }: { row: Row }) {
169
158
  function QueuedSubtitle({ row }: { row: Row }) {
170
159
  const ctx = useRowContext();
171
160
  const { item } = row;
161
+ // Under the Queued heading a waiting one needs no badge.
162
+ const badge: StatusBadge | null =
163
+ item.status === "failed"
164
+ ? { kind: "failed", label: "Failed" }
165
+ : item.status === "queued"
166
+ ? null
167
+ : { kind: "starting", label: "Starting" };
172
168
  const { lead, after } = queuedSubtitle(item);
173
169
  const target = row.afterSessionId;
174
170
  return (
175
- <span
176
- className={cn(
177
- "text-muted-foreground/70 text-xs",
178
- // A failed start's reason is read in full, phones included.
179
- item.status === "failed" ? "line-clamp-3 break-words" : "truncate"
180
- )}
181
- >
182
- <span className={cn(item.status === "failed" && "text-destructive")}>
183
- {lead}
171
+ <span className="flex items-start gap-1.5 md:pr-6">
172
+ <LiveLine badge={badge} />
173
+ <span
174
+ className={cn(
175
+ "text-muted-foreground/70 min-w-0 text-xs",
176
+ // A failed start's reason is read in full, phones included.
177
+ item.status === "failed" ? "line-clamp-3 break-words" : "truncate"
178
+ )}
179
+ >
180
+ <span className={cn(item.status === "failed" && "text-destructive")}>
181
+ {lead}
182
+ </span>
183
+ {after &&
184
+ (target ? (
185
+ <>
186
+ {" "}
187
+ <a
188
+ href={sessionLink("", target)}
189
+ onClick={(e) => {
190
+ // The row opens it too; the link alone handles its click.
191
+ e.stopPropagation();
192
+ // A plain click opens it here; a modified one, a new tab.
193
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
194
+ return;
195
+ e.preventDefault();
196
+ ctx.onSelect(target);
197
+ }}
198
+ className="hover:text-foreground underline decoration-dotted underline-offset-2"
199
+ >
200
+ {after}
201
+ </a>
202
+ </>
203
+ ) : (
204
+ ` ${after}`
205
+ ))}
206
+ {item.projectName && ` · ${item.projectName}`}
184
207
  </span>
185
- {after &&
186
- (target ? (
187
- <>
188
- {" "}
189
- <a
190
- href={sessionLink("", target)}
191
- onClick={(e) => {
192
- // The row opens it too; the link alone handles its click.
193
- e.stopPropagation();
194
- // A plain click opens it here; a modified one, a new tab.
195
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
196
- return;
197
- e.preventDefault();
198
- ctx.onSelect(target);
199
- }}
200
- className="hover:text-foreground underline decoration-dotted underline-offset-2"
201
- >
202
- {after}
203
- </a>
204
- </>
205
- ) : (
206
- ` ${after}`
207
- ))}
208
- {item.projectName && ` · ${item.projectName}`}
209
208
  </span>
210
209
  );
211
210
  }
@@ -1,9 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, useState, type ReactNode } from "react";
4
- import { ClipboardList, Pencil } from "lucide-react";
5
- import { NEED_LABEL, type SidebarRow } from "@/lib/sidebar/shelves";
6
- import { rowKindBadge, type RowKindBadge } from "@/lib/sidebar/row-badge";
4
+ import type { SidebarRow } from "@/lib/sidebar/shelves";
7
5
  import { cn } from "@/lib/utils";
8
6
 
9
7
  // Amber when it needs you, red when it failed, green while it works, and a
@@ -28,57 +26,6 @@ export function RowDot({ row }: { row: SidebarRow }) {
28
26
  );
29
27
  }
30
28
 
31
- // What it needs from you, stalled, its plan step, or a purple dot when it
32
- // changed since you looked.
33
- export function RowBadge({ row }: { row: SidebarRow }) {
34
- if (row.need) {
35
- const asks = row.status?.asks ?? 0;
36
- return (
37
- <span
38
- className={cn(
39
- "rounded-md px-1.5 py-0.5 text-[11px] font-semibold",
40
- row.need === "failed"
41
- ? "text-red-600 dark:text-red-400"
42
- : "bg-amber-500/15 text-amber-700 dark:text-amber-400"
43
- )}
44
- >
45
- {NEED_LABEL[row.need]}
46
- {asks > 1 && ` ${asks}`}
47
- </span>
48
- );
49
- }
50
- if (row.status?.stalled)
51
- return (
52
- <span className="rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700 dark:text-amber-400">
53
- Stalled
54
- </span>
55
- );
56
- const kind = rowKindBadge(row);
57
- if (kind) return <KindBadge badge={kind} />;
58
- if (row.unread) return <UnreadDot />;
59
- return null;
60
- }
61
-
62
- // Planning in blue, a plan waiting for approval in the primary colour:
63
- // an icon and a word, never colour alone.
64
- function KindBadge({ badge }: { badge: RowKindBadge }) {
65
- const Icon = badge.kind === "planning" ? Pencil : ClipboardList;
66
- return (
67
- <span
68
- data-tip={badge.tip}
69
- className={cn(
70
- "flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold whitespace-nowrap",
71
- badge.kind === "planning"
72
- ? "bg-blue-500/15 text-blue-700 dark:text-blue-400"
73
- : "bg-primary/15 text-primary"
74
- )}
75
- >
76
- <Icon aria-hidden className="h-3 w-3" />
77
- {badge.label}
78
- </span>
79
- );
80
- }
81
-
82
29
  // What belongs to the row above it (a conductor's workers, an
83
30
  // orchestrator's quick changes), behind a thin rule.
84
31
  export function NestRail({ children }: { children: ReactNode }) {
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
10
10
  export type RowMenuKind = "dropdown" | "context";
11
11
 
12
12
  // Every sidebar row's frame: a leading mark, title over subtitle, what's on
13
- // the right (hidden under the ⋯ on hover), and the same menu on ⋯ and on
13
+ // the right (hidden under the ⋯ on hover: only the time goes there), and the same menu on ⋯ and on
14
14
  // right-click. Session rows and queued tasks both draw through it. The
15
15
  // title is its hover tip (lib/hover-tip), or tipKey's when one resolves.
16
16
  export function RowShell({
@@ -22,7 +22,7 @@ export function RowShell({
22
22
  leading,
23
23
  children,
24
24
  aside,
25
- trailing,
25
+ hoverHidden,
26
26
  menu,
27
27
  menuLabel,
28
28
  menuButton = true,
@@ -35,9 +35,11 @@ export function RowShell({
35
35
  onActivate?: () => void;
36
36
  leading: ReactNode;
37
37
  children: ReactNode;
38
- // Stays on hover, unlike trailing.
38
+ // Stays on hover, unlike hoverHidden.
39
39
  aside?: ReactNode;
40
- trailing?: ReactNode;
40
+ // Hidden on hover, focus and while the menu is open so the ⋯ can sit on
41
+ // it. Only for what may vanish, the timestamp. Status goes on the second line.
42
+ hoverHidden?: ReactNode;
41
43
  // Null hides the ⋯ (and the right-click menu).
42
44
  menu: ((kind: RowMenuKind) => ReactNode) | null;
43
45
  menuLabel: string;
@@ -73,8 +75,13 @@ export function RowShell({
73
75
  {children}
74
76
  </span>
75
77
  {aside}
76
- <span className="flex shrink-0 items-center gap-2 md:group-hover:invisible md:group-has-[:focus-visible]:invisible md:group-has-[[data-state=open]]:invisible">
77
- {trailing}
78
+ {/* Invisible on hover so the ⋯ shows over it: never put status here, it
79
+ belongs on the second line (liveParts). */}
80
+ <span
81
+ data-hover-hidden
82
+ className="flex shrink-0 items-center gap-2 md:group-hover:invisible md:group-has-[:focus-visible]:invisible md:group-has-[[data-state=open]]:invisible"
83
+ >
84
+ {hoverHidden}
78
85
  </span>
79
86
  {menu && menuButton && (
80
87
  <DM.DropdownMenu>
@@ -0,0 +1,114 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Session } from "@/lib/db/types";
7
+ import type { SidebarRow } from "@/lib/sidebar/shelves";
8
+ import { RowProvider, type RowContextValue } from "./RowContext";
9
+ import { SessionRow } from "./SessionRow";
10
+
11
+ (
12
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
13
+ ).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ let host: HTMLElement;
16
+ let root: Root;
17
+ beforeEach(() => {
18
+ host = document.createElement("div");
19
+ document.body.append(host);
20
+ root = createRoot(host);
21
+ });
22
+ afterEach(() => {
23
+ act(() => root.unmount());
24
+ host.remove();
25
+ });
26
+
27
+ const ctx: RowContextValue = {
28
+ summarizingSessionId: null,
29
+ projects: [],
30
+ projectNames: new Map(),
31
+ workspaceNames: new Map(),
32
+ orchestrators: [],
33
+ runningByWorkspace: new Map(),
34
+ queuedByWorkspace: new Map(),
35
+ rollups: new Map(),
36
+ orchestratorNames: new Map(),
37
+ orderedIds: () => [],
38
+ cardUrl: () => null,
39
+ onSelect: vi.fn(),
40
+ onRename: vi.fn(),
41
+ onFork: vi.fn(),
42
+ onSummarize: vi.fn(),
43
+ onDelete: vi.fn(),
44
+ onMoveToProject: vi.fn(),
45
+ onPin: vi.fn(),
46
+ onFresh: vi.fn(),
47
+ };
48
+
49
+ async function render(
50
+ session: Partial<Session>,
51
+ over: Partial<SidebarRow> = {}
52
+ ) {
53
+ const row: SidebarRow = {
54
+ session: {
55
+ id: "s1",
56
+ name: "Fix typo",
57
+ task_status: "running",
58
+ chat_plan: 0,
59
+ ...session,
60
+ } as Session,
61
+ status: undefined,
62
+ need: null,
63
+ unread: false,
64
+ working: true,
65
+ activeAt: Date.now() - 60_000,
66
+ workers: [],
67
+ ...over,
68
+ };
69
+ await act(async () =>
70
+ root.render(
71
+ createElement(
72
+ QueryClientProvider,
73
+ { client: new QueryClient() },
74
+ createElement(
75
+ RowProvider,
76
+ { value: ctx },
77
+ createElement(SessionRow, { row })
78
+ )
79
+ )
80
+ )
81
+ );
82
+ }
83
+
84
+ // The slot that goes invisible on hover so the ⋯ can sit on it.
85
+ const hoverHidden = () => host.querySelector("[data-hover-hidden]")!;
86
+ const badges = () => [...host.querySelectorAll("[data-status-badge]")];
87
+
88
+ describe("status on a session row", () => {
89
+ it.each([
90
+ ["Plan ready", { plan_phase: "awaiting" as const }, {}],
91
+ ["Planning", { plan_phase: "planning" as const }, {}],
92
+ ["Approve", {}, { need: "approve" as const }],
93
+ ["Stalled", {}, { status: { stalled: true } as SidebarRow["status"] }],
94
+ ])("keeps %s out of the hover-hidden slot", async (label, session, over) => {
95
+ await render(session, over);
96
+ const badge = badges().find((b) => b.textContent?.includes(label));
97
+ expect(badge, label).toBeTruthy();
98
+ expect(hoverHidden().contains(badge!)).toBe(false);
99
+ expect(hoverHidden().textContent).not.toContain(label);
100
+ });
101
+
102
+ it("keeps the badge when the session is paused", async () => {
103
+ await render({
104
+ plan_phase: "awaiting" as const,
105
+ pause_state: "snoozed" as never,
106
+ });
107
+ expect(badges().map((b) => b.textContent)).toContain("Plan ready");
108
+ });
109
+
110
+ it("puts the time, and only the time, in the hover-hidden slot", async () => {
111
+ await render({ plan_phase: "awaiting" as const });
112
+ expect(hoverHidden().textContent).toMatch(/\d+m|now|\d+h/);
113
+ });
114
+ });
@@ -62,7 +62,8 @@ export function RowSubtitle({
62
62
  const pause = session.pause_state;
63
63
  return (
64
64
  <span className="flex min-w-0 items-center gap-1.5">
65
- {pause ? (
65
+ <LiveLine {...live} />
66
+ {pause && (
66
67
  <span className="flex min-w-0 items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
67
68
  <Pause aria-hidden className="h-3 w-3 shrink-0" />
68
69
  <span className="truncate">
@@ -75,8 +76,6 @@ export function RowSubtitle({
75
76
  })}
76
77
  </span>
77
78
  </span>
78
- ) : (
79
- <LiveLine {...live} />
80
79
  )}
81
80
  {group && (
82
81
  <span
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { isFork } from "@/lib/sessions/fork";
3
4
  import { memo, useState } from "react";
4
5
  import { useSnapshot } from "valtio";
5
6
  import {
@@ -12,7 +13,7 @@ import {
12
13
  import { compactTimeAgo } from "@/lib/session-meta";
13
14
  import { wakeLabel } from "@/lib/sidebar/snooze";
14
15
  import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
15
- import { rowKindBadge } from "@/lib/sidebar/row-badge";
16
+ import { rowStatusBadge } from "@/lib/sidebar/row-badge";
16
17
  import { useMinuteTick } from "@/hooks/useMinuteTick";
17
18
  import { selectionStore, selectionActions } from "@/stores/sessionSelection";
18
19
  import { cn } from "@/lib/utils";
@@ -20,7 +21,7 @@ import { QuickChangeRow } from "./QuickChangeRow";
20
21
  import { useRowContext } from "./RowContext";
21
22
  import { SessionRowMenu } from "./SessionRowMenu";
22
23
  import { RowShell, type RowMenuKind } from "./RowShell";
23
- import { NestRail, RowBadge, RowDot, RowRename } from "./RowParts";
24
+ import { NestRail, RowDot, RowRename, UnreadDot } from "./RowParts";
24
25
  import { RowSubtitle } from "./RowSubtitle";
25
26
  import { OrchestratorAsks } from "./OrchestratorAsks";
26
27
 
@@ -61,6 +62,8 @@ export const SessionRow = memo(function SessionRow({
61
62
  ctx.onSelect(session.id);
62
63
  };
63
64
 
65
+ const badge = rowStatusBadge(row);
66
+
64
67
  const menu = (kind: RowMenuKind) => (
65
68
  <SessionRowMenu
66
69
  session={session}
@@ -99,17 +102,13 @@ export const SessionRow = memo(function SessionRow({
99
102
  />
100
103
  </span>
101
104
  )}
102
- {session.parent_session_id && (
105
+ {row.unread && !badge && <UnreadDot />}
106
+ {isFork(session) && (
103
107
  <GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
104
108
  )}
105
109
  </>
106
110
  }
107
- trailing={
108
- <>
109
- <RowBadge row={row} />
110
- {!row.need && !rowKindBadge(row) && <RowTime row={row} />}
111
- </>
112
- }
111
+ hoverHidden={!row.need && <RowTime row={row} />}
113
112
  menu={menu}
114
113
  menuLabel="Session actions"
115
114
  menuButton={!selecting}
@@ -139,8 +138,12 @@ export const SessionRow = memo(function SessionRow({
139
138
  detail={row.status?.detail}
140
139
  live={
141
140
  row.need
142
- ? undefined
143
- : { compacting: row.compacting, prStage: row.status?.prStage }
141
+ ? { badge }
142
+ : {
143
+ compacting: row.compacting,
144
+ prStage: row.status?.prStage,
145
+ badge,
146
+ }
144
147
  }
145
148
  />
146
149
  </RowShell>