@saadnvd1/agent-os 0.3.600 → 0.3.602
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Pane/DesktopTabBar.tsx +1 -1
- package/components/Pane/index.tsx +10 -1
- package/components/SessionList/LiveLine.tsx +33 -6
- package/components/SessionList/QueuedRow.tsx +45 -46
- package/components/SessionList/RowParts.tsx +1 -54
- package/components/SessionList/RowShell.tsx +13 -6
- package/components/SessionList/RowStatusPlacement.test.ts +114 -0
- package/components/SessionList/RowSubtitle.tsx +2 -3
- package/components/SessionList/SessionRow.tsx +12 -10
- package/lib/sidebar/live-status.ts +33 -2
- package/lib/sidebar/row-badge.ts +26 -2
- package/package.json +1 -1
|
@@ -120,7 +120,7 @@ export function DesktopTabBar({
|
|
|
120
120
|
className={cn(
|
|
121
121
|
"group relative flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
|
122
122
|
tab.id === activeTabId
|
|
123
|
-
? "bg-foreground/[0.07] text-foreground after:bg-primary after:absolute after:inset-x-3 after
|
|
123
|
+
? "bg-foreground/[0.07] text-foreground [[data-split][data-focused]_&]:after:bg-primary [[data-split][data-focused]_&]:after:absolute [[data-split][data-focused]_&]:after:inset-x-3 [[data-split][data-focused]_&]:after:bottom-0 [[data-split][data-focused]_&]:after:h-0.5"
|
|
124
124
|
: "text-muted-foreground hover:text-foreground/80 hover:bg-foreground/[0.04]"
|
|
125
125
|
)}
|
|
126
126
|
>
|
|
@@ -164,6 +164,7 @@ export const Pane = memo(function Pane({
|
|
|
164
164
|
? (terminalRefs.current.get(activeTab.id) ?? null)
|
|
165
165
|
: null;
|
|
166
166
|
const isFocused = focusedPaneId === paneId;
|
|
167
|
+
const isSplit = canClose;
|
|
167
168
|
const session = activeTab
|
|
168
169
|
? sessions.find((s) => s.id === activeTab.sessionId)
|
|
169
170
|
: null;
|
|
@@ -433,9 +434,17 @@ export const Pane = memo(function Pane({
|
|
|
433
434
|
"flex h-full w-full flex-col overflow-hidden",
|
|
434
435
|
!isMobile &&
|
|
435
436
|
"bg-card rounded-xl shadow-(--elevation) ring-1 transition-shadow duration-200",
|
|
436
|
-
!isMobile &&
|
|
437
|
+
!isMobile &&
|
|
438
|
+
(isSplit && isFocused
|
|
439
|
+
? "ring-foreground/[0.16]"
|
|
440
|
+
: "ring-foreground/[0.07]"),
|
|
441
|
+
!isMobile &&
|
|
442
|
+
"[&>*]:transition-opacity [&>*]:duration-150 focus-within:[&>*]:opacity-100 hover:[&>*]:opacity-100",
|
|
443
|
+
!isMobile && isSplit && !isFocused && "[&>*]:opacity-60"
|
|
437
444
|
)}
|
|
438
445
|
data-pane-id={paneId}
|
|
446
|
+
data-split={isSplit || undefined}
|
|
447
|
+
data-focused={isFocused || undefined}
|
|
439
448
|
onClick={handleFocus}
|
|
440
449
|
>
|
|
441
450
|
{/* Tab Bar - Mobile vs Desktop */}
|
|
@@ -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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
38
|
+
// Stays on hover, unlike hoverHidden.
|
|
39
39
|
aside?: ReactNode;
|
|
40
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
?
|
|
144
|
-
: {
|
|
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 =
|
|
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
|
|
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];
|
package/lib/sidebar/row-badge.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The plan badge a row shows
|
|
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
|
|
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
|
+
}
|