@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.
Files changed (36) hide show
  1. package/app/api/sessions/[id]/settle/route.ts +22 -0
  2. package/app/api/sessions/[id]/snooze/route.ts +36 -0
  3. package/app/page.tsx +17 -1
  4. package/components/Chat/composer/ComposerBody.tsx +3 -1
  5. package/components/Chat/composer/autofocus.test.ts +139 -2
  6. package/components/Chat/composer/autofocus.ts +80 -15
  7. package/components/Chat/composer/layout.ts +20 -0
  8. package/components/Pane/index.tsx +1 -0
  9. package/components/SessionList/OrchestratorPinRow.test.ts +1 -0
  10. package/components/SessionList/SessionRow.tsx +22 -6
  11. package/components/SessionList/SessionRowMenu.tsx +57 -2
  12. package/components/SessionList/SessionShelves.tsx +24 -1
  13. package/components/SessionList/SidebarBody.tsx +3 -0
  14. package/components/SessionList/index.tsx +2 -0
  15. package/components/SessionList/useShelfActions.ts +49 -0
  16. package/components/SessionList/useSidebarData.ts +5 -0
  17. package/components/views/DesktopView.tsx +3 -2
  18. package/components/views/types.ts +1 -0
  19. package/data/sessions/shelf.ts +85 -0
  20. package/hooks/useMinuteTick.ts +13 -0
  21. package/lib/chat/registry.ts +6 -0
  22. package/lib/chat/worker/host.ts +1 -1
  23. package/lib/db/migrations.ts +21 -0
  24. package/lib/db/types.ts +7 -0
  25. package/lib/program-status/store.ts +1 -1
  26. package/lib/security/demo.ts +3 -1
  27. package/lib/session-activity.ts +19 -0
  28. package/lib/session-meta.ts +4 -0
  29. package/lib/sidebar/machines.ts +2 -0
  30. package/lib/sidebar/rollup.ts +2 -0
  31. package/lib/sidebar/shelf-state.ts +47 -0
  32. package/lib/sidebar/shelves.ts +108 -7
  33. package/lib/sidebar/snooze.ts +102 -0
  34. package/lib/status/collect.ts +1 -1
  35. package/package.json +1 -1
  36. package/stores/sidebarUi.ts +14 -2
@@ -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, then Done. Client-safe: no database.
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 shelves[quietShelf(row.task)].push(row);
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.done.sort(newestFirst);
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) => fromSqliteTime(r.session.updated_at).toDateString() === day
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.543",
3
+ "version": "0.3.545",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -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 } = sidebarUi;
52
+ const { workspaceId, projectId, doneCollapsed, snoozedCollapsed } =
53
+ sidebarUi;
50
54
  localStorage.setItem(
51
55
  STORAGE_KEY,
52
- JSON.stringify({ workspaceId, projectId, doneCollapsed })
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
  },