@saadnvd1/agent-os 0.3.600 → 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.
@@ -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
@@ -13,7 +13,7 @@ import {
13
13
  import { compactTimeAgo } from "@/lib/session-meta";
14
14
  import { wakeLabel } from "@/lib/sidebar/snooze";
15
15
  import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
16
- import { rowKindBadge } from "@/lib/sidebar/row-badge";
16
+ import { rowStatusBadge } from "@/lib/sidebar/row-badge";
17
17
  import { useMinuteTick } from "@/hooks/useMinuteTick";
18
18
  import { selectionStore, selectionActions } from "@/stores/sessionSelection";
19
19
  import { cn } from "@/lib/utils";
@@ -21,7 +21,7 @@ import { QuickChangeRow } from "./QuickChangeRow";
21
21
  import { useRowContext } from "./RowContext";
22
22
  import { SessionRowMenu } from "./SessionRowMenu";
23
23
  import { RowShell, type RowMenuKind } from "./RowShell";
24
- import { NestRail, RowBadge, RowDot, RowRename } from "./RowParts";
24
+ import { NestRail, RowDot, RowRename, UnreadDot } from "./RowParts";
25
25
  import { RowSubtitle } from "./RowSubtitle";
26
26
  import { OrchestratorAsks } from "./OrchestratorAsks";
27
27
 
@@ -62,6 +62,8 @@ export const SessionRow = memo(function SessionRow({
62
62
  ctx.onSelect(session.id);
63
63
  };
64
64
 
65
+ const badge = rowStatusBadge(row);
66
+
65
67
  const menu = (kind: RowMenuKind) => (
66
68
  <SessionRowMenu
67
69
  session={session}
@@ -100,17 +102,13 @@ export const SessionRow = memo(function SessionRow({
100
102
  />
101
103
  </span>
102
104
  )}
105
+ {row.unread && !badge && <UnreadDot />}
103
106
  {isFork(session) && (
104
107
  <GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
105
108
  )}
106
109
  </>
107
110
  }
108
- trailing={
109
- <>
110
- <RowBadge row={row} />
111
- {!row.need && !rowKindBadge(row) && <RowTime row={row} />}
112
- </>
113
- }
111
+ hoverHidden={!row.need && <RowTime row={row} />}
114
112
  menu={menu}
115
113
  menuLabel="Session actions"
116
114
  menuButton={!selecting}
@@ -140,8 +138,12 @@ export const SessionRow = memo(function SessionRow({
140
138
  detail={row.status?.detail}
141
139
  live={
142
140
  row.need
143
- ? undefined
144
- : { compacting: row.compacting, prStage: row.status?.prStage }
141
+ ? { badge }
142
+ : {
143
+ compacting: row.compacting,
144
+ prStage: row.status?.prStage,
145
+ badge,
146
+ }
145
147
  }
146
148
  />
147
149
  </RowShell>
@@ -4,16 +4,37 @@ import {
4
4
  type PrStage,
5
5
  type PrStageName,
6
6
  } from "../tasks/pr-stage";
7
+ import type { StatusBadge } from "./row-badge";
7
8
 
8
- export type LiveTone = "blue" | "amber" | "red" | "green" | "purple" | "muted";
9
+ export type LiveTone =
10
+ | "blue"
11
+ | "amber"
12
+ | "red"
13
+ | "green"
14
+ | "purple"
15
+ | "primary"
16
+ | "muted";
9
17
 
10
18
  export interface LivePart {
11
19
  text: string;
12
20
  tone: LiveTone;
13
21
  // Still moving on its own: the UI shows a spinner.
14
22
  busy?: boolean;
23
+ // A status badge (need, stalled, plan step): drawn as a pill, with an icon
24
+ // for the plan kinds.
25
+ badge?: StatusBadge["kind"];
26
+ tip?: string;
15
27
  }
16
28
 
29
+ export const BADGE_TONE: Record<StatusBadge["kind"], LiveTone> = {
30
+ need: "amber",
31
+ failed: "red",
32
+ stalled: "amber",
33
+ starting: "muted",
34
+ planning: "blue",
35
+ "plan-ready": "primary",
36
+ };
37
+
17
38
  export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
18
39
  merged: "purple",
19
40
  conflicts: "amber",
@@ -28,14 +49,24 @@ export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
28
49
  };
29
50
 
30
51
  // What the row's second line says about the session right now, in order:
31
- // "Compacting", then "PR CI". Empty when there is nothing to say.
52
+ // "Compacting", then a status badge (Needs you, Stalled, Planning, Plan
53
+ // ready), then "PR CI". Empty when there is nothing to say.
32
54
  export function liveParts(input: {
33
55
  compacting?: boolean;
34
56
  prStage?: PrStage;
57
+ badge?: StatusBadge | null;
35
58
  }): LivePart[] {
36
59
  const parts: LivePart[] = [];
37
60
  if (input.compacting)
38
61
  parts.push({ text: "Compacting", tone: "blue", busy: true });
62
+ const b = input.badge;
63
+ if (b)
64
+ parts.push({
65
+ text: b.label,
66
+ tone: BADGE_TONE[b.kind],
67
+ badge: b.kind,
68
+ tip: b.tip,
69
+ });
39
70
  const s = input.prStage;
40
71
  if (s) {
41
72
  const word = PR_STAGE_SHORT[s.stage];
@@ -1,7 +1,7 @@
1
- // The plan badge a row shows on its right: writing its plan, or a plan
1
+ // The plan badge a row shows: writing its plan, or a plan
2
2
  // waiting for approval. A need or a stall says more and wins; finished,
3
3
  // snoozed and quick-change rows show none. Client-safe.
4
- import type { SidebarRow } from "./shelves";
4
+ import { NEED_LABEL, type SidebarRow } from "./shelves";
5
5
 
6
6
  export type RowKindBadge = {
7
7
  kind: "planning" | "plan-ready";
@@ -38,3 +38,27 @@ export function rowKindBadge(
38
38
  };
39
39
  return null;
40
40
  }
41
+
42
+ // The one status badge a row's second line carries: what it needs from you,
43
+ // stalled, or its plan step. They are mutually exclusive by precedence. The
44
+ // queued rows' Failed/Starting use the same shape.
45
+ export type StatusBadge = {
46
+ kind: "need" | "failed" | "stalled" | "starting" | RowKindBadge["kind"];
47
+ label: string;
48
+ tip?: string;
49
+ };
50
+
51
+ export function rowStatusBadge(
52
+ row: Parameters<typeof rowKindBadge>[0] &
53
+ Pick<SidebarRow, "need"> & { status?: SidebarRow["status"] }
54
+ ): StatusBadge | null {
55
+ if (row.need) {
56
+ const asks = row.status?.asks ?? 0;
57
+ return {
58
+ kind: row.need === "failed" ? "failed" : "need",
59
+ label: `${NEED_LABEL[row.need]}${asks > 1 ? ` ${asks}` : ""}`,
60
+ };
61
+ }
62
+ if (row.status?.stalled) return { kind: "stalled", label: "Stalled" };
63
+ return rowKindBadge(row);
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.600",
3
+ "version": "0.3.601",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"