@saadnvd1/agent-os 0.3.543 → 0.3.545
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/app/page.tsx +17 -1
- package/components/Chat/composer/ComposerBody.tsx +3 -1
- package/components/Chat/composer/autofocus.test.ts +139 -2
- package/components/Chat/composer/autofocus.ts +80 -15
- package/components/Chat/composer/layout.ts +20 -0
- package/components/Pane/index.tsx +1 -0
- package/components/SessionList/OrchestratorPinRow.test.ts +1 -0
- package/components/SessionList/SessionRow.tsx +22 -6
- package/components/SessionList/SessionRowMenu.tsx +57 -2
- package/components/SessionList/SessionShelves.tsx +24 -1
- package/components/SessionList/SidebarBody.tsx +3 -0
- package/components/SessionList/index.tsx +2 -0
- package/components/SessionList/useShelfActions.ts +49 -0
- package/components/SessionList/useSidebarData.ts +5 -0
- package/components/views/DesktopView.tsx +3 -2
- package/components/views/types.ts +1 -0
- 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
|
@@ -89,18 +89,20 @@ export function MachineShelves({ groups }: { groups: MachineGroup[] }) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// The pinned orchestrator (or a row to start one), then Pinned, Needs you,
|
|
92
|
-
// 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.
|
|
93
93
|
export function SessionShelves({
|
|
94
94
|
shelves,
|
|
95
95
|
queued,
|
|
96
96
|
toStart,
|
|
97
97
|
doneCollapsed,
|
|
98
|
+
snoozedCollapsed,
|
|
98
99
|
donePages,
|
|
99
100
|
}: {
|
|
100
101
|
shelves: Shelves;
|
|
101
102
|
queued: QueuedRowData[];
|
|
102
103
|
toStart: { workspaceId: string; name: string }[];
|
|
103
104
|
doneCollapsed: boolean;
|
|
105
|
+
snoozedCollapsed: boolean;
|
|
104
106
|
donePages: number;
|
|
105
107
|
}) {
|
|
106
108
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
@@ -153,6 +155,27 @@ export function SessionShelves({
|
|
|
153
155
|
))}
|
|
154
156
|
</Shelf>
|
|
155
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
|
+
)}
|
|
156
179
|
{shelves.done.length > 0 && (
|
|
157
180
|
<section aria-label="Done">
|
|
158
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} />
|
|
@@ -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,
|
|
@@ -18,6 +18,7 @@ export function DesktopView({
|
|
|
18
18
|
setShowQuickSwitcher,
|
|
19
19
|
notificationSettings,
|
|
20
20
|
attachToSession,
|
|
21
|
+
selectSession,
|
|
21
22
|
openSessionInNewTab,
|
|
22
23
|
handleNewSessionInProject,
|
|
23
24
|
handleOpenTerminal,
|
|
@@ -38,7 +39,7 @@ export function DesktopView({
|
|
|
38
39
|
sessionStatuses={sessionStatuses}
|
|
39
40
|
onSelect={(id) => {
|
|
40
41
|
const session = sessions.find((s) => s.id === id);
|
|
41
|
-
if (session)
|
|
42
|
+
if (session) selectSession(session);
|
|
42
43
|
}}
|
|
43
44
|
onOpenInTab={(id) => {
|
|
44
45
|
const session = sessions.find((s) => s.id === id);
|
|
@@ -82,7 +83,7 @@ export function DesktopView({
|
|
|
82
83
|
activeSessionWorkingDir={activeSession?.working_directory ?? undefined}
|
|
83
84
|
onSelectSession={(sessionId) => {
|
|
84
85
|
const session = sessions.find((s) => s.id === sessionId);
|
|
85
|
-
if (session)
|
|
86
|
+
if (session) selectSession(session);
|
|
86
87
|
}}
|
|
87
88
|
onSelectFile={(file, line) => {
|
|
88
89
|
// Convert relative path to absolute by prepending working directory
|
|
@@ -43,6 +43,7 @@ export interface ViewProps {
|
|
|
43
43
|
|
|
44
44
|
// Handlers
|
|
45
45
|
attachToSession: (session: Session) => void;
|
|
46
|
+
selectSession: (session: Session) => void;
|
|
46
47
|
openSessionInNewTab: (session: Session) => void;
|
|
47
48
|
handleNewSessionInProject: (projectId: string) => void;
|
|
48
49
|
handleOpenTerminal: (projectId: string) => void;
|
|
@@ -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;
|