@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.
- package/app/api/bus/spawn/route.ts +35 -16
- package/app/api/lumifyhub/cards/[cardId]/run/route.ts +3 -0
- package/app/api/tasks/route.ts +3 -0
- package/app/page.tsx +2 -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 +14 -11
- package/lib/agents/spawn.ts +16 -4
- package/lib/chat/refresh.ts +2 -1
- package/lib/chat/runner.ts +29 -0
- package/lib/chat/store.ts +53 -0
- package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
- package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
- package/lib/db/types.ts +2 -0
- package/lib/learning-cards/apply.ts +7 -1
- package/lib/models/plan-step.ts +25 -8
- package/lib/models/resolve.ts +1 -1
- package/lib/models/resume.ts +1 -1
- package/lib/models/roles.ts +0 -16
- package/lib/models/session-role.ts +19 -0
- package/lib/orchestrator/act.ts +22 -7
- package/lib/orchestrator/amendments.ts +10 -3
- package/lib/orchestrator/serve.ts +1 -0
- package/lib/orchestrator/testing.ts +8 -0
- package/lib/orchestrator/tool-schemas.ts +9 -0
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/plans/claim.ts +131 -0
- package/lib/plans/release.ts +5 -2
- package/lib/recipes/run.ts +6 -0
- package/lib/schedules/start.ts +1 -0
- package/lib/sessions/fork.ts +10 -0
- package/lib/sessions/launch.ts +5 -1
- package/lib/sidebar/live-status.ts +33 -2
- package/lib/sidebar/row-badge.ts +26 -2
- package/lib/stacks/start.ts +1 -0
- package/lib/tasks/held-remote.ts +4 -2
- package/lib/tasks/held-replies.ts +5 -2
- package/lib/tasks/held.ts +2 -1
- package/lib/tasks/index.ts +85 -46
- package/lib/tasks/move-carry.ts +12 -1
- package/lib/tasks/move-guard.ts +2 -1
- package/lib/tasks/move.ts +7 -5
- package/lib/tasks/pause.ts +1 -1
- package/lib/tasks/queue.ts +54 -31
- package/lib/tasks/remote.ts +3 -1
- package/lib/tasks/starter.ts +124 -0
- package/lib/usage/calls.ts +2 -1
- package/lib/usage/turns.ts +2 -1
- 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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
@@ -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 {
|
|
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,
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
?
|
|
143
|
-
: {
|
|
141
|
+
? { badge }
|
|
142
|
+
: {
|
|
143
|
+
compacting: row.compacting,
|
|
144
|
+
prStage: row.status?.prStage,
|
|
145
|
+
badge,
|
|
146
|
+
}
|
|
144
147
|
}
|
|
145
148
|
/>
|
|
146
149
|
</RowShell>
|