@saadnvd1/agent-os 0.3.542 → 0.3.544
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/sessions/[id]/settle/route.ts +22 -0
- package/app/api/sessions/[id]/snooze/route.ts +36 -0
- package/components/Away/AwayToggle.tsx +1 -1
- package/components/SessionList/LoadGauge.tsx +1 -1
- package/components/SessionList/OrchestratorPinRow.test.ts +1 -0
- package/components/SessionList/ProjectFilter.tsx +1 -1
- package/components/SessionList/RowShell.tsx +1 -1
- package/components/SessionList/RowSubtitle.tsx +2 -2
- package/components/SessionList/SessionRow.tsx +22 -6
- package/components/SessionList/SessionRowMenu.tsx +57 -2
- package/components/SessionList/SessionShelves.tsx +28 -2
- package/components/SessionList/SidebarBody.tsx +3 -0
- package/components/SessionList/SidebarHeader.tsx +6 -6
- package/components/SessionList/SidebarSearch.tsx +2 -2
- package/components/SessionList/WorkspaceSwitcher.tsx +4 -33
- package/components/SessionList/index.tsx +2 -0
- package/components/SessionList/useShelfActions.ts +49 -0
- package/components/SessionList/useSidebarData.ts +5 -0
- package/components/ThemeToggle.tsx +5 -1
- package/data/sessions/shelf.ts +85 -0
- package/hooks/useMinuteTick.ts +13 -0
- package/lib/chat/registry.ts +6 -0
- package/lib/chat/worker/host.ts +1 -1
- package/lib/db/migrations.ts +21 -0
- package/lib/db/types.ts +7 -0
- package/lib/program-status/store.ts +1 -1
- package/lib/security/demo.ts +3 -1
- package/lib/session-activity.ts +19 -0
- package/lib/session-meta.ts +4 -0
- package/lib/sidebar/machines.ts +2 -0
- package/lib/sidebar/rollup.ts +2 -0
- package/lib/sidebar/shelf-state.ts +47 -0
- package/lib/sidebar/shelves.ts +108 -7
- package/lib/sidebar/snooze.ts +102 -0
- package/lib/status/collect.ts +1 -1
- package/package.json +1 -1
- package/stores/sidebarUi.ts +14 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { reopenSession, settleSession } from "@/lib/sidebar/shelf-state";
|
|
3
|
+
|
|
4
|
+
// POST /api/sessions/:id/settle { settled } - Mark done (true) or Reopen
|
|
5
|
+
// (false). Not an archive: new activity brings a settled session back.
|
|
6
|
+
export async function POST(
|
|
7
|
+
request: Request,
|
|
8
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
9
|
+
) {
|
|
10
|
+
const { id } = await params;
|
|
11
|
+
const body = (await request.json().catch(() => null)) as {
|
|
12
|
+
settled?: unknown;
|
|
13
|
+
} | null;
|
|
14
|
+
if (typeof body?.settled !== "boolean")
|
|
15
|
+
return NextResponse.json(
|
|
16
|
+
{ error: "settled must be a boolean" },
|
|
17
|
+
{ status: 400 }
|
|
18
|
+
);
|
|
19
|
+
if (!(body.settled ? settleSession(id) : reopenSession(id)))
|
|
20
|
+
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
21
|
+
return NextResponse.json({ ok: true });
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
SNOOZE_MAX_MS,
|
|
4
|
+
snoozeSession,
|
|
5
|
+
unsnoozeSession,
|
|
6
|
+
} from "@/lib/sidebar/shelf-state";
|
|
7
|
+
|
|
8
|
+
// POST /api/sessions/:id/snooze { until } - hold it on Snoozed until that
|
|
9
|
+
// ISO time (the reader's own 9am, worked out on their device); null wakes it.
|
|
10
|
+
export async function POST(
|
|
11
|
+
request: Request,
|
|
12
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
13
|
+
) {
|
|
14
|
+
const { id } = await params;
|
|
15
|
+
const body = (await request.json().catch(() => null)) as {
|
|
16
|
+
until?: unknown;
|
|
17
|
+
} | null;
|
|
18
|
+
if (body?.until === undefined)
|
|
19
|
+
return NextResponse.json({ error: "until is required" }, { status: 400 });
|
|
20
|
+
let ok: boolean;
|
|
21
|
+
if (body.until === null) ok = unsnoozeSession(id);
|
|
22
|
+
else {
|
|
23
|
+
const at =
|
|
24
|
+
typeof body.until === "string" ? new Date(body.until) : new Date(NaN);
|
|
25
|
+
const ahead = at.getTime() - Date.now();
|
|
26
|
+
if (Number.isNaN(ahead) || ahead < -60_000 || ahead > SNOOZE_MAX_MS)
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: "until must be a time within the next 60 days" },
|
|
29
|
+
{ status: 400 }
|
|
30
|
+
);
|
|
31
|
+
ok = snoozeSession(id, at);
|
|
32
|
+
}
|
|
33
|
+
if (!ok)
|
|
34
|
+
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
35
|
+
return NextResponse.json({ ok: true });
|
|
36
|
+
}
|
|
@@ -51,7 +51,7 @@ export function AwayToggle() {
|
|
|
51
51
|
}
|
|
52
52
|
disabled={setAway.isPending}
|
|
53
53
|
className={cn(
|
|
54
|
-
"relative h-11 w-11 md:h-8 md:w-8",
|
|
54
|
+
"relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11",
|
|
55
55
|
away &&
|
|
56
56
|
"bg-amber-500/20 text-amber-700 ring-1 ring-amber-500/50 hover:bg-amber-500/30 dark:text-amber-300"
|
|
57
57
|
)}
|
|
@@ -37,7 +37,7 @@ export function LoadGauge() {
|
|
|
37
37
|
size="icon-sm"
|
|
38
38
|
aria-label={`Machine load ${load.level}. ${summary}`}
|
|
39
39
|
title={summary}
|
|
40
|
-
className="-ml-1.5 h-11 w-11 shrink-0 md:ml-0 md:h-8 md:w-6"
|
|
40
|
+
className="-ml-1.5 h-11 w-11 shrink-0 md:ml-0 md:h-8 md:w-6 [@media(hover:none)]:md:-ml-1.5 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
|
|
41
41
|
>
|
|
42
42
|
<span className={cn("h-2 w-2 rounded-full", DOT[load.level])} />
|
|
43
43
|
</Button>
|
|
@@ -25,7 +25,7 @@ export function ProjectFilter({
|
|
|
25
25
|
<button
|
|
26
26
|
type="button"
|
|
27
27
|
className={cn(
|
|
28
|
-
"bg-card flex min-h-11 max-w-[
|
|
28
|
+
"bg-card flex min-h-11 max-w-[7.5rem] shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
|
|
29
29
|
current && "text-primary"
|
|
30
30
|
)}
|
|
31
31
|
>
|
|
@@ -61,7 +61,7 @@ export function RowSubtitle({
|
|
|
61
61
|
<LiveLine {...live} />
|
|
62
62
|
{group && (
|
|
63
63
|
<span
|
|
64
|
-
className="text-muted-foreground/70
|
|
64
|
+
className="text-muted-foreground/70 hidden min-w-0 shrink items-center gap-1 truncate text-xs @[14rem]:flex"
|
|
65
65
|
title={group.kind === "recipe" ? "A recipe run" : "A piece of a plan"}
|
|
66
66
|
>
|
|
67
67
|
{group.kind === "recipe" ? (
|
|
@@ -73,7 +73,7 @@ export function RowSubtitle({
|
|
|
73
73
|
</span>
|
|
74
74
|
)}
|
|
75
75
|
{(project || detail || usage) && (
|
|
76
|
-
<span className="text-muted-foreground/70 truncate text-xs">
|
|
76
|
+
<span className="text-muted-foreground/70 min-w-0 truncate text-xs">
|
|
77
77
|
{[usage && usageLabel(usage), detail?.slice(0, 200), project]
|
|
78
78
|
.filter(Boolean)
|
|
79
79
|
.join(" · ")}
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
AlarmClock,
|
|
7
|
+
CheckSquare,
|
|
8
|
+
GitFork,
|
|
9
|
+
Square,
|
|
10
|
+
Workflow,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { compactTimeAgo } from "@/lib/session-meta";
|
|
13
|
+
import { wakeLabel } from "@/lib/sidebar/snooze";
|
|
7
14
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
8
15
|
import { useMinuteTick } from "@/hooks/useMinuteTick";
|
|
9
16
|
import { selectionStore, selectionActions } from "@/stores/sessionSelection";
|
|
@@ -55,6 +62,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
55
62
|
const menu = (kind: RowMenuKind) => (
|
|
56
63
|
<SessionRowMenu
|
|
57
64
|
session={session}
|
|
65
|
+
row={row}
|
|
58
66
|
kind={kind}
|
|
59
67
|
onRename={() => setRenaming(true)}
|
|
60
68
|
/>
|
|
@@ -96,7 +104,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
96
104
|
trailing={
|
|
97
105
|
<>
|
|
98
106
|
<RowBadge row={row} />
|
|
99
|
-
{!row.need && <
|
|
107
|
+
{!row.need && <RowTime row={row} />}
|
|
100
108
|
</>
|
|
101
109
|
}
|
|
102
110
|
menu={menu}
|
|
@@ -152,11 +160,19 @@ function sameRowProps(
|
|
|
152
160
|
return a.muted === b.muted && a.nested === b.nested && sameRow(a.row, b.row);
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
|
|
163
|
+
// Snoozed: when it wakes. Otherwise when it last did something.
|
|
164
|
+
function RowTime({ row }: { row: SidebarRow }) {
|
|
156
165
|
useMinuteTick();
|
|
157
166
|
return (
|
|
158
|
-
<span className="text-muted-foreground/70 text-xs tabular-nums">
|
|
159
|
-
{
|
|
167
|
+
<span className="text-muted-foreground/70 flex items-center gap-1 text-xs tabular-nums">
|
|
168
|
+
{row.wakesAt ? (
|
|
169
|
+
<>
|
|
170
|
+
<AlarmClock aria-hidden className="h-3 w-3" />
|
|
171
|
+
{wakeLabel(row.wakesAt)}
|
|
172
|
+
</>
|
|
173
|
+
) : (
|
|
174
|
+
compactTimeAgo(new Date(row.activeAt))
|
|
175
|
+
)}
|
|
160
176
|
</span>
|
|
161
177
|
);
|
|
162
178
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
CheckSquare,
|
|
5
|
+
AlarmClock,
|
|
6
|
+
Archive,
|
|
5
7
|
CircleCheck,
|
|
6
8
|
Clock,
|
|
7
9
|
Copy,
|
|
@@ -30,6 +32,9 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
|
30
32
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
31
33
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
32
34
|
import { useDoneAction } from "./useDoneAction";
|
|
35
|
+
import { useShelfActions } from "./useShelfActions";
|
|
36
|
+
import { SNOOZE_PRESETS, snoozePreset, wakeLabel } from "@/lib/sidebar/snooze";
|
|
37
|
+
import type { SidebarRow } from "@/lib/sidebar/shelves";
|
|
33
38
|
import { useRowContext } from "./RowContext";
|
|
34
39
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
35
40
|
import { copySessionLink } from "@/hooks/useCopyToClipboard";
|
|
@@ -82,15 +87,20 @@ function BlockedItem({ kind, icon: Icon, label, why }: BlockedProps) {
|
|
|
82
87
|
// The row's ⋯ menu (and right-click menu): everything a session can do.
|
|
83
88
|
export function SessionRowMenu({
|
|
84
89
|
session,
|
|
90
|
+
row,
|
|
85
91
|
kind,
|
|
86
92
|
onRename,
|
|
87
93
|
}: {
|
|
88
94
|
session: Session;
|
|
95
|
+
// Where it sits, for Snooze, Mark done and Reopen (not on an orchestrator).
|
|
96
|
+
row?: SidebarRow;
|
|
89
97
|
kind: keyof typeof PARTS;
|
|
90
98
|
onRename: () => void;
|
|
91
99
|
}) {
|
|
92
100
|
const ctx = useRowContext();
|
|
93
101
|
const markDone = useDoneAction();
|
|
102
|
+
const shelf = useShelfActions();
|
|
103
|
+
const plain = !!row && !row.task && !session.task_status;
|
|
94
104
|
const { Item, Separator, Sub, SubTrigger, SubContent } = PARTS[kind];
|
|
95
105
|
const linked = useLinkedHostNames();
|
|
96
106
|
// A linked machine's session: what can't reach it yet shows why.
|
|
@@ -291,9 +301,54 @@ export function SessionRowMenu({
|
|
|
291
301
|
{!session.role && (
|
|
292
302
|
<>
|
|
293
303
|
<Separator />
|
|
304
|
+
{row?.wakesAt ? (
|
|
305
|
+
<Item onClick={() => shelf.unsnooze(session)}>
|
|
306
|
+
<AlarmClock className={icon} />
|
|
307
|
+
Unsnooze
|
|
308
|
+
</Item>
|
|
309
|
+
) : (
|
|
310
|
+
!session.pinned &&
|
|
311
|
+
row && (
|
|
312
|
+
<Sub>
|
|
313
|
+
<SubTrigger>
|
|
314
|
+
<AlarmClock className={icon} />
|
|
315
|
+
Snooze
|
|
316
|
+
</SubTrigger>
|
|
317
|
+
<SubContent>
|
|
318
|
+
{SNOOZE_PRESETS.map((p) => (
|
|
319
|
+
<Item
|
|
320
|
+
key={p.key}
|
|
321
|
+
onClick={() => shelf.snooze(session, p.key)}
|
|
322
|
+
>
|
|
323
|
+
{p.label}
|
|
324
|
+
<span className="text-muted-foreground ml-3 text-xs">
|
|
325
|
+
{wakeLabel(snoozePreset(p.key).getTime())}
|
|
326
|
+
</span>
|
|
327
|
+
</Item>
|
|
328
|
+
))}
|
|
329
|
+
</SubContent>
|
|
330
|
+
</Sub>
|
|
331
|
+
)
|
|
332
|
+
)}
|
|
333
|
+
{plain &&
|
|
334
|
+
(row.settled ? (
|
|
335
|
+
<Item onClick={() => shelf.reopen(session)}>
|
|
336
|
+
<RotateCcw className={icon} />
|
|
337
|
+
Reopen
|
|
338
|
+
</Item>
|
|
339
|
+
) : (
|
|
340
|
+
<Item onClick={() => shelf.settle(session)}>
|
|
341
|
+
<CircleCheck className={icon} />
|
|
342
|
+
Mark done
|
|
343
|
+
</Item>
|
|
344
|
+
))}
|
|
294
345
|
<Item onClick={() => markDone(session)}>
|
|
295
|
-
|
|
296
|
-
|
|
346
|
+
{plain ? (
|
|
347
|
+
<Archive className={icon} />
|
|
348
|
+
) : (
|
|
349
|
+
<CircleCheck className={icon} />
|
|
350
|
+
)}
|
|
351
|
+
{plain ? "Archive" : "Done"}
|
|
297
352
|
</Item>
|
|
298
353
|
<Item
|
|
299
354
|
onClick={() => ctx.onDelete(session.id)}
|
|
@@ -48,7 +48,10 @@ function ShelfLabel({
|
|
|
48
48
|
type="button"
|
|
49
49
|
onClick={onToggle}
|
|
50
50
|
aria-expanded={!collapsed}
|
|
51
|
-
className={cn(
|
|
51
|
+
className={cn(
|
|
52
|
+
cls,
|
|
53
|
+
"hover:text-foreground min-h-11 w-full md:min-h-8 [@media(hover:none)]:md:min-h-11"
|
|
54
|
+
)}
|
|
52
55
|
>
|
|
53
56
|
{text}
|
|
54
57
|
</button>
|
|
@@ -86,18 +89,20 @@ export function MachineShelves({ groups }: { groups: MachineGroup[] }) {
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
// The pinned orchestrator (or a row to start one), then Pinned, Needs you,
|
|
89
|
-
// Working, In review, Queued and Done: once for the whole list.
|
|
92
|
+
// Working, In review, Queued, Ongoing, Snoozed and Done: once for the whole list.
|
|
90
93
|
export function SessionShelves({
|
|
91
94
|
shelves,
|
|
92
95
|
queued,
|
|
93
96
|
toStart,
|
|
94
97
|
doneCollapsed,
|
|
98
|
+
snoozedCollapsed,
|
|
95
99
|
donePages,
|
|
96
100
|
}: {
|
|
97
101
|
shelves: Shelves;
|
|
98
102
|
queued: QueuedRowData[];
|
|
99
103
|
toStart: { workspaceId: string; name: string }[];
|
|
100
104
|
doneCollapsed: boolean;
|
|
105
|
+
snoozedCollapsed: boolean;
|
|
101
106
|
donePages: number;
|
|
102
107
|
}) {
|
|
103
108
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
@@ -150,6 +155,27 @@ export function SessionShelves({
|
|
|
150
155
|
))}
|
|
151
156
|
</Shelf>
|
|
152
157
|
)}
|
|
158
|
+
{shelves.ongoing.length > 0 && (
|
|
159
|
+
<Shelf label="Ongoing" count={shelves.ongoing.length}>
|
|
160
|
+
{shelves.ongoing.map((r) => (
|
|
161
|
+
<SessionRow key={r.session.id} row={r} />
|
|
162
|
+
))}
|
|
163
|
+
</Shelf>
|
|
164
|
+
)}
|
|
165
|
+
{shelves.snoozed.length > 0 && (
|
|
166
|
+
<section aria-label="Snoozed">
|
|
167
|
+
<ShelfLabel
|
|
168
|
+
label="Snoozed"
|
|
169
|
+
count={shelves.snoozed.length}
|
|
170
|
+
collapsed={snoozedCollapsed}
|
|
171
|
+
onToggle={sidebarUiActions.toggleSnoozed}
|
|
172
|
+
/>
|
|
173
|
+
{!snoozedCollapsed &&
|
|
174
|
+
shelves.snoozed.map((r) => (
|
|
175
|
+
<SessionRow key={r.session.id} row={r} muted />
|
|
176
|
+
))}
|
|
177
|
+
</section>
|
|
178
|
+
)}
|
|
153
179
|
{shelves.done.length > 0 && (
|
|
154
180
|
<section aria-label="Done">
|
|
155
181
|
<ShelfLabel
|
|
@@ -66,6 +66,8 @@ export function SidebarBody({
|
|
|
66
66
|
shelves.needsYou.length +
|
|
67
67
|
shelves.working.length +
|
|
68
68
|
shelves.inReview.length +
|
|
69
|
+
shelves.ongoing.length +
|
|
70
|
+
shelves.snoozed.length +
|
|
69
71
|
data.queued.length +
|
|
70
72
|
shelves.done.length +
|
|
71
73
|
data.machines.length;
|
|
@@ -121,6 +123,7 @@ export function SidebarBody({
|
|
|
121
123
|
queued={data.queued}
|
|
122
124
|
toStart={data.toStart}
|
|
123
125
|
doneCollapsed={ui.doneCollapsed}
|
|
126
|
+
snoozedCollapsed={ui.snoozedCollapsed}
|
|
124
127
|
donePages={ui.donePages}
|
|
125
128
|
/>
|
|
126
129
|
<MachineShelves groups={data.machines} />
|
|
@@ -62,15 +62,15 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
62
62
|
const workspaceId = props.workspace?.id ?? null;
|
|
63
63
|
const schedules = useSchedulesGlance(workspaceId);
|
|
64
64
|
return (
|
|
65
|
-
<div className="flex items-center gap-1 px-3 pt-3 pb-2">
|
|
66
|
-
<div className="flex min-w-
|
|
65
|
+
<div className="flex flex-wrap items-center gap-x-1 px-3 pt-3 pb-2">
|
|
66
|
+
<div className="flex min-w-[9.5rem] flex-1 items-center">
|
|
67
67
|
<WorkspaceSwitcher
|
|
68
68
|
workspaces={props.workspaces}
|
|
69
69
|
current={props.workspace}
|
|
70
70
|
/>
|
|
71
71
|
<LoadGauge />
|
|
72
72
|
</div>
|
|
73
|
-
<div className="flex shrink-0 items-center gap-0.5">
|
|
73
|
+
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
|
74
74
|
<AwayToggle />
|
|
75
75
|
<ADropdownMenu
|
|
76
76
|
trigger={
|
|
@@ -80,7 +80,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
80
80
|
aria-label={
|
|
81
81
|
schedules.failedText ? `More (${schedules.failedText})` : "More"
|
|
82
82
|
}
|
|
83
|
-
className="relative h-11 w-11 md:h-8 md:w-8"
|
|
83
|
+
className="relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
|
|
84
84
|
>
|
|
85
85
|
<MoreHorizontal className="h-4 w-4" />
|
|
86
86
|
{schedules.failed > 0 && (
|
|
@@ -88,7 +88,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
88
88
|
aria-hidden
|
|
89
89
|
className={cn(
|
|
90
90
|
failedDot,
|
|
91
|
-
"ring-background absolute top-2 right-2 ring-2 md:top-1 md:right-1"
|
|
91
|
+
"ring-background absolute top-2 right-2 ring-2 md:top-1 md:right-1 [@media(hover:none)]:md:top-2 [@media(hover:none)]:md:right-2"
|
|
92
92
|
)}
|
|
93
93
|
/>
|
|
94
94
|
)}
|
|
@@ -149,7 +149,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
149
149
|
<Button
|
|
150
150
|
size="icon-sm"
|
|
151
151
|
aria-label="New"
|
|
152
|
-
className="ml-0.5 h-11 w-11 rounded-[10px] md:h-8 md:w-8"
|
|
152
|
+
className="ml-0.5 h-11 w-11 rounded-[10px] md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
|
|
153
153
|
>
|
|
154
154
|
<Plus className="h-4 w-4" />
|
|
155
155
|
</Button>
|
|
@@ -7,7 +7,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
|
7
7
|
// sessions too.
|
|
8
8
|
export function SidebarSearch({ query }: { query: string }) {
|
|
9
9
|
return (
|
|
10
|
-
<label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-
|
|
10
|
+
<label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-1 items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
|
|
11
11
|
<Search className="h-3.5 w-3.5 shrink-0" />
|
|
12
12
|
<input
|
|
13
13
|
type="search"
|
|
@@ -28,7 +28,7 @@ export function SidebarSearch({ query }: { query: string }) {
|
|
|
28
28
|
type="button"
|
|
29
29
|
aria-label="Clear search"
|
|
30
30
|
onClick={() => sidebarUiActions.setQuery("")}
|
|
31
|
-
className="-mr-3 flex h-11 w-11 items-center justify-center md:h-7 md:w-7"
|
|
31
|
+
className="-mr-3 flex h-11 w-11 items-center justify-center md:h-7 md:w-7 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
|
|
32
32
|
>
|
|
33
33
|
<X className="h-3.5 w-3.5" />
|
|
34
34
|
</button>
|
|
@@ -59,12 +59,11 @@ export function WorkspaceSwitcher({
|
|
|
59
59
|
<button
|
|
60
60
|
type="button"
|
|
61
61
|
title={name}
|
|
62
|
-
className="hover:bg-foreground/[0.04] -ml-1.5 flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 md:min-h-9"
|
|
62
|
+
className="hover:bg-foreground/[0.04] -ml-1.5 flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 md:min-h-9 [@media(hover:none)]:md:min-h-11"
|
|
63
63
|
>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
/>
|
|
64
|
+
<span className="truncate text-[15px] font-semibold tracking-tight">
|
|
65
|
+
{name}
|
|
66
|
+
</span>
|
|
68
67
|
<ChevronDown className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
69
68
|
</button>
|
|
70
69
|
</DM.DropdownMenuTrigger>
|
|
@@ -190,31 +189,3 @@ export function WorkspaceSwitcher({
|
|
|
190
189
|
</>
|
|
191
190
|
);
|
|
192
191
|
}
|
|
193
|
-
|
|
194
|
-
const TAIL = 4;
|
|
195
|
-
|
|
196
|
-
// Cuts a name that doesn't fit in the middle ("All wo…aces"), so the end
|
|
197
|
-
// that tells similar names apart stays: the head truncates, the tail doesn't.
|
|
198
|
-
function MiddleTruncate({
|
|
199
|
-
text,
|
|
200
|
-
className,
|
|
201
|
-
}: {
|
|
202
|
-
text: string;
|
|
203
|
-
className?: string;
|
|
204
|
-
}) {
|
|
205
|
-
if (text.length <= TAIL * 2) {
|
|
206
|
-
return <span className={`truncate ${className}`}>{text}</span>;
|
|
207
|
-
}
|
|
208
|
-
// Screen readers get the name whole, not as two words.
|
|
209
|
-
return (
|
|
210
|
-
<span className={`flex min-w-0 ${className}`}>
|
|
211
|
-
<span className="sr-only">{text}</span>
|
|
212
|
-
<span aria-hidden className="truncate whitespace-pre">
|
|
213
|
-
{text.slice(0, -TAIL)}
|
|
214
|
-
</span>
|
|
215
|
-
<span aria-hidden className="shrink-0 whitespace-pre">
|
|
216
|
-
{text.slice(-TAIL)}
|
|
217
|
-
</span>
|
|
218
|
-
</span>
|
|
219
|
-
);
|
|
220
|
-
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { toast } from "sonner";
|
|
2
|
+
import type { Session } from "@/lib/db";
|
|
3
|
+
import { useSettleSession, useSnoozeSession } from "@/data/sessions/shelf";
|
|
4
|
+
import {
|
|
5
|
+
snoozePreset,
|
|
6
|
+
wakeLabel,
|
|
7
|
+
type SnoozePreset,
|
|
8
|
+
} from "@/lib/sidebar/snooze";
|
|
9
|
+
|
|
10
|
+
// A row menu's Snooze, Unsnooze, Mark done and Reopen, each with an Undo.
|
|
11
|
+
export function useShelfActions() {
|
|
12
|
+
const snooze = useSnoozeSession();
|
|
13
|
+
const settle = useSettleSession();
|
|
14
|
+
const unsnooze = (session: Session) =>
|
|
15
|
+
snooze.mutate({ id: session.id, until: null });
|
|
16
|
+
return {
|
|
17
|
+
snooze: (session: Session, preset: SnoozePreset) => {
|
|
18
|
+
const until = snoozePreset(preset);
|
|
19
|
+
snooze.mutate(
|
|
20
|
+
{ id: session.id, until },
|
|
21
|
+
{
|
|
22
|
+
onSuccess: () =>
|
|
23
|
+
toast.success(`Snoozed until ${wakeLabel(until.getTime())}`, {
|
|
24
|
+
action: { label: "Undo", onClick: () => unsnooze(session) },
|
|
25
|
+
}),
|
|
26
|
+
onError: (e) => toast.error(e.message),
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
unsnooze,
|
|
31
|
+
settle: (session: Session) =>
|
|
32
|
+
settle.mutate(
|
|
33
|
+
{ id: session.id, settled: true },
|
|
34
|
+
{
|
|
35
|
+
onSuccess: () =>
|
|
36
|
+
toast.success("Marked done", {
|
|
37
|
+
action: {
|
|
38
|
+
label: "Undo",
|
|
39
|
+
onClick: () =>
|
|
40
|
+
settle.mutate({ id: session.id, settled: false }),
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
onError: (e) => toast.error(e.message),
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
reopen: (session: Session) =>
|
|
47
|
+
settle.mutate({ id: session.id, settled: false }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -19,6 +19,7 @@ import { rollups, type Rollup } from "@/lib/sidebar/rollup";
|
|
|
19
19
|
import { queuedRows } from "@/lib/sidebar/queued";
|
|
20
20
|
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
21
21
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
22
|
+
import { useClock } from "@/hooks/useMinuteTick";
|
|
22
23
|
import { sidebarUi } from "@/stores/sidebarUi";
|
|
23
24
|
import type { SessionStatus } from "./SessionList.types";
|
|
24
25
|
|
|
@@ -31,6 +32,8 @@ export function useSidebarData(
|
|
|
31
32
|
sessionStatuses: Record<string, SessionStatus> | undefined
|
|
32
33
|
) {
|
|
33
34
|
const ui = useSnapshot(sidebarUi);
|
|
35
|
+
// Wakes and 3-day settles are read from the clock.
|
|
36
|
+
const now = useClock();
|
|
34
37
|
|
|
35
38
|
const sessionsQuery = useSessionsQuery();
|
|
36
39
|
const projectsQuery = useProjectsQuery();
|
|
@@ -84,6 +87,7 @@ export function useSidebarData(
|
|
|
84
87
|
tasks: taskStates,
|
|
85
88
|
projectName,
|
|
86
89
|
query: ui.query,
|
|
90
|
+
now,
|
|
87
91
|
};
|
|
88
92
|
const input = {
|
|
89
93
|
...common,
|
|
@@ -113,6 +117,7 @@ export function useSidebarData(
|
|
|
113
117
|
: machineGroups({ ...common, sessions }, linked),
|
|
114
118
|
};
|
|
115
119
|
}, [
|
|
120
|
+
now,
|
|
116
121
|
linked,
|
|
117
122
|
sessions,
|
|
118
123
|
projects,
|
|
@@ -30,7 +30,11 @@ export function ThemeToggle() {
|
|
|
30
30
|
return (
|
|
31
31
|
<DropdownMenu>
|
|
32
32
|
<DropdownMenuTrigger asChild>
|
|
33
|
-
<Button
|
|
33
|
+
<Button
|
|
34
|
+
variant="ghost"
|
|
35
|
+
size="icon"
|
|
36
|
+
className="h-8 w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
|
|
37
|
+
>
|
|
34
38
|
<Palette className="h-4 w-4" />
|
|
35
39
|
<span className="sr-only">Change theme</span>
|
|
36
40
|
</Button>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { Session, Group } from "@/lib/db";
|
|
3
|
+
import { toSqliteTime } from "@/lib/session-meta";
|
|
4
|
+
import { sessionKeys } from "./keys";
|
|
5
|
+
|
|
6
|
+
interface SessionsResponse {
|
|
7
|
+
sessions: Session[];
|
|
8
|
+
groups: Group[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function post(url: string, body: object) {
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
throw new Error(
|
|
19
|
+
(await res.json().catch(() => null))?.error ?? "Request failed"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Snooze, wake, mark done and reopen all change a few columns the shelves
|
|
24
|
+
// read, so the row moves at once and rolls back if the server refuses.
|
|
25
|
+
function useShelfMutation<V extends { id: string }>(
|
|
26
|
+
send: (v: V) => Promise<void>,
|
|
27
|
+
patch: (v: V, now: string) => Partial<Session>
|
|
28
|
+
) {
|
|
29
|
+
const queryClient = useQueryClient();
|
|
30
|
+
return useMutation({
|
|
31
|
+
mutationFn: send,
|
|
32
|
+
onMutate: async (v: V) => {
|
|
33
|
+
await queryClient.cancelQueries({ queryKey: sessionKeys.list() });
|
|
34
|
+
const previous = queryClient.getQueryData<SessionsResponse>(
|
|
35
|
+
sessionKeys.list()
|
|
36
|
+
);
|
|
37
|
+
const now = toSqliteTime(new Date());
|
|
38
|
+
queryClient.setQueryData<SessionsResponse>(
|
|
39
|
+
sessionKeys.list(),
|
|
40
|
+
(old) =>
|
|
41
|
+
old && {
|
|
42
|
+
...old,
|
|
43
|
+
sessions: old.sessions.map((s) =>
|
|
44
|
+
s.id === v.id ? { ...s, ...patch(v, now) } : s
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
return { previous };
|
|
49
|
+
},
|
|
50
|
+
onError: (_, __, context) => {
|
|
51
|
+
if (context?.previous)
|
|
52
|
+
queryClient.setQueryData(sessionKeys.list(), context.previous);
|
|
53
|
+
},
|
|
54
|
+
onSettled: () =>
|
|
55
|
+
queryClient.invalidateQueries({ queryKey: sessionKeys.list() }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// until null wakes it.
|
|
60
|
+
export function useSnoozeSession() {
|
|
61
|
+
return useShelfMutation<{ id: string; until: Date | null }>(
|
|
62
|
+
({ id, until }) =>
|
|
63
|
+
post(`/api/sessions/${id}/snooze`, {
|
|
64
|
+
until: until?.toISOString() ?? null,
|
|
65
|
+
}),
|
|
66
|
+
({ until }, now) =>
|
|
67
|
+
until
|
|
68
|
+
? {
|
|
69
|
+
snoozed_until: toSqliteTime(until),
|
|
70
|
+
snoozed_at: now,
|
|
71
|
+
settled_at: null,
|
|
72
|
+
}
|
|
73
|
+
: { snoozed_until: null, snoozed_at: null, active_at: now }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useSettleSession() {
|
|
78
|
+
return useShelfMutation<{ id: string; settled: boolean }>(
|
|
79
|
+
({ id, settled }) => post(`/api/sessions/${id}/settle`, { settled }),
|
|
80
|
+
({ settled }, now) =>
|
|
81
|
+
settled
|
|
82
|
+
? { settled_at: now, snoozed_until: null, snoozed_at: null }
|
|
83
|
+
: { settled_at: null, active_at: now }
|
|
84
|
+
);
|
|
85
|
+
}
|
package/hooks/useMinuteTick.ts
CHANGED
|
@@ -4,12 +4,15 @@ import { useSyncExternalStore } from "react";
|
|
|
4
4
|
// so they stay current without their lists redrawing for it.
|
|
5
5
|
const listeners = new Set<() => void>();
|
|
6
6
|
let tick = 0;
|
|
7
|
+
let now = Date.now();
|
|
7
8
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
8
9
|
|
|
9
10
|
function subscribe(listener: () => void) {
|
|
11
|
+
now = Date.now();
|
|
10
12
|
listeners.add(listener);
|
|
11
13
|
timer ??= setInterval(() => {
|
|
12
14
|
tick++;
|
|
15
|
+
now = Date.now();
|
|
13
16
|
listeners.forEach((l) => l());
|
|
14
17
|
}, 30_000);
|
|
15
18
|
return () => {
|
|
@@ -28,3 +31,13 @@ export function useMinuteTick(): number {
|
|
|
28
31
|
() => tick
|
|
29
32
|
);
|
|
30
33
|
}
|
|
34
|
+
|
|
35
|
+
// The clock itself (ms), moving with the same tick: for code that reads wakes
|
|
36
|
+
// and settles from the time.
|
|
37
|
+
export function useClock(): number {
|
|
38
|
+
return useSyncExternalStore(
|
|
39
|
+
subscribe,
|
|
40
|
+
() => now,
|
|
41
|
+
() => now
|
|
42
|
+
);
|
|
43
|
+
}
|
package/lib/chat/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { touchActive } from "../session-activity";
|
|
1
2
|
import { noteWaiting } from "../notify/push/waiting";
|
|
2
3
|
import { db, type Session } from "../db";
|
|
3
4
|
import type { WorkerClient } from "./worker/client";
|
|
@@ -94,6 +95,11 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
|
|
|
94
95
|
if (m.type === "state" || m.type === "compacting" || m.type === "item")
|
|
95
96
|
markStatusDirty(sessionId, EMIT_SETTLE_MS);
|
|
96
97
|
if (m.type === "state") noteWaiting(sessionId, m.state === "waiting");
|
|
98
|
+
if (
|
|
99
|
+
(m.type === "state" && m.state !== "idle") ||
|
|
100
|
+
(m.type === "item" && m.item.kind === "turn_end")
|
|
101
|
+
)
|
|
102
|
+
touchActive(sessionId);
|
|
97
103
|
registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
|
|
98
104
|
// Idle, running, waiting and back, and a finished turn (unread): the
|
|
99
105
|
// sidebar hears at once.
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -549,7 +549,7 @@ export class ChatHost {
|
|
|
549
549
|
// A new turn makes the last guess stale.
|
|
550
550
|
if (this.suggestion !== null) this.setSuggestion(null);
|
|
551
551
|
db.prepare(
|
|
552
|
-
`UPDATE sessions SET updated_at = datetime('now') WHERE id = ?`
|
|
552
|
+
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE id = ?`
|
|
553
553
|
).run(this.session.id);
|
|
554
554
|
}
|
|
555
555
|
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1973,6 +1973,27 @@ const migrations: Migration[] = [
|
|
|
1973
1973
|
moveSchedulesToRecipes(db);
|
|
1974
1974
|
},
|
|
1975
1975
|
},
|
|
1976
|
+
{
|
|
1977
|
+
id: 82,
|
|
1978
|
+
name: "session_shelf_times",
|
|
1979
|
+
up: (db) => {
|
|
1980
|
+
const cols = (
|
|
1981
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
1982
|
+
).map((c) => c.name);
|
|
1983
|
+
// Last real activity, when it was marked done, and the snooze.
|
|
1984
|
+
for (const col of [
|
|
1985
|
+
"active_at",
|
|
1986
|
+
"settled_at",
|
|
1987
|
+
"snoozed_until",
|
|
1988
|
+
"snoozed_at",
|
|
1989
|
+
])
|
|
1990
|
+
if (!cols.includes(col))
|
|
1991
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
|
|
1992
|
+
db.exec(
|
|
1993
|
+
`UPDATE sessions SET active_at = updated_at WHERE active_at IS NULL`
|
|
1994
|
+
);
|
|
1995
|
+
},
|
|
1996
|
+
},
|
|
1976
1997
|
];
|
|
1977
1998
|
|
|
1978
1999
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -79,6 +79,13 @@ export interface Session {
|
|
|
79
79
|
chat_context: string | null;
|
|
80
80
|
chat_usage: string | null;
|
|
81
81
|
last_seen_at: string | null;
|
|
82
|
+
// Last real activity: a message, a run starting, a need (null: updated_at).
|
|
83
|
+
active_at?: string | null;
|
|
84
|
+
// Marked done by hand: Done until new activity (not archived).
|
|
85
|
+
settled_at?: string | null;
|
|
86
|
+
// Snoozed until this time, from snoozed_at; activity since wakes it early.
|
|
87
|
+
snoozed_until?: string | null;
|
|
88
|
+
snoozed_at?: string | null;
|
|
82
89
|
// Set by done: hidden from the sidebar and the orchestrator, never deleted.
|
|
83
90
|
archived_at: string | null;
|
|
84
91
|
// On the sidebar's Pinned shelf (0/1 from SQLite).
|
|
@@ -85,7 +85,7 @@ function commit(sessionName: string, next: Records): boolean {
|
|
|
85
85
|
if (same(before, after)) return false;
|
|
86
86
|
if (after && after.state !== "idle" && after.state !== before?.state)
|
|
87
87
|
db.prepare(
|
|
88
|
-
`UPDATE sessions SET updated_at = datetime('now') WHERE tmux_name = ? OR id = ?`
|
|
88
|
+
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE tmux_name = ? OR id = ?`
|
|
89
89
|
).run(sessionName, getSessionIdFromName(sessionName));
|
|
90
90
|
return true;
|
|
91
91
|
}
|
package/lib/security/demo.ts
CHANGED
|
@@ -129,13 +129,15 @@ const READS = [
|
|
|
129
129
|
|
|
130
130
|
// The only writes, each a database write and nothing more in demo mode:
|
|
131
131
|
// pairing a device (start is loopback-only in its route), marking a session
|
|
132
|
-
// seen or
|
|
132
|
+
// seen, pinned, snoozed or settled, done (archives only: no merge, no clean-up), answering an
|
|
133
133
|
// ask, and removing the caller's own device.
|
|
134
134
|
const WRITES: [string, RegExp][] = [
|
|
135
135
|
["POST", "pair/start"],
|
|
136
136
|
["POST", "pair/claim"],
|
|
137
137
|
["POST", "sessions/[id]/seen"],
|
|
138
138
|
["POST", "sessions/[id]/pin"],
|
|
139
|
+
["POST", "sessions/[id]/snooze"],
|
|
140
|
+
["POST", "sessions/[id]/settle"],
|
|
139
141
|
["POST", "sessions/[id]/done"],
|
|
140
142
|
["POST", "workspaces/[id]/orchestrator/asks/[id]"],
|
|
141
143
|
["DELETE", "devices/[id]"],
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getDb } from "./db";
|
|
2
|
+
|
|
3
|
+
// A session did something: a message, a run starting, a need. Written at most
|
|
4
|
+
// once a minute, unless it wakes a snooze or brings a settled session back.
|
|
5
|
+
export function touchActive(sessionId: string): void {
|
|
6
|
+
try {
|
|
7
|
+
getDb()
|
|
8
|
+
.prepare(
|
|
9
|
+
`UPDATE sessions SET active_at = datetime('now')
|
|
10
|
+
WHERE id = ? AND (active_at IS NULL
|
|
11
|
+
OR active_at < datetime('now', '-60 seconds')
|
|
12
|
+
OR (snoozed_at IS NOT NULL AND active_at <= snoozed_at)
|
|
13
|
+
OR (settled_at IS NOT NULL AND active_at <= settled_at))`
|
|
14
|
+
)
|
|
15
|
+
.run(sessionId);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error("[activity] touch failed:", err);
|
|
18
|
+
}
|
|
19
|
+
}
|
package/lib/session-meta.ts
CHANGED
|
@@ -17,6 +17,10 @@ export function fromSqliteTime(value: string): Date {
|
|
|
17
17
|
return new Date(value.includes("T") ? value : `${value.replace(" ", "T")}Z`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function toSqliteTime(date: Date): string {
|
|
21
|
+
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
const STATUS: Record<SessionStatus, { label: string; tone: string }> = {
|
|
21
25
|
idle: { label: "", tone: "text-muted-foreground/60" },
|
|
22
26
|
running: { label: "Working", tone: "text-muted-foreground" },
|
package/lib/sidebar/machines.ts
CHANGED
package/lib/sidebar/rollup.ts
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { toSqliteTime } from "@/lib/session-meta";
|
|
3
|
+
|
|
4
|
+
// Marking done and snoozing are view choices like pinning: none of them
|
|
5
|
+
// touch updated_at. Archived sessions are out of the sidebar, so they refuse.
|
|
6
|
+
function update(sql: string, ...args: (string | null)[]): boolean {
|
|
7
|
+
return (
|
|
8
|
+
getDb()
|
|
9
|
+
.prepare(`UPDATE sessions SET ${sql} AND archived_at IS NULL`)
|
|
10
|
+
.run(...args).changes > 0
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function settleSession(id: string): boolean {
|
|
15
|
+
return update(
|
|
16
|
+
`settled_at = datetime('now'), snoozed_until = NULL, snoozed_at = NULL
|
|
17
|
+
WHERE id = ?`,
|
|
18
|
+
id
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reopening is activity, so an auto-settled row doesn't fall back into Done.
|
|
23
|
+
export function reopenSession(id: string): boolean {
|
|
24
|
+
return update(
|
|
25
|
+
`settled_at = NULL, active_at = datetime('now') WHERE id = ?`,
|
|
26
|
+
id
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function snoozeSession(id: string, until: Date): boolean {
|
|
31
|
+
return update(
|
|
32
|
+
`snoozed_until = ?, snoozed_at = datetime('now'), settled_at = NULL
|
|
33
|
+
WHERE id = ?`,
|
|
34
|
+
toSqliteTime(until),
|
|
35
|
+
id
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function unsnoozeSession(id: string): boolean {
|
|
40
|
+
return update(
|
|
41
|
+
`snoozed_until = NULL, snoozed_at = NULL, active_at = datetime('now')
|
|
42
|
+
WHERE id = ?`,
|
|
43
|
+
id
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SNOOZE_MAX_MS = 60 * 24 * 3600_000;
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The sidebar's one flat list, sorted onto shelves: the workspace's pinned
|
|
2
|
-
// orchestrator, Pinned, Needs you, Working, In review,
|
|
2
|
+
// orchestrator, Pinned, Needs you, Working, In review, Ongoing, Snoozed, then
|
|
3
|
+
// Done. Client-safe: no database.
|
|
3
4
|
import type { Session } from "@/lib/db/types";
|
|
4
5
|
import { fromSqliteTime } from "@/lib/session-meta";
|
|
5
6
|
import type { PrStage } from "@/lib/tasks/pr-stage";
|
|
@@ -23,6 +24,9 @@ export const NEED_LABEL: Record<SessionNeed, string> = {
|
|
|
23
24
|
failed: "Failed",
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
// A plain session quiet this long has settled by itself.
|
|
28
|
+
export const SETTLE_AFTER_MS = 3 * 24 * 3600_000;
|
|
29
|
+
|
|
26
30
|
export const DONE_FIRST_PAGE = 10;
|
|
27
31
|
export const DONE_PAGE = 25;
|
|
28
32
|
|
|
@@ -55,6 +59,12 @@ export interface SidebarRow {
|
|
|
55
59
|
task?: TaskState;
|
|
56
60
|
// An orchestrator's quick changes (orchestrator/quick.ts), nested under it.
|
|
57
61
|
quick?: SidebarRow[];
|
|
62
|
+
// Last real activity (ms): sorts Ongoing and is the "2d" on a row.
|
|
63
|
+
activeAt: number;
|
|
64
|
+
// While snoozed: when it wakes (ms).
|
|
65
|
+
wakesAt?: number;
|
|
66
|
+
// A plain session in Done: marked by hand, or settled by itself.
|
|
67
|
+
settled?: "manual" | "auto";
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
export interface Shelves {
|
|
@@ -64,6 +74,8 @@ export interface Shelves {
|
|
|
64
74
|
needsYou: SidebarRow[];
|
|
65
75
|
working: SidebarRow[];
|
|
66
76
|
inReview: SidebarRow[];
|
|
77
|
+
ongoing: SidebarRow[];
|
|
78
|
+
snoozed: SidebarRow[];
|
|
67
79
|
done: SidebarRow[];
|
|
68
80
|
}
|
|
69
81
|
|
|
@@ -125,6 +137,8 @@ export interface ShelfInput {
|
|
|
125
137
|
currentOrchestrators?: ReadonlySet<string>;
|
|
126
138
|
// Only the sessions this orchestrator session started.
|
|
127
139
|
startedBy?: string | null;
|
|
140
|
+
// The clock (ms): wakes and 3-day settles are read from it.
|
|
141
|
+
now?: number;
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
const newestFirst = (a: SidebarRow, b: SidebarRow) =>
|
|
@@ -132,6 +146,73 @@ const newestFirst = (a: SidebarRow, b: SidebarRow) =>
|
|
|
132
146
|
const oldestFirst = (a: SidebarRow, b: SidebarRow) =>
|
|
133
147
|
a.session.created_at.localeCompare(b.session.created_at);
|
|
134
148
|
|
|
149
|
+
const ms = (t: string | null | undefined) =>
|
|
150
|
+
t ? fromSqliteTime(t).getTime() : 0;
|
|
151
|
+
|
|
152
|
+
// When it last did something: a mirrored session only has its machine's
|
|
153
|
+
// updated_at; a snooze that ran out counts as activity at its wake time.
|
|
154
|
+
export function activeMs(
|
|
155
|
+
session: Pick<
|
|
156
|
+
Session,
|
|
157
|
+
"active_at" | "updated_at" | "peer_mirror" | "snoozed_until" | "snoozed_at"
|
|
158
|
+
>,
|
|
159
|
+
now: number
|
|
160
|
+
): number {
|
|
161
|
+
const base = ms(
|
|
162
|
+
session.peer_mirror
|
|
163
|
+
? session.updated_at
|
|
164
|
+
: (session.active_at ?? session.updated_at)
|
|
165
|
+
);
|
|
166
|
+
const wake = ms(session.snoozed_until);
|
|
167
|
+
if (wake && wake <= now && base <= ms(session.snoozed_at))
|
|
168
|
+
return Math.max(base, wake);
|
|
169
|
+
return base;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// When a snooze still holds, until when: any activity since ends it early.
|
|
173
|
+
export function snoozedUntil(
|
|
174
|
+
session: Parameters<typeof activeMs>[0],
|
|
175
|
+
now: number
|
|
176
|
+
): number | null {
|
|
177
|
+
const until = ms(session.snoozed_until);
|
|
178
|
+
return until > now && activeMs(session, now) <= ms(session.snoozed_at)
|
|
179
|
+
? until
|
|
180
|
+
: null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function settledHow(
|
|
184
|
+
session: Parameters<typeof activeMs>[0] & Pick<Session, "settled_at">,
|
|
185
|
+
now: number
|
|
186
|
+
): "manual" | "auto" | null {
|
|
187
|
+
const active = activeMs(session, now);
|
|
188
|
+
if (session.settled_at && active <= ms(session.settled_at)) return "manual";
|
|
189
|
+
return now - active >= SETTLE_AFTER_MS ? "auto" : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Woken by its timer and not opened since.
|
|
193
|
+
function wokeUnread(
|
|
194
|
+
session: Parameters<typeof activeMs>[0] & Pick<Session, "last_seen_at">,
|
|
195
|
+
now: number
|
|
196
|
+
): boolean {
|
|
197
|
+
const wake = ms(session.snoozed_until);
|
|
198
|
+
return (
|
|
199
|
+
wake > 0 &&
|
|
200
|
+
wake <= now &&
|
|
201
|
+
activeMs(session, now) <= wake &&
|
|
202
|
+
ms(session.last_seen_at) < wake
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// When a row went to Done: marked by hand, quiet for 3 days, or (a task)
|
|
207
|
+
// last changed.
|
|
208
|
+
export function doneAtMs(row: SidebarRow): number {
|
|
209
|
+
return row.settled === "manual"
|
|
210
|
+
? ms(row.session.settled_at)
|
|
211
|
+
: row.settled === "auto"
|
|
212
|
+
? row.activeAt
|
|
213
|
+
: ms(row.session.updated_at);
|
|
214
|
+
}
|
|
215
|
+
|
|
135
216
|
const OPEN_RUN = ["queued", "starting", "running"];
|
|
136
217
|
|
|
137
218
|
// A recipe run that hasn't reported its result yet: its session going quiet
|
|
@@ -146,13 +227,16 @@ export function recipeRunOpen(session: Pick<Session, "recipe_run">): boolean {
|
|
|
146
227
|
}
|
|
147
228
|
}
|
|
148
229
|
|
|
149
|
-
function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
230
|
+
function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
|
|
150
231
|
const status = input.statuses[session.id];
|
|
232
|
+
const wakesAt = snoozedUntil(session, now);
|
|
151
233
|
return {
|
|
152
234
|
session,
|
|
153
235
|
status,
|
|
154
236
|
need: needOf(session, status, input.tasks[session.id]),
|
|
155
|
-
unread: !!status?.unread,
|
|
237
|
+
unread: !!status?.unread || wokeUnread(session, now),
|
|
238
|
+
activeAt: activeMs(session, now),
|
|
239
|
+
...(wakesAt && { wakesAt }),
|
|
156
240
|
working:
|
|
157
241
|
status?.status === "running" ||
|
|
158
242
|
(status?.background ?? 0) > 0 ||
|
|
@@ -165,6 +249,7 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
165
249
|
}
|
|
166
250
|
|
|
167
251
|
export function buildShelves(input: ShelfInput): Shelves {
|
|
252
|
+
const now = input.now ?? Date.now();
|
|
168
253
|
const rows = input.sessions
|
|
169
254
|
.filter(
|
|
170
255
|
(s) =>
|
|
@@ -173,7 +258,7 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
173
258
|
(!input.startedBy || s.started_by === input.startedBy) &&
|
|
174
259
|
matchesQuery(s, input.projectName(s), input.query ?? "")
|
|
175
260
|
)
|
|
176
|
-
.map((s) => toRow(input, s));
|
|
261
|
+
.map((s) => toRow(input, s, now));
|
|
177
262
|
const byId = new Map(rows.map((r) => [r.session.id, r]));
|
|
178
263
|
|
|
179
264
|
const top: SidebarRow[] = [];
|
|
@@ -201,6 +286,8 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
201
286
|
needsYou: [],
|
|
202
287
|
working: [],
|
|
203
288
|
inReview: [],
|
|
289
|
+
ongoing: [],
|
|
290
|
+
snoozed: [],
|
|
204
291
|
done: [],
|
|
205
292
|
};
|
|
206
293
|
for (const row of top) {
|
|
@@ -210,14 +297,25 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
210
297
|
else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
|
|
211
298
|
else if (row.need) shelves.needsYou.push(row);
|
|
212
299
|
else if (row.working) shelves.working.push(row);
|
|
213
|
-
else
|
|
300
|
+
else if (row.wakesAt) shelves.snoozed.push(row);
|
|
301
|
+
else if (row.task || row.session.task_status)
|
|
302
|
+
shelves[quietShelf(row.task)].push(row);
|
|
303
|
+
else {
|
|
304
|
+
const settled = settledHow(row.session, now);
|
|
305
|
+
if (settled) {
|
|
306
|
+
row.settled = settled;
|
|
307
|
+
shelves.done.push(row);
|
|
308
|
+
} else shelves.ongoing.push(row);
|
|
309
|
+
}
|
|
214
310
|
}
|
|
215
311
|
shelves.orchestrators.sort(oldestFirst);
|
|
216
312
|
shelves.pinned.sort(oldestFirst);
|
|
217
313
|
shelves.needsYou.sort(newestFirst);
|
|
218
314
|
shelves.working.sort(newestFirst);
|
|
219
315
|
shelves.inReview.sort(newestFirst);
|
|
220
|
-
shelves.
|
|
316
|
+
shelves.ongoing.sort((a, b) => b.activeAt - a.activeAt);
|
|
317
|
+
shelves.snoozed.sort((a, b) => a.wakesAt! - b.wakesAt!);
|
|
318
|
+
shelves.done.sort((a, b) => doneAtMs(b) - doneAtMs(a));
|
|
221
319
|
return shelves;
|
|
222
320
|
}
|
|
223
321
|
|
|
@@ -233,7 +331,7 @@ export function doneSummary(
|
|
|
233
331
|
): { today: number; earlier: number; text: string } {
|
|
234
332
|
const day = now.toDateString();
|
|
235
333
|
const today = done.filter(
|
|
236
|
-
(r) =>
|
|
334
|
+
(r) => new Date(doneAtMs(r)).toDateString() === day
|
|
237
335
|
).length;
|
|
238
336
|
const earlier = done.length - today;
|
|
239
337
|
const text = [
|
|
@@ -281,6 +379,9 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
|
|
|
281
379
|
a.working === b.working &&
|
|
282
380
|
sameStatus(a.status, b.status) &&
|
|
283
381
|
a.task === b.task &&
|
|
382
|
+
a.activeAt === b.activeAt &&
|
|
383
|
+
a.wakesAt === b.wakesAt &&
|
|
384
|
+
a.settled === b.settled &&
|
|
284
385
|
a.workers.length === b.workers.length &&
|
|
285
386
|
a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
|
|
286
387
|
(a.quick?.length ?? 0) === (b.quick?.length ?? 0) &&
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Snooze presets and how a wake time reads. The reader's own zone decides
|
|
2
|
+
// "9am", so this runs on their device. Client-safe.
|
|
3
|
+
|
|
4
|
+
export type SnoozePreset = "tomorrow" | "next-week";
|
|
5
|
+
|
|
6
|
+
export const SNOOZE_PRESETS: { key: SnoozePreset; label: string }[] = [
|
|
7
|
+
{ key: "tomorrow", label: "Tomorrow" },
|
|
8
|
+
{ key: "next-week", label: "Next week" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
12
|
+
|
|
13
|
+
function wall(ms: number, timeZone: string) {
|
|
14
|
+
const p = Object.fromEntries(
|
|
15
|
+
new Intl.DateTimeFormat("en-US", {
|
|
16
|
+
timeZone,
|
|
17
|
+
hourCycle: "h23",
|
|
18
|
+
year: "numeric",
|
|
19
|
+
month: "numeric",
|
|
20
|
+
day: "numeric",
|
|
21
|
+
hour: "numeric",
|
|
22
|
+
minute: "numeric",
|
|
23
|
+
second: "numeric",
|
|
24
|
+
})
|
|
25
|
+
.formatToParts(new Date(ms))
|
|
26
|
+
.map((x) => [x.type, Number(x.value)])
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
y: p.year,
|
|
30
|
+
m: p.month,
|
|
31
|
+
d: p.day,
|
|
32
|
+
h: p.hour,
|
|
33
|
+
mi: p.minute,
|
|
34
|
+
s: p.second,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const offsetAt = (ms: number, timeZone: string) => {
|
|
39
|
+
const w = wall(ms, timeZone);
|
|
40
|
+
return (
|
|
41
|
+
Date.UTC(w.y, w.m - 1, w.d, w.h, w.mi, w.s) - Math.floor(ms / 1000) * 1000
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The instant a zone's wall clock reads the given date and hour; the offset
|
|
46
|
+
// is read again at the result so a DST change in between is honoured.
|
|
47
|
+
function zoned(y: number, m: number, d: number, h: number, timeZone: string) {
|
|
48
|
+
const guess = Date.UTC(y, m - 1, d, h);
|
|
49
|
+
let t = guess - offsetAt(guess, timeZone);
|
|
50
|
+
t = guess - offsetAt(t, timeZone);
|
|
51
|
+
return t;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// "Tomorrow" is 9am the next day; "Next week" is 9am next Monday (a Monday
|
|
55
|
+
// waits a full week, a Sunday waits a day).
|
|
56
|
+
export function snoozePreset(
|
|
57
|
+
preset: SnoozePreset,
|
|
58
|
+
now: Date = new Date(),
|
|
59
|
+
timeZone: string = localZone()
|
|
60
|
+
): Date {
|
|
61
|
+
const { y, m, d } = wall(now.getTime(), timeZone);
|
|
62
|
+
const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
|
63
|
+
const ahead = preset === "tomorrow" ? 1 : (1 - weekday + 7) % 7 || 7;
|
|
64
|
+
const target = new Date(Date.UTC(y, m - 1, d + ahead));
|
|
65
|
+
return new Date(
|
|
66
|
+
zoned(
|
|
67
|
+
target.getUTCFullYear(),
|
|
68
|
+
target.getUTCMonth() + 1,
|
|
69
|
+
target.getUTCDate(),
|
|
70
|
+
9,
|
|
71
|
+
timeZone
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dayNumber = (ms: number, timeZone: string) => {
|
|
77
|
+
const w = wall(ms, timeZone);
|
|
78
|
+
return Math.round(Date.UTC(w.y, w.m - 1, w.d) / 86400000);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// "9 AM", "Tomorrow 9 AM", "Mon 9 AM", "Oct 20, 9 AM".
|
|
82
|
+
export function wakeLabel(
|
|
83
|
+
ms: number,
|
|
84
|
+
now: number = Date.now(),
|
|
85
|
+
timeZone: string = localZone()
|
|
86
|
+
): string {
|
|
87
|
+
const date = new Date(ms);
|
|
88
|
+
const time = date
|
|
89
|
+
.toLocaleTimeString("en-US", {
|
|
90
|
+
timeZone,
|
|
91
|
+
hour: "numeric",
|
|
92
|
+
minute: "2-digit",
|
|
93
|
+
})
|
|
94
|
+
.replace(/\s/g, " ")
|
|
95
|
+
.replace(":00", "");
|
|
96
|
+
const days = dayNumber(ms, timeZone) - dayNumber(now, timeZone);
|
|
97
|
+
if (days <= 0) return time;
|
|
98
|
+
if (days === 1) return `Tomorrow ${time}`;
|
|
99
|
+
if (days < 7)
|
|
100
|
+
return `${date.toLocaleDateString("en-US", { timeZone, weekday: "short" })} ${time}`;
|
|
101
|
+
return `${date.toLocaleDateString("en-US", { timeZone, month: "short", day: "numeric" })}, ${time}`;
|
|
102
|
+
}
|
package/lib/status/collect.ts
CHANGED
|
@@ -421,7 +421,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
421
421
|
|
|
422
422
|
// Batch update sessions and claude_session_id in a single transaction
|
|
423
423
|
const updateStatusStmt = db.prepare(
|
|
424
|
-
"UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
|
|
424
|
+
"UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE id = ?"
|
|
425
425
|
);
|
|
426
426
|
const updateClaudeIdStmt = db.prepare(
|
|
427
427
|
"UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
|
package/package.json
CHANGED
package/stores/sidebarUi.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface Remembered {
|
|
|
8
8
|
workspaceId: string | null;
|
|
9
9
|
projectId: string | null;
|
|
10
10
|
doneCollapsed: boolean;
|
|
11
|
+
snoozedCollapsed: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export const sidebarUi = proxy<
|
|
@@ -22,6 +23,7 @@ export const sidebarUi = proxy<
|
|
|
22
23
|
workspaceId: null,
|
|
23
24
|
projectId: null,
|
|
24
25
|
doneCollapsed: true,
|
|
26
|
+
snoozedCollapsed: true,
|
|
25
27
|
query: "",
|
|
26
28
|
orchestratorId: null,
|
|
27
29
|
donePages: 0,
|
|
@@ -43,13 +45,20 @@ export const sidebarUiActions = {
|
|
|
43
45
|
sidebarUi.workspaceId = saved.workspaceId ?? null;
|
|
44
46
|
sidebarUi.projectId = saved.projectId ?? null;
|
|
45
47
|
sidebarUi.doneCollapsed = saved.doneCollapsed ?? true;
|
|
48
|
+
sidebarUi.snoozedCollapsed = saved.snoozedCollapsed ?? true;
|
|
46
49
|
sidebarUi.hydrated = true;
|
|
47
50
|
subscribe(sidebarUi, () => {
|
|
48
51
|
try {
|
|
49
|
-
const { workspaceId, projectId, doneCollapsed } =
|
|
52
|
+
const { workspaceId, projectId, doneCollapsed, snoozedCollapsed } =
|
|
53
|
+
sidebarUi;
|
|
50
54
|
localStorage.setItem(
|
|
51
55
|
STORAGE_KEY,
|
|
52
|
-
JSON.stringify({
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
workspaceId,
|
|
58
|
+
projectId,
|
|
59
|
+
doneCollapsed,
|
|
60
|
+
snoozedCollapsed,
|
|
61
|
+
})
|
|
53
62
|
);
|
|
54
63
|
} catch {
|
|
55
64
|
// Storage can be unavailable (private mode); the view still works.
|
|
@@ -76,6 +85,9 @@ export const sidebarUiActions = {
|
|
|
76
85
|
toggleDone: () => {
|
|
77
86
|
sidebarUi.doneCollapsed = !sidebarUi.doneCollapsed;
|
|
78
87
|
},
|
|
88
|
+
toggleSnoozed: () => {
|
|
89
|
+
sidebarUi.snoozedCollapsed = !sidebarUi.snoozedCollapsed;
|
|
90
|
+
},
|
|
79
91
|
showMoreDone: () => {
|
|
80
92
|
sidebarUi.donePages += 1;
|
|
81
93
|
},
|