@saadnvd1/agent-os 0.3.592 → 0.3.595
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/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/Providers.tsx +5 -1
- package/components/SessionList/LiveLine.tsx +1 -1
- package/components/SessionList/OrchestratorPinRow.tsx +10 -5
- package/components/SessionList/QuickChangeRow.tsx +1 -1
- package/components/SessionList/RowParts.tsx +37 -2
- package/components/SessionList/RowShell.tsx +6 -2
- package/components/SessionList/RowSubtitle.tsx +20 -12
- package/components/SessionList/SessionRow.tsx +28 -17
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/SessionShelves.tsx +40 -8
- package/components/SessionList/SidebarExtras.tsx +7 -1
- package/components/SessionList/index.tsx +2 -0
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/SessionList/useSessionTips.ts +62 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/components/ui/HoverTip.tsx +20 -0
- package/data/done/index.ts +17 -0
- package/data/presence/index.ts +14 -7
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/hover-tip.ts +161 -0
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/row-badge.ts +40 -0
- package/lib/sidebar/row-details.ts +53 -0
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const resurrect = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("@/lib/resurrect", () => ({ resurrectSession: resurrect }));
|
|
6
|
+
|
|
7
|
+
const { db } = await import("@/lib/db");
|
|
8
|
+
const { seedWorkspace } = await import("@/lib/orchestrator/testing");
|
|
9
|
+
const { POST } = await import("./route");
|
|
10
|
+
|
|
11
|
+
const call = (body: object) =>
|
|
12
|
+
POST(
|
|
13
|
+
new NextRequest("http://localhost/api/bus/resurrect", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("POST /api/bus/resurrect (aos resurrect)", () => {
|
|
20
|
+
it("runs only from inside a session", async () => {
|
|
21
|
+
const res = await call({ session: "x" });
|
|
22
|
+
expect(res.status).toBe(409);
|
|
23
|
+
expect((await res.json()).error).toMatch(/inside an AgentOS session/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reaches only the caller's workspace, and lists what ended with no session", async () => {
|
|
27
|
+
const mine = seedWorkspace("Bus mine");
|
|
28
|
+
const other = seedWorkspace("Bus other");
|
|
29
|
+
db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
|
|
30
|
+
mine.task
|
|
31
|
+
);
|
|
32
|
+
db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
|
|
33
|
+
other.task
|
|
34
|
+
);
|
|
35
|
+
const list = await (await call({ from: mine.chat })).json();
|
|
36
|
+
expect(list.sessions.map((s: { id: string }) => s.id)).toEqual([mine.task]);
|
|
37
|
+
|
|
38
|
+
const refused = await call({ from: mine.chat, session: other.task });
|
|
39
|
+
expect(refused.status).toBe(409);
|
|
40
|
+
expect(resurrect).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
resurrect.mockResolvedValueOnce({ text: "back" });
|
|
43
|
+
const ok = await call({ from: mine.chat, session: mine.task });
|
|
44
|
+
expect((await ok.json()).summary).toBe("back");
|
|
45
|
+
expect(resurrect).toHaveBeenCalledWith(mine.task);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getDoneTarget } from "@/lib/done";
|
|
3
|
+
import { scopeOf } from "@/lib/done/bulk";
|
|
4
|
+
import { resurrectSession } from "@/lib/resurrect";
|
|
5
|
+
import { endedSessions, resolveEnded } from "@/lib/resurrect/find";
|
|
6
|
+
|
|
7
|
+
// `aos resurrect [session]`, from inside a session: reaches only the
|
|
8
|
+
// caller's workspace (or its project when it has none). Without a session it
|
|
9
|
+
// lists the dropped and done ones that can come back.
|
|
10
|
+
export async function POST(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
const { from = null, session } = await request.json();
|
|
13
|
+
if (typeof from !== "string" || !from)
|
|
14
|
+
throw new Error("aos resurrect runs from inside an AgentOS session");
|
|
15
|
+
const scope = scopeOf(getDoneTarget(from));
|
|
16
|
+
if (typeof session !== "string" || !session.trim()) {
|
|
17
|
+
const ended = endedSessions(scope, { limit: 15 });
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
sessions: ended,
|
|
20
|
+
summary: ended.length
|
|
21
|
+
? ended
|
|
22
|
+
.map(
|
|
23
|
+
(e) =>
|
|
24
|
+
`${e.id.slice(0, 8)} ${e.kind.padEnd(8)} ${e.projectName ? `${e.projectName}/` : ""}${e.name}`
|
|
25
|
+
)
|
|
26
|
+
.join("\n")
|
|
27
|
+
: "Nothing dropped, done or archived to restore.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const outcome = await resurrectSession(resolveEnded(session, scope));
|
|
31
|
+
return NextResponse.json({ summary: outcome.text, outcome });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -89,8 +89,11 @@ export async function DELETE(
|
|
|
89
89
|
) {
|
|
90
90
|
const { id } = await params;
|
|
91
91
|
try {
|
|
92
|
-
await resumeNow(id, "person");
|
|
93
|
-
return NextResponse.json({
|
|
92
|
+
const out = await resumeNow(id, "person");
|
|
93
|
+
return NextResponse.json({
|
|
94
|
+
pause: pauseViewOf(id),
|
|
95
|
+
...(out.outcome === "held" && { note: out.note }),
|
|
96
|
+
});
|
|
94
97
|
} catch (error) {
|
|
95
98
|
return fail(error);
|
|
96
99
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const resurrect = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("@/lib/resurrect", () => ({ resurrectSession: resurrect }));
|
|
6
|
+
|
|
7
|
+
const { POST } = await import("./route");
|
|
8
|
+
|
|
9
|
+
const call = (id: string) =>
|
|
10
|
+
POST(
|
|
11
|
+
new NextRequest(`http://localhost/api/sessions/${id}/resurrect`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
}),
|
|
14
|
+
{ params: Promise.resolve({ id }) }
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("POST /api/sessions/:id/resurrect", () => {
|
|
18
|
+
it("answers with the outcome", async () => {
|
|
19
|
+
resurrect.mockResolvedValueOnce({ id: "s1", text: "back" });
|
|
20
|
+
const res = await call("s1");
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
expect((await res.json()).outcome).toEqual({ id: "s1", text: "back" });
|
|
23
|
+
expect(resurrect).toHaveBeenCalledWith("s1", { device: null });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("answers a refusal with 409 and the reason", async () => {
|
|
27
|
+
resurrect.mockRejectedValueOnce(new Error("its PR #3 was merged"));
|
|
28
|
+
const res = await call("s2");
|
|
29
|
+
expect(res.status).toBe(409);
|
|
30
|
+
expect(await res.json()).toEqual({ error: "its PR #3 was merged" });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { resurrectSession } from "@/lib/resurrect";
|
|
3
|
+
import { callingDevice } from "@/lib/security/device-me";
|
|
4
|
+
import { db, queries, type Session } from "@/lib/db";
|
|
5
|
+
|
|
6
|
+
// POST /api/sessions/:id/resurrect - bring a dropped, done or archived
|
|
7
|
+
// session back: same id, same conversation, its status as it was. A refusal
|
|
8
|
+
// (merged PR, conversation gone, project removed) is a 409 with the reason.
|
|
9
|
+
export async function POST(
|
|
10
|
+
request: NextRequest,
|
|
11
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
12
|
+
) {
|
|
13
|
+
try {
|
|
14
|
+
const id = (await params).id;
|
|
15
|
+
const outcome = await resurrectSession(id, {
|
|
16
|
+
device: callingDevice(request),
|
|
17
|
+
});
|
|
18
|
+
const session = queries.getSession(db).get(id) as Session | undefined;
|
|
19
|
+
return NextResponse.json({ outcome, session });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { endedSessions, type EndedKind } from "@/lib/resurrect/find";
|
|
3
|
+
|
|
4
|
+
const KINDS = new Set(["dropped", "done", "archived"]);
|
|
5
|
+
|
|
6
|
+
// GET /api/sessions/ended[?workspace=id][&kind=dropped|done|archived][&limit=n]
|
|
7
|
+
// - what can be restored, most recently ended first.
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
const q = request.nextUrl.searchParams;
|
|
10
|
+
const workspace = q.get("workspace");
|
|
11
|
+
const kind = q.get("kind");
|
|
12
|
+
const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200);
|
|
13
|
+
return NextResponse.json({
|
|
14
|
+
sessions: endedSessions(workspace ? { workspaceId: workspace } : null, {
|
|
15
|
+
limit,
|
|
16
|
+
kind: kind && KINDS.has(kind) ? (kind as EndedKind) : undefined,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { ArchiveRestore, ExternalLink, Loader2 } from "lucide-react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
-
import {
|
|
6
|
+
import { useResurrectSession } from "@/data/done";
|
|
7
7
|
import type { ArchivedView } from "@/lib/done/archive";
|
|
8
8
|
import { fromSqliteTime } from "@/lib/session-meta";
|
|
9
9
|
|
|
@@ -24,7 +24,7 @@ function when(at: string): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function ArchivedRow({ session }: { session: ArchivedView }) {
|
|
27
|
-
const
|
|
27
|
+
const restore = useResurrectSession();
|
|
28
28
|
const kind = session.taskStatus ? STATUS[session.taskStatus] : "Session";
|
|
29
29
|
|
|
30
30
|
return (
|
|
@@ -70,20 +70,24 @@ export function ArchivedRow({ session }: { session: ArchivedView }) {
|
|
|
70
70
|
? "Puts the entry back in the sidebar; its worktree was removed"
|
|
71
71
|
: undefined
|
|
72
72
|
}
|
|
73
|
-
disabled={
|
|
73
|
+
disabled={restore.isPending}
|
|
74
74
|
onClick={() =>
|
|
75
|
-
|
|
76
|
-
onSuccess: () => toast.success(
|
|
75
|
+
restore.mutate(session.id, {
|
|
76
|
+
onSuccess: (outcome) => toast.success(outcome.text),
|
|
77
77
|
onError: (e) => toast.error(e.message),
|
|
78
78
|
})
|
|
79
79
|
}
|
|
80
80
|
>
|
|
81
|
-
{
|
|
81
|
+
{restore.isPending ? (
|
|
82
82
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
83
83
|
) : (
|
|
84
84
|
<ArchiveRestore className="h-3.5 w-3.5" />
|
|
85
85
|
)}
|
|
86
|
-
{session.
|
|
86
|
+
{session.taskStatus === "dropped"
|
|
87
|
+
? "Restore"
|
|
88
|
+
: session.worktreeRemoved
|
|
89
|
+
? "Restore entry"
|
|
90
|
+
: "Unarchive"}
|
|
87
91
|
</Button>
|
|
88
92
|
)}
|
|
89
93
|
</div>
|
|
@@ -28,7 +28,7 @@ export function PausedBanner({ sessionId }: { sessionId: string }) {
|
|
|
28
28
|
className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
|
|
29
29
|
/>
|
|
30
30
|
<p className="min-w-0 flex-1 text-sm">
|
|
31
|
-
{pauseSubtitle({ state, until })}
|
|
31
|
+
{pauseSubtitle({ state, until, note: session.pause_note })}
|
|
32
32
|
<span className="text-muted-foreground block text-xs">
|
|
33
33
|
Messages you send now are held and delivered together when it resumes.
|
|
34
34
|
</span>
|
package/components/Providers.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|
|
4
4
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
5
5
|
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
|
|
6
6
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
|
+
import { HoverTip } from "@/components/ui/HoverTip";
|
|
7
8
|
import { createQueryClient } from "@/lib/query-client";
|
|
8
9
|
import { parseTheme, getAllThemes } from "@/lib/theme-config";
|
|
9
10
|
|
|
@@ -41,7 +42,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|
|
41
42
|
themes={getAllThemes()}
|
|
42
43
|
>
|
|
43
44
|
<ThemeClassHandler>
|
|
44
|
-
<TooltipProvider delayDuration={200}>
|
|
45
|
+
<TooltipProvider delayDuration={200}>
|
|
46
|
+
{children}
|
|
47
|
+
<HoverTip />
|
|
48
|
+
</TooltipProvider>
|
|
45
49
|
</ThemeClassHandler>
|
|
46
50
|
</NextThemesProvider>
|
|
47
51
|
</QueryClientProvider>
|
|
@@ -16,6 +16,7 @@ import { useRowContext } from "./RowContext";
|
|
|
16
16
|
import { SessionRowMenu } from "./SessionRowMenu";
|
|
17
17
|
import { RowRename, UnreadDot } from "./RowParts";
|
|
18
18
|
import { QuickChangeRow } from "./QuickChangeRow";
|
|
19
|
+
import { NestRail } from "./RowParts";
|
|
19
20
|
|
|
20
21
|
// A workspace's orchestrator, pinned above its sessions and titled with the
|
|
21
22
|
// workspace's name: what it's doing and how many asks wait on you, on a line
|
|
@@ -54,7 +55,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
54
55
|
|
|
55
56
|
const content = (
|
|
56
57
|
<RowFrame
|
|
57
|
-
|
|
58
|
+
data-tip={`${label}\n${summary}`}
|
|
58
59
|
onActivate={() => !renaming && ctx.onSelect(session.id)}
|
|
59
60
|
className={
|
|
60
61
|
active
|
|
@@ -172,9 +173,13 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
172
173
|
{menu("context")}
|
|
173
174
|
</CM.ContextMenuContent>
|
|
174
175
|
</CM.ContextMenu>
|
|
175
|
-
{row.quick?.
|
|
176
|
-
<
|
|
177
|
-
|
|
176
|
+
{!!row.quick?.length && (
|
|
177
|
+
<NestRail>
|
|
178
|
+
{row.quick.map((q) => (
|
|
179
|
+
<QuickChangeRow key={q.session.id} row={q} />
|
|
180
|
+
))}
|
|
181
|
+
</NestRail>
|
|
182
|
+
)}
|
|
178
183
|
</>
|
|
179
184
|
);
|
|
180
185
|
}, sameProps);
|
|
@@ -190,7 +195,7 @@ export function OrchestratorStartRow({
|
|
|
190
195
|
}) {
|
|
191
196
|
return (
|
|
192
197
|
<RowFrame
|
|
193
|
-
|
|
198
|
+
data-tip={`${name}\nStart orchestrator`}
|
|
194
199
|
onActivate={() => orchestratorOpenActions.request(workspaceId)}
|
|
195
200
|
className="hover:bg-muted/50 border-dashed"
|
|
196
201
|
>
|
|
@@ -42,7 +42,7 @@ export function QuickChangeRow({ row }: { row: SidebarRow }) {
|
|
|
42
42
|
);
|
|
43
43
|
const openPr = pr ? () => window.open(pr, "_blank") : undefined;
|
|
44
44
|
return (
|
|
45
|
-
<div
|
|
45
|
+
<div>
|
|
46
46
|
<RowShell
|
|
47
47
|
title={`Quick change: ${session.name}\n${label}`}
|
|
48
48
|
onActivate={openPr}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { ClipboardList, Pencil } from "lucide-react";
|
|
4
5
|
import { NEED_LABEL, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
6
|
+
import { rowKindBadge, type RowKindBadge } from "@/lib/sidebar/row-badge";
|
|
5
7
|
import { cn } from "@/lib/utils";
|
|
6
8
|
|
|
7
9
|
// Amber when it needs you, red when it failed, green while it works, and a
|
|
@@ -26,7 +28,8 @@ export function RowDot({ row }: { row: SidebarRow }) {
|
|
|
26
28
|
);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
// What it needs from you, stalled, or a purple dot when it
|
|
31
|
+
// What it needs from you, stalled, its plan step, or a purple dot when it
|
|
32
|
+
// changed since you looked.
|
|
30
33
|
export function RowBadge({ row }: { row: SidebarRow }) {
|
|
31
34
|
if (row.need) {
|
|
32
35
|
const asks = row.status?.asks ?? 0;
|
|
@@ -50,10 +53,42 @@ export function RowBadge({ row }: { row: SidebarRow }) {
|
|
|
50
53
|
Stalled
|
|
51
54
|
</span>
|
|
52
55
|
);
|
|
56
|
+
const kind = rowKindBadge(row);
|
|
57
|
+
if (kind) return <KindBadge badge={kind} />;
|
|
53
58
|
if (row.unread) return <UnreadDot />;
|
|
54
59
|
return null;
|
|
55
60
|
}
|
|
56
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
|
+
// What belongs to the row above it (a conductor's workers, an
|
|
83
|
+
// orchestrator's quick changes), behind a thin rule.
|
|
84
|
+
export function NestRail({ children }: { children: ReactNode }) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="border-foreground/10 ml-3.5 border-l pl-1.5">
|
|
87
|
+
{children}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
export function UnreadDot() {
|
|
58
93
|
return (
|
|
59
94
|
<span
|
|
@@ -11,9 +11,11 @@ export type RowMenuKind = "dropdown" | "context";
|
|
|
11
11
|
|
|
12
12
|
// Every sidebar row's frame: a leading mark, title over subtitle, what's on
|
|
13
13
|
// the right (hidden under the ⋯ on hover), and the same menu on ⋯ and on
|
|
14
|
-
// right-click. Session rows and queued tasks both draw through it.
|
|
14
|
+
// right-click. Session rows and queued tasks both draw through it. The
|
|
15
|
+
// title is its hover tip (lib/hover-tip), or tipKey's when one resolves.
|
|
15
16
|
export function RowShell({
|
|
16
17
|
title,
|
|
18
|
+
tipKey,
|
|
17
19
|
highlight,
|
|
18
20
|
onClick,
|
|
19
21
|
onActivate,
|
|
@@ -26,6 +28,7 @@ export function RowShell({
|
|
|
26
28
|
menuButton = true,
|
|
27
29
|
}: {
|
|
28
30
|
title: string;
|
|
31
|
+
tipKey?: string;
|
|
29
32
|
highlight?: "selected" | "active" | null;
|
|
30
33
|
onClick?: (e: React.MouseEvent) => void;
|
|
31
34
|
// Enter or Space on the row; without it the row isn't a button.
|
|
@@ -45,7 +48,8 @@ export function RowShell({
|
|
|
45
48
|
<div
|
|
46
49
|
role={onActivate ? "button" : undefined}
|
|
47
50
|
tabIndex={onActivate ? 0 : undefined}
|
|
48
|
-
|
|
51
|
+
data-tip={title}
|
|
52
|
+
data-tip-key={tipKey}
|
|
49
53
|
onClick={onClick}
|
|
50
54
|
onKeyDown={(e) => {
|
|
51
55
|
if (!onActivate || e.target !== e.currentTarget) return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Repeat, Layers } from "lucide-react";
|
|
3
|
+
import { Repeat, Layers, Pause } from "lucide-react";
|
|
4
4
|
import { HostBadge } from "@/components/Hosts";
|
|
5
5
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
6
6
|
import type { Session } from "@/lib/db";
|
|
@@ -21,10 +21,12 @@ export function RowSubtitle({
|
|
|
21
21
|
session,
|
|
22
22
|
detail,
|
|
23
23
|
live,
|
|
24
|
+
hideHost = false,
|
|
24
25
|
}: {
|
|
25
26
|
session: Session;
|
|
26
27
|
detail?: string | null;
|
|
27
28
|
live?: LiveInput;
|
|
29
|
+
hideHost?: boolean;
|
|
28
30
|
}) {
|
|
29
31
|
const ctx = useRowContext();
|
|
30
32
|
// What its processes use, on a busy session: "3.1 cores · 4.2 GB".
|
|
@@ -61,28 +63,34 @@ export function RowSubtitle({
|
|
|
61
63
|
return (
|
|
62
64
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
63
65
|
{pause ? (
|
|
64
|
-
<span className="min-w-0
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
:
|
|
70
|
-
|
|
66
|
+
<span className="flex min-w-0 items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
|
67
|
+
<Pause aria-hidden className="h-3 w-3 shrink-0" />
|
|
68
|
+
<span className="truncate">
|
|
69
|
+
{pauseSubtitle({
|
|
70
|
+
state: pause,
|
|
71
|
+
until: session.paused_until
|
|
72
|
+
? Date.parse(`${session.paused_until.replace(" ", "T")}Z`)
|
|
73
|
+
: null,
|
|
74
|
+
note: session.pause_note,
|
|
75
|
+
})}
|
|
76
|
+
</span>
|
|
71
77
|
</span>
|
|
72
78
|
) : (
|
|
73
79
|
<LiveLine {...live} />
|
|
74
80
|
)}
|
|
75
81
|
{group && (
|
|
76
82
|
<span
|
|
77
|
-
className="text-muted-foreground/70
|
|
78
|
-
|
|
83
|
+
className="text-muted-foreground/70 flex min-w-0 shrink-0 items-center gap-1 truncate text-xs @[14rem]:shrink"
|
|
84
|
+
data-tip={`${group.kind === "recipe" ? "Recipe run" : "Plan piece"}: ${group.text}`}
|
|
79
85
|
>
|
|
80
86
|
{group.kind === "recipe" ? (
|
|
81
87
|
<Repeat aria-hidden className="h-3 w-3 shrink-0" />
|
|
82
88
|
) : (
|
|
83
89
|
<Layers aria-hidden className="h-3 w-3 shrink-0" />
|
|
84
90
|
)}
|
|
85
|
-
<span className="truncate">
|
|
91
|
+
<span className="sr-only truncate @[14rem]:not-sr-only">
|
|
92
|
+
{group.text}
|
|
93
|
+
</span>
|
|
86
94
|
</span>
|
|
87
95
|
)}
|
|
88
96
|
{(project || detail || usage || session.placed_note) && (
|
|
@@ -97,7 +105,7 @@ export function RowSubtitle({
|
|
|
97
105
|
.join(" · ")}
|
|
98
106
|
</span>
|
|
99
107
|
)}
|
|
100
|
-
<HostBadge hostId={session.host_id} />
|
|
108
|
+
{!hideHost && <HostBadge hostId={session.host_id} />}
|
|
101
109
|
</span>
|
|
102
110
|
);
|
|
103
111
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { compactTimeAgo } from "@/lib/session-meta";
|
|
13
13
|
import { wakeLabel } from "@/lib/sidebar/snooze";
|
|
14
14
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
15
|
+
import { rowKindBadge } from "@/lib/sidebar/row-badge";
|
|
15
16
|
import { useMinuteTick } from "@/hooks/useMinuteTick";
|
|
16
17
|
import { selectionStore, selectionActions } from "@/stores/sessionSelection";
|
|
17
18
|
import { cn } from "@/lib/utils";
|
|
@@ -19,7 +20,7 @@ import { QuickChangeRow } from "./QuickChangeRow";
|
|
|
19
20
|
import { useRowContext } from "./RowContext";
|
|
20
21
|
import { SessionRowMenu } from "./SessionRowMenu";
|
|
21
22
|
import { RowShell, type RowMenuKind } from "./RowShell";
|
|
22
|
-
import { RowBadge, RowDot, RowRename } from "./RowParts";
|
|
23
|
+
import { NestRail, RowBadge, RowDot, RowRename } from "./RowParts";
|
|
23
24
|
import { RowSubtitle } from "./RowSubtitle";
|
|
24
25
|
import { OrchestratorAsks } from "./OrchestratorAsks";
|
|
25
26
|
|
|
@@ -29,11 +30,12 @@ import { OrchestratorAsks } from "./OrchestratorAsks";
|
|
|
29
30
|
export const SessionRow = memo(function SessionRow({
|
|
30
31
|
row,
|
|
31
32
|
muted = false,
|
|
32
|
-
|
|
33
|
+
inMachineShelf = false,
|
|
33
34
|
}: {
|
|
34
35
|
row: SidebarRow;
|
|
35
36
|
muted?: boolean;
|
|
36
|
-
|
|
37
|
+
// Under its machine's own shelf, which already names the machine.
|
|
38
|
+
inMachineShelf?: boolean;
|
|
37
39
|
}) {
|
|
38
40
|
const ctx = useRowContext();
|
|
39
41
|
const { selectedIds } = useSnapshot(selectionStore);
|
|
@@ -69,9 +71,10 @@ export const SessionRow = memo(function SessionRow({
|
|
|
69
71
|
);
|
|
70
72
|
|
|
71
73
|
return (
|
|
72
|
-
<div
|
|
74
|
+
<div>
|
|
73
75
|
<RowShell
|
|
74
76
|
title={session.name}
|
|
77
|
+
tipKey={`session:${session.id}`}
|
|
75
78
|
highlight={selected ? "selected" : active ? "active" : null}
|
|
76
79
|
onClick={onClick}
|
|
77
80
|
onActivate={() => ctx.onSelect(session.id)}
|
|
@@ -89,7 +92,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
89
92
|
aside={
|
|
90
93
|
<>
|
|
91
94
|
{viaName && (
|
|
92
|
-
<span
|
|
95
|
+
<span data-tip={`Started by ${viaName}`} className="shrink-0">
|
|
93
96
|
<Workflow
|
|
94
97
|
aria-label={`Via orchestrator: ${viaName}`}
|
|
95
98
|
className="text-muted-foreground/60 h-3 w-3"
|
|
@@ -104,7 +107,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
104
107
|
trailing={
|
|
105
108
|
<>
|
|
106
109
|
<RowBadge row={row} />
|
|
107
|
-
{!row.need && <RowTime row={row} />}
|
|
110
|
+
{!row.need && !rowKindBadge(row) && <RowTime row={row} />}
|
|
108
111
|
</>
|
|
109
112
|
}
|
|
110
113
|
menu={menu}
|
|
@@ -132,6 +135,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
132
135
|
)}
|
|
133
136
|
<RowSubtitle
|
|
134
137
|
session={session}
|
|
138
|
+
hideHost={inMachineShelf}
|
|
135
139
|
detail={row.status?.detail}
|
|
136
140
|
live={
|
|
137
141
|
row.need
|
|
@@ -143,21 +147,28 @@ export const SessionRow = memo(function SessionRow({
|
|
|
143
147
|
{session.role === "orchestrator" && row.need && (
|
|
144
148
|
<OrchestratorAsks workspaceId={session.workspace_id} />
|
|
145
149
|
)}
|
|
146
|
-
{row.workers.
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
{(row.workers.length > 0 || !!row.quick?.length) && (
|
|
151
|
+
<NestRail>
|
|
152
|
+
{row.workers.map((worker) => (
|
|
153
|
+
<SessionRow key={worker.session.id} row={worker} muted={muted} />
|
|
154
|
+
))}
|
|
155
|
+
{row.quick?.map((q) => (
|
|
156
|
+
<QuickChangeRow key={q.session.id} row={q} />
|
|
157
|
+
))}
|
|
158
|
+
</NestRail>
|
|
159
|
+
)}
|
|
152
160
|
</div>
|
|
153
161
|
);
|
|
154
162
|
}, sameRowProps);
|
|
155
163
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
type RowProps = { row: SidebarRow; muted?: boolean; inMachineShelf?: boolean };
|
|
165
|
+
|
|
166
|
+
function sameRowProps(a: RowProps, b: RowProps) {
|
|
167
|
+
return (
|
|
168
|
+
a.muted === b.muted &&
|
|
169
|
+
a.inMachineShelf === b.inMachineShelf &&
|
|
170
|
+
sameRow(a.row, b.row)
|
|
171
|
+
);
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
// Snoozed: when it wakes. Otherwise when it last did something.
|
|
@@ -34,7 +34,11 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
|
34
34
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
35
35
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
36
36
|
import { finishActions } from "@/lib/sidebar/row-actions";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
useArchiveAction,
|
|
39
|
+
useDoneAction,
|
|
40
|
+
useResurrectAction,
|
|
41
|
+
} from "./useDoneAction";
|
|
38
42
|
import { useShelfActions } from "./useShelfActions";
|
|
39
43
|
import {
|
|
40
44
|
PAUSE_PRESETS,
|
|
@@ -106,6 +110,7 @@ function FinishItems({
|
|
|
106
110
|
}) {
|
|
107
111
|
const markDone = useDoneAction();
|
|
108
112
|
const archive = useArchiveAction();
|
|
113
|
+
const restore = useResurrectAction();
|
|
109
114
|
const actions = finishActions(session, plain);
|
|
110
115
|
return (
|
|
111
116
|
<>
|
|
@@ -115,6 +120,12 @@ function FinishItems({
|
|
|
115
120
|
Done
|
|
116
121
|
</Item>
|
|
117
122
|
)}
|
|
123
|
+
{actions.includes("restore") && (
|
|
124
|
+
<Item onClick={() => restore(session)}>
|
|
125
|
+
<RotateCcw className={icon} />
|
|
126
|
+
Restore
|
|
127
|
+
</Item>
|
|
128
|
+
)}
|
|
118
129
|
{actions.includes("archive") && (
|
|
119
130
|
<Item onClick={() => archive(session)}>
|
|
120
131
|
<Archive className={icon} />
|