@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
@@ -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
+ }
package/app/page.tsx CHANGED
@@ -46,6 +46,7 @@ import type { TerminalHandle } from "@/components/Terminal";
46
46
  import { getProvider } from "@/lib/providers";
47
47
  import { CLAUDE_STATUS_SETTINGS_FLAG } from "@/lib/program-status/claude-flag";
48
48
  import { DesktopView } from "@/components/views/DesktopView";
49
+ import { focusComposerInGesture } from "@/components/Chat/composer/autofocus";
49
50
  import { MobileView } from "@/components/views/MobileView";
50
51
  import { getPendingPrompt, clearPendingPrompt } from "@/stores/initialPrompt";
51
52
  import { useDraftKeys } from "@/hooks/useDraftKeys";
@@ -533,6 +534,20 @@ function HomeContent() {
533
534
  return () => window.removeEventListener("keydown", handleKeyDown);
534
535
  }, []);
535
536
 
537
+ // Picking a session with a tap, ⌘K or the keyboard: the composer is focused
538
+ // inside the gesture, which iOS requires. Other ways in (URL, notification,
539
+ // a session just created) use attachToSession's later focus.
540
+ const selectSession = useCallback(
541
+ (session: Session) => {
542
+ focusComposerInGesture(
543
+ document.querySelector(`[data-pane-id="${focusedPaneId}"]`),
544
+ session
545
+ );
546
+ void attachToSession(session);
547
+ },
548
+ [attachToSession, focusedPaneId]
549
+ );
550
+
536
551
  // Session selection handler
537
552
  const handleSelectSession = useCallback(
538
553
  (sessionId: string) => {
@@ -661,7 +676,7 @@ function HomeContent() {
661
676
 
662
677
  useAppCommands({
663
678
  sessions,
664
- onSelectSession: attachToSession,
679
+ onSelectSession: selectSession,
665
680
  onSearchCode: () => setShowQuickSwitcher(true),
666
681
  });
667
682
  useMoveCommands(activeSession);
@@ -683,6 +698,7 @@ function HomeContent() {
683
698
  setShowQuickSwitcher,
684
699
  notificationSettings,
685
700
  attachToSession,
701
+ selectSession,
686
702
  openSessionInNewTab,
687
703
  handleNewSessionInProject,
688
704
  handleOpenTerminal,
@@ -30,6 +30,7 @@ import { useImageDrop } from "./useImageDrop";
30
30
  import type { MenuKey } from "./keys";
31
31
  import { useComposerEditor, type ComposerHandlers } from "./useComposerEditor";
32
32
  import { useFocusOnShow } from "./autofocus";
33
+ import { usePhoneLayout } from "./layout";
33
34
 
34
35
  // Handled here rather than sent: picking it opens the model picker.
35
36
  const MODEL_COMMAND: ChatCommand = {
@@ -108,6 +109,7 @@ export function ComposerBody({
108
109
  const [modelOpen, setModelOpen] = useState(false);
109
110
  const handlers = useRef<ComposerHandlers | null>(null);
110
111
  const coarse = useCoarsePointer();
112
+ const phone = usePhoneLayout();
111
113
  const [historyIndex, setHistoryIndex] = useState(-1);
112
114
  // The editor shows it only while it's empty, as its placeholder.
113
115
  const { editor, text, setText, caret, plain, setPlain } = useComposerEditor({
@@ -138,7 +140,7 @@ export function ComposerBody({
138
140
  setImages(saved?.images ?? []);
139
141
  setFiles(saved?.files ?? []);
140
142
  }, [draftKey, editor, setText]);
141
- useFocusOnShow(editor, focusKey, coarse, disabled);
143
+ useFocusOnShow(editor, focusKey, phone, disabled);
142
144
 
143
145
  const prefilled = useRef<number | null>(null);
144
146
  const inserted = useRef<number | null>(null);
@@ -3,7 +3,13 @@ import { act, createElement } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { Editor } from "@tiptap/react";
6
- import { DIALOG_WAIT_MS, focusVerdict, useFocusOnShow } from "./autofocus";
6
+ import {
7
+ DIALOG_WAIT_MS,
8
+ focusComposerInGesture,
9
+ focusVerdict,
10
+ useFocusOnShow,
11
+ } from "./autofocus";
12
+ import { isPhoneLayout } from "./layout";
7
13
 
8
14
  (
9
15
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@@ -35,6 +41,131 @@ beforeEach(() => {
35
41
  document.getSelection()?.removeAllRanges();
36
42
  });
37
43
 
44
+ function stubMedia(matches: Record<string, boolean>) {
45
+ window.matchMedia = ((q: string) => ({
46
+ matches: matches[q] ?? false,
47
+ addEventListener: () => {},
48
+ removeEventListener: () => {},
49
+ })) as never;
50
+ }
51
+
52
+ afterEach(() => {
53
+ delete (window as { matchMedia?: unknown }).matchMedia;
54
+ document.querySelectorAll("[data-composer-proxy]").forEach((e) => e.remove());
55
+ });
56
+
57
+ describe("focusComposerInGesture", () => {
58
+ const chat = { view: "chat" };
59
+ let paneA: HTMLElement;
60
+ let paneB: HTMLElement;
61
+ let hostEl: HTMLElement;
62
+ const panelOf = (pane: HTMLElement) =>
63
+ pane.querySelector("[data-composer-scope]")!;
64
+
65
+ // Two panes, each with a chat panel and composer. jsdom has no layout, so
66
+ // a hidden tab is a panel marked data-hidden that reports no client rects.
67
+ beforeEach(() => {
68
+ hostEl = document.createElement("div");
69
+ hostEl.innerHTML = ["a", "b"]
70
+ .map(
71
+ (id) =>
72
+ `<div data-pane-id="${id}"><div data-composer-scope tabindex="-1"><div class="chat-composer"><div id="ed-${id}" contenteditable="true"></div></div></div></div>`
73
+ )
74
+ .join("");
75
+ document.body.append(hostEl);
76
+ paneA = hostEl.querySelector('[data-pane-id="a"]')!;
77
+ paneB = hostEl.querySelector('[data-pane-id="b"]')!;
78
+ vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(
79
+ function (this: HTMLElement) {
80
+ return this.closest("[data-hidden]") ? ([] as never) : ([{}] as never);
81
+ } as never
82
+ );
83
+ // The page's own composer is gone: only the panes have one.
84
+ editorDom.parentElement!.remove();
85
+ });
86
+ afterEach(() => {
87
+ vi.restoreAllMocks();
88
+ hostEl.remove();
89
+ });
90
+
91
+ it("focuses the composer in the target pane, not another pane's", () => {
92
+ stubMedia({ "(pointer: coarse)": true });
93
+ sidebarRow.focus();
94
+ focusComposerInGesture(paneB, chat);
95
+ expect(document.activeElement?.id).toBe("ed-b");
96
+ sidebarRow.focus();
97
+ focusComposerInGesture(paneA, chat);
98
+ expect(document.activeElement?.id).toBe("ed-a");
99
+ });
100
+
101
+ it("skips a hidden tab's composer and falls back to the proxy", () => {
102
+ stubMedia({});
103
+ panelOf(paneB).setAttribute("data-hidden", "");
104
+ sidebarRow.focus();
105
+ focusComposerInGesture(paneB, chat);
106
+ expect(document.activeElement?.hasAttribute("data-composer-proxy")).toBe(
107
+ true
108
+ );
109
+ expect(document.activeElement?.id).not.toBe("ed-b");
110
+ });
111
+
112
+ it("never for a terminal session", () => {
113
+ stubMedia({});
114
+ sidebarRow.focus();
115
+ focusComposerInGesture(paneB, { view: "terminal" });
116
+ expect(document.activeElement).toBe(sidebarRow);
117
+ expect(document.querySelector("[data-composer-proxy]")).toBeNull();
118
+ });
119
+
120
+ it("does nothing in the phone layout", () => {
121
+ stubMedia({ "(max-width: 767px)": true });
122
+ sidebarRow.focus();
123
+ focusComposerInGesture(paneB, chat);
124
+ expect(document.activeElement).toBe(sidebarRow);
125
+ expect(document.querySelector("[data-composer-proxy]")).toBeNull();
126
+ });
127
+
128
+ it("leaves someone typing elsewhere, or working in the panel, alone", () => {
129
+ stubMedia({});
130
+ search.focus();
131
+ focusComposerInGesture(paneB, chat);
132
+ expect(document.activeElement).toBe(search);
133
+ // Focus inside a panel with no composer showing keeps the proxy away too.
134
+ const inPanel = document.createElement("button");
135
+ panelOf(paneB).append(inPanel);
136
+ panelOf(paneB).querySelector(".chat-composer")!.remove();
137
+ inPanel.focus();
138
+ focusComposerInGesture(paneB, chat);
139
+ expect(document.activeElement).toBe(inPanel);
140
+ expect(document.querySelector("[data-composer-proxy]")).toBeNull();
141
+ });
142
+
143
+ it("the hook hands the proxy's focus to the editor and drops the proxy", async () => {
144
+ stubMedia({});
145
+ panelOf(paneB).setAttribute("data-hidden", "");
146
+ sidebarRow.focus();
147
+ focusComposerInGesture(paneB, chat);
148
+ const proxy = document.querySelector("[data-composer-proxy]")!;
149
+ const dom = hostEl.querySelector<HTMLElement>("#ed-a")!;
150
+ const editor = {
151
+ view: { dom },
152
+ commands: { focus: () => dom.focus() },
153
+ isEditable: true,
154
+ on: () => {},
155
+ off: () => {},
156
+ } as never;
157
+ function Probe() {
158
+ useFocusOnShow(editor, "a", false);
159
+ return null;
160
+ }
161
+ const root = createRoot(document.createElement("div"));
162
+ await act(() => root.render(createElement(Probe)));
163
+ expect(document.activeElement).toBe(dom);
164
+ expect(proxy.isConnected).toBe(false);
165
+ await act(() => root.unmount());
166
+ });
167
+ });
168
+
38
169
  describe("focusVerdict", () => {
39
170
  it("takes the caret from nothing, a sidebar row, or itself", () => {
40
171
  (document.activeElement as HTMLElement | null)?.blur();
@@ -45,11 +176,17 @@ describe("focusVerdict", () => {
45
176
  expect(focusVerdict(editorDom, false)).toBe("take");
46
177
  });
47
178
 
48
- it("never on a touch screen, where it raises the keyboard", () => {
179
+ it("never in the phone layout, where it raises the keyboard", () => {
49
180
  sidebarRow.focus();
50
181
  expect(focusVerdict(editorDom, true)).toBe("leave");
51
182
  });
52
183
 
184
+ it("does in the desktop layout on a coarse pointer (iPad)", () => {
185
+ stubMedia({ "(pointer: coarse)": true, "(max-width: 767px)": false });
186
+ sidebarRow.focus();
187
+ expect(focusVerdict(editorDom, isPhoneLayout())).toBe("take");
188
+ });
189
+
53
190
  it("leaves someone typing in another field (search, rename) alone", () => {
54
191
  search.focus();
55
192
  expect(focusVerdict(editorDom, false)).toBe("leave");
@@ -1,12 +1,19 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import type { Editor } from "@tiptap/react";
3
+ import { isPhoneLayout } from "./layout";
3
4
 
4
5
  // Whether a composer just shown (a session or draft opened) may take the
5
- // caret, so its conversation can be typed into straight away. Not on a touch
6
- // screen, where it would raise the keyboard over the conversation, and never
7
- // from someone busy elsewhere: typing in another field, selecting text, or
8
- // already working inside this panel. With a dialog or menu open it waits:
9
- // one that opened the session (⌘K) is about to close and hand focus back.
6
+ // caret, so its conversation can be typed into straight away. Not in the phone
7
+ // layout (MobileView), where it would raise the keyboard over the
8
+ // conversation, and never from someone busy elsewhere: typing in another
9
+ // field, selecting text, or already working inside this panel. With a dialog
10
+ // or menu open it waits: one that opened the session (⌘K) is about to close
11
+ // and hand focus back.
12
+ //
13
+ // The rule follows the layout, not `pointer: coarse`: an iPad shows the
14
+ // desktop layout with a coarse pointer (often with a keyboard attached), and
15
+ // is meant to type straight away. A raised on-screen keyboard there is
16
+ // accepted. Width is what picks the layout, so touch laptops are covered too.
10
17
 
11
18
  // Something open on top that keeps the focus it has.
12
19
  const OPEN_ON_TOP = [
@@ -23,29 +30,86 @@ const EDITABLE =
23
30
 
24
31
  export type FocusVerdict = "take" | "wait" | "leave";
25
32
 
33
+ // A hidden field focused inside a tap, to be handed to the editor once it
34
+ // mounts (see focusComposerInGesture).
35
+ const PROXY = "[data-composer-proxy]";
36
+
26
37
  export function focusVerdict(
27
38
  editor: HTMLElement,
28
- coarse: boolean
39
+ phone: boolean
40
+ ): FocusVerdict {
41
+ if (phone) return "leave";
42
+ return verdictFor(editor.ownerDocument, editor);
43
+ }
44
+
45
+ function verdictFor(
46
+ doc: Document,
47
+ editor: HTMLElement | null,
48
+ panel: Element | null = editor?.closest("[data-composer-scope]") ?? null
29
49
  ): FocusVerdict {
30
- if (coarse) return "leave";
31
- const doc = editor.ownerDocument;
32
50
  if (doc.querySelector(OPEN_ON_TOP)) return "wait";
33
51
  const selection = doc.getSelection();
34
52
  if (
35
53
  selection &&
36
54
  !selection.isCollapsed &&
37
- !editor.contains(selection.anchorNode)
55
+ !editor?.contains(selection.anchorNode)
38
56
  )
39
57
  return "leave";
40
58
  const active = doc.activeElement;
41
- if (!active || active === doc.body || editor.contains(active)) return "take";
59
+ if (!active || active === doc.body || editor?.contains(active)) return "take";
42
60
  // Still in a dialog on its way out (⌘K's search box as it closes).
43
61
  if (active.closest(DIALOG)) return "wait";
62
+ if (active.closest(PROXY)) return "take";
44
63
  if (active.closest(EDITABLE)) return "leave";
45
64
  // The panel it belongs to (a chat, a draft): focus already inside it stays
46
65
  // where the user put it.
47
- const scope = editor.closest("[data-composer-scope]");
48
- return scope && scope !== active && scope.contains(active) ? "leave" : "take";
66
+ return panel && panel !== active && panel.contains(active) ? "leave" : "take";
67
+ }
68
+
69
+ const PANEL = "[data-composer-scope]";
70
+ const COMPOSER = '.chat-composer [contenteditable="true"]';
71
+ const PROXY_MS = 3000;
72
+
73
+ // Hidden tabs are display:none, and every tab of every pane stays mounted.
74
+ const visible = (el: Element) => el.getClientRects().length > 0;
75
+
76
+ // iOS WebKit only honours focus() inside the tap or key press that asked for
77
+ // it, and a session's composer is focused later (after it reconnects). So the
78
+ // handler that selects a chat session from a real gesture (a sidebar tap, ⌘K,
79
+ // the keyboard switch) calls this, synchronously: it focuses the composer
80
+ // showing in `pane` (the one the session opens into) if there is one, else a
81
+ // hidden field that useFocusOnShow hands over to the editor when it mounts
82
+ // (focus moving on from a focused element is allowed). Never for a terminal
83
+ // session, whose keystrokes the hidden field would take. Other browsers don't
84
+ // need it; the late focus works there.
85
+ export function focusComposerInGesture(
86
+ pane: Element | null,
87
+ session: { view?: string },
88
+ doc: Document = document
89
+ ) {
90
+ if (session.view !== "chat" || isPhoneLayout()) return;
91
+ const panel =
92
+ [...(pane?.querySelectorAll(PANEL) ?? [])].find(visible) ?? null;
93
+ const editor =
94
+ [...(panel?.querySelectorAll<HTMLElement>(COMPOSER) ?? [])].find(visible) ??
95
+ null;
96
+ if (verdictFor(doc, editor, panel) !== "take") return;
97
+ if (editor) {
98
+ editor.focus({ preventScroll: true });
99
+ return;
100
+ }
101
+ if (doc.querySelector(PROXY)) return;
102
+ const proxy = doc.createElement("textarea");
103
+ proxy.setAttribute("data-composer-proxy", "");
104
+ proxy.setAttribute("aria-hidden", "true");
105
+ proxy.tabIndex = -1;
106
+ // 16px: iOS zooms into smaller fields. Offscreen, not display:none, which
107
+ // can't take focus.
108
+ proxy.style.cssText =
109
+ "position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;font-size:16px";
110
+ doc.body.append(proxy);
111
+ proxy.focus({ preventScroll: true });
112
+ setTimeout(() => proxy.remove(), PROXY_MS);
49
113
  }
50
114
 
51
115
  // How long a session opened from a dialog waits for it to close.
@@ -58,7 +122,7 @@ export const DIALOG_WAIT_MS = 1500;
58
122
  export function useFocusOnShow(
59
123
  editor: Editor | null,
60
124
  key: string | null | undefined,
61
- coarse: boolean,
125
+ phone: boolean,
62
126
  disabled = false
63
127
  ) {
64
128
  const shown = useRef<string | null>(null);
@@ -95,6 +159,7 @@ export function useFocusOnShow(
95
159
  const take = (at: "end" | null) => {
96
160
  pending.current = null;
97
161
  editor.commands.focus(at, { scrollIntoView: false });
162
+ doc.querySelectorAll(PROXY).forEach((el) => el.remove());
98
163
  holding.current = true;
99
164
  };
100
165
  if (pending.current === null) {
@@ -105,7 +170,7 @@ export function useFocusOnShow(
105
170
  let timer: ReturnType<typeof setTimeout> | undefined;
106
171
  // `settled`: no dialog was seen closing just now.
107
172
  const decide = (settled: boolean) => {
108
- const verdict = focusVerdict(editor.view.dom, coarse);
173
+ const verdict = focusVerdict(editor.view.dom, phone);
109
174
  if (verdict === "take" && settled) return take("end");
110
175
  // A dialog just closed and hands focus back on a timer of its own:
111
176
  // decide after that.
@@ -116,5 +181,5 @@ export function useFocusOnShow(
116
181
  };
117
182
  decide(true);
118
183
  return () => clearTimeout(timer);
119
- }, [editor, key, coarse, disabled]);
184
+ }, [editor, key, phone, disabled]);
120
185
  }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ // The phone layout (MobileView) is the one under 768px, as in useViewport.
6
+ const QUERY = "(max-width: 767px)";
7
+
8
+ export function isPhoneLayout(): boolean {
9
+ return window.matchMedia(QUERY).matches;
10
+ }
11
+
12
+ function subscribe(listener: () => void) {
13
+ const mq = window.matchMedia(QUERY);
14
+ mq.addEventListener("change", listener);
15
+ return () => mq.removeEventListener("change", listener);
16
+ }
17
+
18
+ export function usePhoneLayout(): boolean {
19
+ return useSyncExternalStore(subscribe, isPhoneLayout, () => false);
20
+ }
@@ -415,6 +415,7 @@ export const Pane = memo(function Pane({
415
415
  "bg-card rounded-xl shadow-(--elevation) ring-1 transition-shadow duration-200",
416
416
  !isMobile && (isFocused ? "ring-primary/40" : "ring-foreground/[0.07]")
417
417
  )}
418
+ data-pane-id={paneId}
418
419
  onClick={handleFocus}
419
420
  >
420
421
  {/* Tab Bar - Mobile vs Desktop */}
@@ -103,6 +103,7 @@ describe("OrchestratorPinRow", () => {
103
103
  need: null,
104
104
  unread,
105
105
  working: false,
106
+ activeAt: 0,
106
107
  workers: [],
107
108
  },
108
109
  })
@@ -2,8 +2,15 @@
2
2
 
3
3
  import { memo, useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
- import { CheckSquare, GitFork, Square, Workflow } from "lucide-react";
6
- import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
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 && <TimeAgo at={session.updated_at} />}
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
- function TimeAgo({ at }: { at: string }) {
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
- {compactTimeAgo(fromSqliteTime(at))}
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
- <CircleCheck className={icon} />
296
- Done
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)}