@saadnvd1/agent-os 0.3.608 → 0.3.609

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.
@@ -1,20 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useSyncExternalStore } from "react";
4
-
5
- const QUERY = "(pointer: coarse)";
6
-
7
- function subscribe(listener: () => void) {
8
- const mq = window.matchMedia(QUERY);
9
- mq.addEventListener("change", listener);
10
- return () => mq.removeEventListener("change", listener);
11
- }
3
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
12
4
 
13
5
  // A touch screen with no keyboard to speak of: taps, not shortcuts.
14
6
  export function useCoarsePointer(): boolean {
15
- return useSyncExternalStore(
16
- subscribe,
17
- () => window.matchMedia(QUERY).matches,
18
- () => false
19
- );
7
+ return useMediaQuery("(pointer: coarse)");
20
8
  }
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
14
14
  import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
15
15
  import { useRowContext } from "./RowContext";
16
16
  import { SessionRowMenu } from "./SessionRowMenu";
17
- import { ActiveTick } from "./RowShell";
17
+ import { ActiveTick, ROW_MENU_CONTENT } from "./RowShell";
18
18
  import { RowRename, UnreadDot } from "./RowParts";
19
19
  import { QuickChangeRow } from "./QuickChangeRow";
20
20
  import { NestRail } from "./RowParts";
@@ -158,7 +158,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
158
158
  </DM.DropdownMenuTrigger>
159
159
  <DM.DropdownMenuContent
160
160
  align="end"
161
- className="w-56"
161
+ className={ROW_MENU_CONTENT}
162
162
  onClick={(e) => e.stopPropagation()}
163
163
  >
164
164
  {menu("dropdown")}
@@ -171,7 +171,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
171
171
  <>
172
172
  <CM.ContextMenu>
173
173
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
174
- <CM.ContextMenuContent className="w-56">
174
+ <CM.ContextMenuContent className={ROW_MENU_CONTENT}>
175
175
  {menu("context")}
176
176
  </CM.ContextMenuContent>
177
177
  </CM.ContextMenu>
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { useState, type ComponentProps, type ElementType } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import * as DM from "@/components/ui/dropdown-menu";
6
+ import * as CM from "@/components/ui/context-menu";
7
+ import { useCompactMenu } from "@/hooks/useCompactMenu";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ export const PARTS = {
11
+ dropdown: {
12
+ Item: DM.DropdownMenuItem,
13
+ Separator: DM.DropdownMenuSeparator,
14
+ Sub: DM.DropdownMenuSub,
15
+ SubTrigger: DM.DropdownMenuSubTrigger,
16
+ SubContent: DM.DropdownMenuSubContent,
17
+ },
18
+ context: {
19
+ Item: CM.ContextMenuItem,
20
+ Separator: CM.ContextMenuSeparator,
21
+ Sub: CM.ContextMenuSub,
22
+ SubTrigger: CM.ContextMenuSubTrigger,
23
+ SubContent: CM.ContextMenuSubContent,
24
+ },
25
+ };
26
+
27
+ export type MenuKind = keyof typeof PARTS;
28
+
29
+ export const icon = "mr-2 h-3.5 w-3.5";
30
+
31
+ // An item that is a 44px target on a phone.
32
+ export function MenuItem({
33
+ kind,
34
+ className,
35
+ ...props
36
+ }: { kind: MenuKind } & ComponentProps<typeof DM.DropdownMenuItem>) {
37
+ const compact = useCompactMenu();
38
+ const Item = PARTS[kind].Item as typeof DM.DropdownMenuItem;
39
+ return <Item className={cn(compact && "min-h-11", className)} {...props} />;
40
+ }
41
+
42
+ // A submenu on a desktop; on a phone or touch screen, a row that opens its
43
+ // items in place (a flyout can't be held open by a finger, and the list
44
+ // beneath it keeps scrolling as one).
45
+ export function Expander({
46
+ kind,
47
+ icon: Icon,
48
+ label,
49
+ children,
50
+ }: {
51
+ kind: MenuKind;
52
+ icon: ElementType;
53
+ label: string;
54
+ children: React.ReactNode;
55
+ }) {
56
+ const compact = useCompactMenu();
57
+ const [open, setOpen] = useState(false);
58
+ const { Sub, SubTrigger, SubContent } = PARTS[kind];
59
+ if (!compact)
60
+ return (
61
+ <Sub>
62
+ <SubTrigger>
63
+ <Icon className={icon} />
64
+ {label}
65
+ </SubTrigger>
66
+ <SubContent>{children}</SubContent>
67
+ </Sub>
68
+ );
69
+ return (
70
+ <>
71
+ <MenuItem
72
+ kind={kind}
73
+ aria-expanded={open}
74
+ onSelect={(e) => {
75
+ e.preventDefault();
76
+ setOpen((o) => !o);
77
+ }}
78
+ >
79
+ <Icon className={icon} />
80
+ {label}
81
+ <ChevronDown
82
+ className={cn("ml-auto transition-transform", open && "rotate-180")}
83
+ />
84
+ </MenuItem>
85
+ {open && (
86
+ <div className="border-border ml-4 border-l pl-1">{children}</div>
87
+ )}
88
+ </>
89
+ );
90
+ }
@@ -18,6 +18,10 @@ export function ActiveTick() {
18
18
  );
19
19
  }
20
20
 
21
+ // A phone gets the full width, a desktop a fixed column; either scrolls when
22
+ // the window is short (the menu primitives cap their height).
23
+ export const ROW_MENU_CONTENT = "w-56 max-md:w-[calc(100vw-1rem)]";
24
+
21
25
  export type RowMenuKind = "dropdown" | "context";
22
26
 
23
27
  // Every sidebar row's frame: a leading mark, title over subtitle, what's on
@@ -109,7 +113,7 @@ export function RowShell({
109
113
  </DM.DropdownMenuTrigger>
110
114
  <DM.DropdownMenuContent
111
115
  align="end"
112
- className="w-56"
116
+ className={ROW_MENU_CONTENT}
113
117
  onClick={(e) => e.stopPropagation()}
114
118
  >
115
119
  {menu("dropdown")}
@@ -122,7 +126,7 @@ export function RowShell({
122
126
  return (
123
127
  <CM.ContextMenu>
124
128
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
125
- <CM.ContextMenuContent className="w-56">
129
+ <CM.ContextMenuContent className={ROW_MENU_CONTENT}>
126
130
  {menu("context")}
127
131
  </CM.ContextMenuContent>
128
132
  </CM.ContextMenu>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { Fragment } from "react";
3
4
  import {
4
5
  CheckSquare,
5
6
  AlarmClock,
@@ -26,8 +27,6 @@ import {
26
27
  Sparkles,
27
28
  Trash2,
28
29
  } from "lucide-react";
29
- import * as DM from "@/components/ui/dropdown-menu";
30
- import * as CM from "@/components/ui/context-menu";
31
30
  import type { Session } from "@/lib/db";
32
31
  import { selectionActions } from "@/stores/sessionSelection";
33
32
  import { schedulesUiActions } from "@/stores/schedulesUi";
@@ -53,90 +52,27 @@ import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
53
52
  import { shareUiActions } from "@/stores/shareUi";
54
53
  import { copySessionLink } from "@/hooks/useCopyToClipboard";
55
54
  import { useLinkedHostNames } from "@/data/hosts";
56
- import { remoteBlocks } from "@/lib/hosts/remote-menu";
55
+ import { remoteBlocks, remoteNote } from "@/lib/hosts/remote-menu";
56
+ import { movableSession } from "@/lib/tasks/move-targets";
57
+ import { useMoveTargets } from "@/data/tasks/move";
58
+ import {
59
+ rowMenu,
60
+ groupLabel,
61
+ type RowMenuGroup,
62
+ type RowMenuItem,
63
+ } from "@/lib/sidebar/row-menu";
64
+ import { Expander, MenuItem, PARTS, icon, type MenuKind } from "./RowMenuParts";
57
65
 
58
- const PARTS = {
59
- dropdown: {
60
- Item: DM.DropdownMenuItem,
61
- Separator: DM.DropdownMenuSeparator,
62
- Sub: DM.DropdownMenuSub,
63
- SubTrigger: DM.DropdownMenuSubTrigger,
64
- SubContent: DM.DropdownMenuSubContent,
65
- },
66
- context: {
67
- Item: CM.ContextMenuItem,
68
- Separator: CM.ContextMenuSeparator,
69
- Sub: CM.ContextMenuSub,
70
- SubTrigger: CM.ContextMenuSubTrigger,
71
- SubContent: CM.ContextMenuSubContent,
72
- },
66
+ const GROUP_ICON: Record<RowMenuGroup, typeof Copy> = {
67
+ open: ExternalLink,
68
+ share: Share2,
69
+ restart: Copy,
70
+ move: FolderInput,
71
+ automate: Clock,
73
72
  };
74
73
 
75
- const icon = "mr-2 h-3.5 w-3.5";
76
-
77
- interface BlockedProps {
78
- kind: keyof typeof PARTS;
79
- icon: typeof Copy;
80
- label: string;
81
- why: string;
82
- }
83
-
84
- // An action that can't run here yet: shown, disabled, with why.
85
- function BlockedItem({ kind, icon: Icon, label, why }: BlockedProps) {
86
- const Item = PARTS[kind].Item;
87
- return (
88
- // Disabled, but the reason keeps full contrast: only the label dims.
89
- <Item disabled className="items-start data-[disabled]:opacity-100">
90
- <Icon className={`${icon} mt-0.5 opacity-50`} />
91
- <span className="flex min-w-0 flex-col">
92
- <span className="text-muted-foreground">{label}</span>
93
- <span className="text-muted-foreground text-xs leading-tight">
94
- {why}
95
- </span>
96
- </span>
97
- </Item>
98
- );
99
- }
100
-
101
- // Done and Archive, as the session's kind allows.
102
- function FinishItems({
103
- session,
104
- plain,
105
- Item,
106
- }: {
107
- session: Session;
108
- plain: boolean;
109
- Item: (typeof PARTS)[keyof typeof PARTS]["Item"];
110
- }) {
111
- const markDone = useDoneAction();
112
- const archive = useArchiveAction();
113
- const restore = useResurrectAction();
114
- const actions = finishActions(session, plain);
115
- return (
116
- <>
117
- {actions.includes("done") && (
118
- <Item onClick={() => markDone(session)}>
119
- <CircleCheck className={icon} />
120
- Done
121
- </Item>
122
- )}
123
- {actions.includes("restore") && (
124
- <Item onClick={() => restore(session)}>
125
- <RotateCcw className={icon} />
126
- Restore
127
- </Item>
128
- )}
129
- {actions.includes("archive") && (
130
- <Item onClick={() => archive(session)}>
131
- <Archive className={icon} />
132
- Archive
133
- </Item>
134
- )}
135
- </>
136
- );
137
- }
138
-
139
- // The row's ⋯ menu (and right-click menu): everything a session can do.
74
+ // The row's ⋯ menu (and right-click menu): everything a session can do. What
75
+ // it offers is decided in lib/sidebar/row-menu; this only draws it.
140
76
  export function SessionRowMenu({
141
77
  session,
142
78
  row,
@@ -146,16 +82,18 @@ export function SessionRowMenu({
146
82
  session: Session;
147
83
  // Where it sits, for Snooze, Mark done and Reopen (not on an orchestrator).
148
84
  row?: SidebarRow;
149
- kind: keyof typeof PARTS;
85
+ kind: MenuKind;
150
86
  onRename: () => void;
151
87
  }) {
152
88
  const ctx = useRowContext();
153
89
  const shelf = useShelfActions();
154
- const plain = !!row && !row.task && !session.task_status;
155
- const { Item, Separator, Sub, SubTrigger, SubContent } = PARTS[kind];
90
+ const markDone = useDoneAction();
91
+ const archive = useArchiveAction();
92
+ const restore = useResurrectAction();
93
+ const { Separator } = PARTS[kind];
156
94
  const linked = useLinkedHostNames();
157
- // A linked machine's session: what can't reach it yet shows why.
158
- const blocked = remoteBlocks(session, (id) => linked[id]);
95
+ const remote = remoteBlocks(session, (id) => linked[id]);
96
+ const targets = useMoveTargets(movableSession(session));
159
97
  const summarizing = ctx.summarizingSessionId === session.id;
160
98
  const cardUrl = ctx.cardUrl(session.id);
161
99
  const others = ctx.projects.filter(
@@ -168,31 +106,203 @@ export function SessionRowMenu({
168
106
  ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
169
107
  null;
170
108
 
171
- // A quick change has no agent: no fork, fresh start, snooze or move, just
172
- // its PR and the ways to finish it.
173
- if (session.quick_of)
174
- return (
175
- <>
176
- {session.pr_url && (
177
- <Item onClick={() => window.open(session.pr_url!, "_blank")}>
178
- <GitPullRequest className={icon} />
179
- Open PR #{session.pr_number}
180
- </Item>
181
- )}
182
- <FinishItems session={session} plain={plain} Item={Item} />
183
- <Item
184
- onClick={() => ctx.onDelete(session.id)}
185
- className="text-red-500 focus:text-red-500"
186
- >
187
- <Trash2 className={icon} />
188
- Delete session
189
- </Item>
190
- </>
109
+ const plan = rowMenu({
110
+ session,
111
+ row,
112
+ remote: !!remote,
113
+ canOpenInTab: !!ctx.onOpenInTab,
114
+ hasCard: !!cardUrl,
115
+ recipeRun: !!recipeRun,
116
+ hasWorkspace: !!workspaceId,
117
+ machineTargets: targets.length,
118
+ otherProjects: others.length,
119
+ });
120
+
121
+ const item = (
122
+ label: string,
123
+ Icon: typeof Copy,
124
+ onClick: () => void,
125
+ extra?: { className?: string; disabled?: boolean; spin?: boolean }
126
+ ) => (
127
+ <MenuItem
128
+ key={label}
129
+ kind={kind}
130
+ onClick={onClick}
131
+ disabled={extra?.disabled}
132
+ className={extra?.className}
133
+ >
134
+ <Icon className={`${icon}${extra?.spin ? "animate-spin" : ""}`} />
135
+ {label}
136
+ </MenuItem>
137
+ );
138
+ const open = (url: string) => () => window.open(url, "_blank");
139
+ const projects = others.map((p) => (
140
+ <MenuItem
141
+ key={p.id}
142
+ kind={kind}
143
+ onClick={() => ctx.onMoveToProject(session.id, p.id)}
144
+ >
145
+ {p.name}
146
+ </MenuItem>
147
+ ));
148
+ const wake = (ms: number | null) =>
149
+ ms && (
150
+ <span className="text-muted-foreground ml-3 text-xs">
151
+ {wakeLabel(ms)}
152
+ </span>
191
153
  );
192
154
 
155
+ const render = (it: RowMenuItem, inGroup: boolean) => {
156
+ switch (it) {
157
+ case "rename":
158
+ return item("Rename", Pencil, onRename);
159
+ case "pin":
160
+ return item("Pin", Pin, () => ctx.onPin(session.id, true));
161
+ case "unpin":
162
+ return item("Unpin", PinOff, () => ctx.onPin(session.id, false));
163
+ case "select":
164
+ return item("Select", CheckSquare, () =>
165
+ selectionActions.toggle(session.id, false, ctx.orderedIds())
166
+ );
167
+ case "open-tab":
168
+ return item("Open in new tab", ExternalLink, () =>
169
+ ctx.onOpenInTab?.(session.id)
170
+ );
171
+ case "open-pr":
172
+ return item(
173
+ `Open PR #${session.pr_number}`,
174
+ GitPullRequest,
175
+ open(session.pr_url!)
176
+ );
177
+ case "card":
178
+ return item("LumifyHub card", KanbanSquare, open(cardUrl!));
179
+ case "open-recipe":
180
+ return item("Open recipe", Repeat, () =>
181
+ recipesUiActions.openRecipe(recipeRun!.id)
182
+ );
183
+ case "copy-link":
184
+ return item("Copy link", Link, () => copySessionLink(session.id));
185
+ case "share":
186
+ return item("Share…", Share2, () =>
187
+ shareUiActions.open({
188
+ kind: "chat",
189
+ sessionId: session.id,
190
+ title: session.name,
191
+ })
192
+ );
193
+ case "fork":
194
+ return item("Fork session", Copy, () => ctx.onFork(session.id));
195
+ case "fresh-start":
196
+ return item(
197
+ summarizing ? "Summarizing..." : "Fresh start",
198
+ summarizing ? Loader2 : Sparkles,
199
+ () => ctx.onSummarize(session.id),
200
+ { disabled: summarizing, spin: summarizing }
201
+ );
202
+ case "new-conversation":
203
+ return item("New conversation (keep brief)", RotateCcw, () =>
204
+ ctx.onFresh(session.id)
205
+ );
206
+ case "move-machine":
207
+ return (
208
+ <MoveMenuItems key={it} session={session} Item={PARTS[kind].Item} />
209
+ );
210
+ case "move-project":
211
+ return inGroup ? (
212
+ <div key={it}>
213
+ {targets.length > 0 && <Separator />}
214
+ {projects}
215
+ </div>
216
+ ) : (
217
+ <Expander
218
+ key={it}
219
+ kind={kind}
220
+ icon={FolderInput}
221
+ label="Move to project"
222
+ >
223
+ {projects}
224
+ </Expander>
225
+ );
226
+ case "check-ins":
227
+ return item("Schedule check-ins", Clock, () =>
228
+ schedulesUiActions.openDraft(workspaceId!, {
229
+ kind: "message",
230
+ targetSessionId: session.id,
231
+ name: `Check-in: ${session.name}`.slice(0, 80),
232
+ })
233
+ );
234
+ case "save-recipe":
235
+ return item("Save as recipe", ChefHat, () =>
236
+ recipesUiActions.saveSessionAs(session.id, workspaceId)
237
+ );
238
+ case "run-again":
239
+ return item("Run again", Play, () =>
240
+ recipesUiActions.openRecipe(recipeRun!.id, true)
241
+ );
242
+ case "snooze":
243
+ return (
244
+ <Expander key={it} kind={kind} icon={AlarmClock} label="Snooze">
245
+ {SNOOZE_PRESETS.map((p) => (
246
+ <MenuItem
247
+ key={p.key}
248
+ kind={kind}
249
+ onClick={() => shelf.snooze(session, p.key)}
250
+ >
251
+ {p.label}
252
+ {wake(snoozePreset(p.key).getTime())}
253
+ </MenuItem>
254
+ ))}
255
+ </Expander>
256
+ );
257
+ case "unsnooze":
258
+ return item("Unsnooze", AlarmClock, () => shelf.unsnooze(session));
259
+ case "pause":
260
+ return (
261
+ <Expander key={it} kind={kind} icon={Pause} label="Pause">
262
+ {PAUSE_PRESETS.map((p) => (
263
+ <MenuItem
264
+ key={p.key}
265
+ kind={kind}
266
+ onClick={() => shelf.pause(session, p.key)}
267
+ >
268
+ {p.label}
269
+ {wake(pauseUntil(p.key)?.getTime() ?? null)}
270
+ </MenuItem>
271
+ ))}
272
+ <MenuItem
273
+ kind={kind}
274
+ onClick={() => shelf.pause(session, "until-resumed", true)}
275
+ >
276
+ Pause now (interrupt its step)
277
+ </MenuItem>
278
+ </Expander>
279
+ );
280
+ case "resume":
281
+ return item(
282
+ session.pause_state === "paused" ? "Resume now" : "Keep running",
283
+ Play,
284
+ () => shelf.resume(session)
285
+ );
286
+ case "settle":
287
+ return item("Mark done", CircleCheck, () => shelf.settle(session));
288
+ case "reopen":
289
+ return item("Reopen", RotateCcw, () => shelf.reopen(session));
290
+ case "done":
291
+ return item("Done", CircleCheck, () => markDone(session));
292
+ case "restore":
293
+ return item("Restore", RotateCcw, () => restore(session));
294
+ case "archive":
295
+ return item("Archive", Archive, () => archive(session));
296
+ case "delete":
297
+ return item("Delete session", Trash2, () => ctx.onDelete(session.id), {
298
+ className: "text-red-500 focus:text-red-500",
299
+ });
300
+ }
301
+ };
302
+
193
303
  return (
194
304
  <>
195
- {session.branch_name && (
305
+ {session.branch_name && !session.quick_of && (
196
306
  <>
197
307
  <div className="text-muted-foreground flex flex-col gap-0.5 px-2 py-1.5 text-xs">
198
308
  <span className="flex items-center gap-1.5">
@@ -208,278 +318,32 @@ export function SessionRowMenu({
208
318
  <Separator />
209
319
  </>
210
320
  )}
211
- <Item onClick={() => ctx.onPin(session.id, !session.pinned)}>
212
- {session.pinned ? (
213
- <PinOff className={icon} />
214
- ) : (
215
- <Pin className={icon} />
216
- )}
217
- {session.pinned ? "Unpin" : "Pin"}
218
- </Item>
219
- {ctx.onOpenInTab && (
220
- <Item onClick={() => ctx.onOpenInTab?.(session.id)}>
221
- <ExternalLink className={icon} />
222
- Open in new tab
223
- </Item>
224
- )}
225
- <Item onClick={() => copySessionLink(session.id)}>
226
- <Link className={icon} />
227
- Copy link
228
- </Item>
229
- {session.view === "chat" && (
230
- <Item
231
- onClick={() =>
232
- shareUiActions.open({
233
- kind: "chat",
234
- sessionId: session.id,
235
- title: session.name,
236
- })
237
- }
238
- >
239
- <Share2 className={icon} />
240
- Share…
241
- </Item>
242
- )}
243
- <Item onClick={onRename}>
244
- <Pencil className={icon} />
245
- Rename
246
- </Item>
247
- {session.agent_type === "claude" && !session.role && blocked && (
248
- <BlockedItem
249
- kind={kind}
250
- icon={Copy}
251
- label="Fork session"
252
- why={blocked.fork}
253
- />
321
+ {plan.remoteNote && remote && (
322
+ <div className="text-muted-foreground px-2 py-1.5 text-xs leading-tight">
323
+ {remoteNote(linked[session.host_id!] ?? "its machine")}
324
+ </div>
254
325
  )}
255
- {!session.role && blocked && (
256
- <BlockedItem
257
- kind={kind}
258
- icon={Sparkles}
259
- label="Fresh start"
260
- why={blocked.freshStart}
261
- />
262
- )}
263
- {session.agent_type === "claude" && !session.role && !blocked && (
264
- <Item onClick={() => ctx.onFork(session.id)}>
265
- <Copy className={icon} />
266
- Fork session
267
- </Item>
268
- )}
269
- {!session.role && !blocked && (
270
- <Item
271
- onClick={() => ctx.onSummarize(session.id)}
272
- disabled={summarizing}
273
- >
274
- {summarizing ? (
275
- <Loader2 className={`${icon} animate-spin`} />
276
- ) : (
277
- <Sparkles className={icon} />
278
- )}
279
- {summarizing ? "Summarizing..." : "Fresh start"}
280
- </Item>
281
- )}
282
- {(session.role === "orchestrator" || !!session.has_brief) &&
283
- session.view === "chat" &&
284
- !session.archived_at &&
285
- !blocked && (
286
- <Item onClick={() => ctx.onFresh(session.id)}>
287
- <RotateCcw className={icon} />
288
- New conversation (keep brief)
289
- </Item>
290
- )}
291
- {session.pr_url && (
292
- <Item onClick={() => window.open(session.pr_url!, "_blank")}>
293
- <GitPullRequest className={icon} />
294
- Open PR #{session.pr_number}
295
- </Item>
296
- )}
297
- {cardUrl && (
298
- <Item onClick={() => window.open(cardUrl, "_blank")}>
299
- <KanbanSquare className={icon} />
300
- LumifyHub card
301
- </Item>
302
- )}
303
- <MoveMenuItems session={session} Item={Item} />
304
- {!session.role && others.length > 0 && blocked && (
305
- <BlockedItem
306
- kind={kind}
307
- icon={FolderInput}
308
- label="Move to project"
309
- why={blocked.moveToProject}
310
- />
311
- )}
312
- {!session.role && others.length > 0 && !blocked && (
313
- <Sub>
314
- <SubTrigger>
315
- <FolderInput className={icon} />
316
- Move to project
317
- </SubTrigger>
318
- <SubContent>
319
- {others.map((p) => (
320
- <Item
321
- key={p.id}
322
- onClick={() => ctx.onMoveToProject(session.id, p.id)}
326
+ {plan.sections.map((section, i) => (
327
+ <Fragment key={i}>
328
+ {i > 0 && <Separator />}
329
+ {section.map((entry) =>
330
+ typeof entry === "string" ? (
331
+ render(entry, false)
332
+ ) : (
333
+ <Expander
334
+ key={entry.group}
335
+ kind={kind}
336
+ icon={GROUP_ICON[entry.group]}
337
+ label={groupLabel(entry.group)}
323
338
  >
324
- {p.name}
325
- </Item>
326
- ))}
327
- </SubContent>
328
- </Sub>
329
- )}
330
- {workspaceId &&
331
- session.role !== "orchestrator" &&
332
- (!session.task_status || session.task_status === "running") &&
333
- blocked && (
334
- <BlockedItem
335
- kind={kind}
336
- icon={Clock}
337
- label="Schedule check-ins"
338
- why={blocked.schedule}
339
- />
340
- )}
341
- {workspaceId &&
342
- session.role !== "orchestrator" &&
343
- (!session.task_status || session.task_status === "running") &&
344
- !blocked && (
345
- <Item
346
- onClick={() =>
347
- schedulesUiActions.openDraft(workspaceId, {
348
- kind: "message",
349
- targetSessionId: session.id,
350
- name: `Check-in: ${session.name}`.slice(0, 80),
351
- })
352
- }
353
- >
354
- <Clock className={icon} />
355
- Schedule check-ins
356
- </Item>
357
- )}
358
- {recipeRun && (
359
- <>
360
- <Item onClick={() => recipesUiActions.openRecipe(recipeRun.id)}>
361
- <Repeat className={icon} />
362
- Open recipe
363
- </Item>
364
- <Item onClick={() => recipesUiActions.openRecipe(recipeRun.id, true)}>
365
- <Play className={icon} />
366
- Run again
367
- </Item>
368
- </>
369
- )}
370
- {!recipeRun && !session.role && !session.quick_of && (
371
- <Item
372
- onClick={() =>
373
- recipesUiActions.saveSessionAs(session.id, workspaceId)
374
- }
375
- >
376
- <ChefHat className={icon} />
377
- Save as recipe
378
- </Item>
379
- )}
380
- <Item
381
- onClick={() =>
382
- selectionActions.toggle(session.id, false, ctx.orderedIds())
383
- }
384
- >
385
- <CheckSquare className={icon} />
386
- Select
387
- </Item>
388
- {!session.role && (
389
- <>
390
- <Separator />
391
- {row?.wakesAt ? (
392
- <Item onClick={() => shelf.unsnooze(session)}>
393
- <AlarmClock className={icon} />
394
- Unsnooze
395
- </Item>
396
- ) : (
397
- !session.pinned &&
398
- row && (
399
- <Sub>
400
- <SubTrigger>
401
- <AlarmClock className={icon} />
402
- Snooze
403
- </SubTrigger>
404
- <SubContent>
405
- {SNOOZE_PRESETS.map((p) => (
406
- <Item
407
- key={p.key}
408
- onClick={() => shelf.snooze(session, p.key)}
409
- >
410
- {p.label}
411
- <span className="text-muted-foreground ml-3 text-xs">
412
- {wakeLabel(snoozePreset(p.key).getTime())}
413
- </span>
414
- </Item>
415
- ))}
416
- </SubContent>
417
- </Sub>
339
+ {entry.items.map((it) => (
340
+ <Fragment key={it}>{render(it, true)}</Fragment>
341
+ ))}
342
+ </Expander>
418
343
  )
419
344
  )}
420
- {session.task_status || session.view === "chat" ? (
421
- session.pause_state === "paused" ||
422
- session.pause_state === "pausing" ? (
423
- <Item onClick={() => shelf.resume(session)}>
424
- <Play className={icon} />
425
- {session.pause_state === "paused"
426
- ? "Resume now"
427
- : "Keep running"}
428
- </Item>
429
- ) : session.pause_state ? null : (
430
- <Sub>
431
- <SubTrigger>
432
- <Pause className={icon} />
433
- Pause
434
- </SubTrigger>
435
- <SubContent>
436
- {PAUSE_PRESETS.map((p) => {
437
- const at = pauseUntil(p.key);
438
- return (
439
- <Item
440
- key={p.key}
441
- onClick={() => shelf.pause(session, p.key)}
442
- >
443
- {p.label}
444
- {at && (
445
- <span className="text-muted-foreground ml-3 text-xs">
446
- {wakeLabel(at.getTime())}
447
- </span>
448
- )}
449
- </Item>
450
- );
451
- })}
452
- <Item
453
- onClick={() => shelf.pause(session, "until-resumed", true)}
454
- >
455
- Pause now (interrupt its step)
456
- </Item>
457
- </SubContent>
458
- </Sub>
459
- )
460
- ) : null}
461
- {plain &&
462
- (row.settled ? (
463
- <Item onClick={() => shelf.reopen(session)}>
464
- <RotateCcw className={icon} />
465
- Reopen
466
- </Item>
467
- ) : (
468
- <Item onClick={() => shelf.settle(session)}>
469
- <CircleCheck className={icon} />
470
- Mark done
471
- </Item>
472
- ))}
473
- <FinishItems session={session} plain={plain} Item={Item} />
474
- <Item
475
- onClick={() => ctx.onDelete(session.id)}
476
- className="text-red-500 focus:text-red-500"
477
- >
478
- <Trash2 className={icon} />
479
- Delete session
480
- </Item>
481
- </>
482
- )}
345
+ </Fragment>
346
+ ))}
483
347
  </>
484
348
  );
485
349
  }
@@ -45,8 +45,9 @@ const ContextMenuSubContent = React.forwardRef<
45
45
  >(({ className, ...props }, ref) => (
46
46
  <ContextMenuPrimitive.SubContent
47
47
  ref={ref}
48
+ collisionPadding={8}
48
49
  className={cn(
49
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-xl p-1.5",
50
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
50
51
  className
51
52
  )}
52
53
  {...props}
@@ -61,8 +62,9 @@ const ContextMenuContent = React.forwardRef<
61
62
  <ContextMenuPrimitive.Portal>
62
63
  <ContextMenuPrimitive.Content
63
64
  ref={ref}
65
+ collisionPadding={8}
64
66
  className={cn(
65
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-xl p-1.5",
67
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
66
68
  className
67
69
  )}
68
70
  {...props}
@@ -34,6 +34,7 @@ function DropdownMenuTrigger({
34
34
  function DropdownMenuContent({
35
35
  className,
36
36
  sideOffset = 4,
37
+ collisionPadding = 8,
37
38
  ...props
38
39
  }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
39
40
  return (
@@ -41,8 +42,9 @@ function DropdownMenuContent({
41
42
  <DropdownMenuPrimitive.Content
42
43
  data-slot="dropdown-menu-content"
43
44
  sideOffset={sideOffset}
45
+ collisionPadding={collisionPadding}
44
46
  className={cn(
45
- "popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl p-1.5",
47
+ "popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
46
48
  className
47
49
  )}
48
50
  {...props}
@@ -229,8 +231,9 @@ function DropdownMenuSubContent({
229
231
  return (
230
232
  <DropdownMenuPrimitive.SubContent
231
233
  data-slot="dropdown-menu-sub-content"
234
+ collisionPadding={8}
232
235
  className={cn(
233
- "popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-xl p-1.5",
236
+ "popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
234
237
  className
235
238
  )}
236
239
  {...props}
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ // Where a menu opens (flip, shift, the 8px gap) is floating-ui's job inside
5
+ // Radix, and Radix can't mount in the suite's fake DOM. What is ours is that
6
+ // every menu surface hands its height to the room left on screen and scrolls
7
+ // past it instead of clipping, so that is what this pins. The geometry itself
8
+ // is checked in a browser.
9
+ const surfaces = (file: string) =>
10
+ readFileSync(new URL(file, import.meta.url), "utf8")
11
+ .split("\n")
12
+ .filter((l) => l.includes("popover-surface"));
13
+
14
+ describe.each([
15
+ ["./dropdown-menu.tsx", "dropdown-menu"],
16
+ ["./context-menu.tsx", "context-menu"],
17
+ ])("%s", (file, name) => {
18
+ const lines = surfaces(file);
19
+
20
+ it("has a menu and a submenu surface", () => {
21
+ expect(lines).toHaveLength(2);
22
+ });
23
+
24
+ it.each([0, 1])("caps surface %i to the space available and scrolls", (i) => {
25
+ expect(lines[i]).toContain(
26
+ `max-h-(--radix-${name}-content-available-height)`
27
+ );
28
+ expect(lines[i]).toContain("overflow-y-auto");
29
+ expect(lines[i]).not.toContain("overflow-hidden");
30
+ });
31
+
32
+ it("keeps menus off the window edge", () => {
33
+ const src = readFileSync(new URL(file, import.meta.url), "utf8");
34
+ expect(src.match(/collisionPadding/g)?.length).toBeGreaterThanOrEqual(2);
35
+ });
36
+ });
@@ -0,0 +1,9 @@
1
+ "use client";
2
+
3
+ import { useMediaQuery } from "./useMediaQuery";
4
+
5
+ // A phone or a touch screen: menus use one scrolling list with sections that
6
+ // open in place, not flyouts a finger can't hold open.
7
+ export function useCompactMenu(): boolean {
8
+ return useMediaQuery("(max-width: 767px), (pointer: coarse)");
9
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ const supported = () =>
6
+ typeof window !== "undefined" && typeof window.matchMedia === "function";
7
+
8
+ // Whether a CSS media query matches now, and stays in step as it changes.
9
+ export function useMediaQuery(query: string): boolean {
10
+ return useSyncExternalStore(
11
+ (listener) => {
12
+ if (!supported()) return () => {};
13
+ const mq = window.matchMedia(query);
14
+ mq.addEventListener("change", listener);
15
+ return () => mq.removeEventListener("change", listener);
16
+ },
17
+ () => supported() && window.matchMedia(query).matches,
18
+ () => false
19
+ );
20
+ }
@@ -13,6 +13,10 @@ export const REMOTE_UNSUPPORTED = {
13
13
  schedule: (host: string) => `Check-ins can't message ${host} yet`,
14
14
  };
15
15
 
16
+ // The one line that replaces the hidden items.
17
+ export const remoteNote = (host: string) =>
18
+ `Runs on ${host}: fork, fresh start, project moves and check-ins happen there`;
19
+
16
20
  export type RemoteAction = keyof typeof REMOTE_UNSUPPORTED;
17
21
 
18
22
  /**
@@ -0,0 +1,168 @@
1
+ import type { Session } from "@/lib/db/types";
2
+ import type { SidebarRow } from "./shelves";
3
+ import { finishActions } from "./row-actions";
4
+
5
+ // What a session row's ⋯ / right-click menu offers, as data: which items,
6
+ // in which sections, and which sit together in a submenu. The component only
7
+ // draws it. An item that doesn't apply is absent, never shown disabled.
8
+ export type RowMenuItem =
9
+ | "rename"
10
+ | "pin"
11
+ | "unpin"
12
+ | "select"
13
+ | "open-tab"
14
+ | "open-pr"
15
+ | "card"
16
+ | "open-recipe"
17
+ | "copy-link"
18
+ | "share"
19
+ | "fork"
20
+ | "fresh-start"
21
+ | "new-conversation"
22
+ | "move-machine"
23
+ | "move-project"
24
+ | "check-ins"
25
+ | "save-recipe"
26
+ | "run-again"
27
+ | "snooze"
28
+ | "unsnooze"
29
+ | "pause"
30
+ | "resume"
31
+ | "settle"
32
+ | "reopen"
33
+ | "done"
34
+ | "restore"
35
+ | "archive"
36
+ | "delete";
37
+
38
+ export type RowMenuGroup = "open" | "share" | "restart" | "move" | "automate";
39
+
40
+ export type RowMenuEntry =
41
+ | RowMenuItem
42
+ | { group: RowMenuGroup; items: RowMenuItem[] };
43
+
44
+ export interface RowMenuInput {
45
+ session: Pick<
46
+ Session,
47
+ | "role"
48
+ | "quick_of"
49
+ | "pinned"
50
+ | "view"
51
+ | "agent_type"
52
+ | "task_status"
53
+ | "pr_url"
54
+ | "has_brief"
55
+ | "archived_at"
56
+ | "pause_state"
57
+ >;
58
+ row?: Pick<SidebarRow, "task" | "wakesAt" | "settled">;
59
+ // A linked machine runs it: what can't reach it yet is left out.
60
+ remote: boolean;
61
+ canOpenInTab: boolean;
62
+ hasCard: boolean;
63
+ recipeRun: boolean;
64
+ hasWorkspace: boolean;
65
+ machineTargets: number;
66
+ otherProjects: number;
67
+ }
68
+
69
+ export interface RowMenuPlan {
70
+ sections: RowMenuEntry[][];
71
+ // Say once why Fork, Fresh start, project moves and check-ins are missing.
72
+ remoteNote: boolean;
73
+ }
74
+
75
+ const GROUP_LABEL: Record<RowMenuGroup, string> = {
76
+ open: "Open",
77
+ share: "Share",
78
+ restart: "Fork or restart",
79
+ move: "Move",
80
+ automate: "Automate",
81
+ };
82
+ export const groupLabel = (g: RowMenuGroup) => GROUP_LABEL[g];
83
+
84
+ // A group of one is just that item; an empty one is nothing.
85
+ function group(
86
+ g: RowMenuGroup,
87
+ items: (RowMenuItem | false)[]
88
+ ): RowMenuEntry[] {
89
+ const kept = items.filter((i): i is RowMenuItem => !!i);
90
+ if (kept.length === 0) return [];
91
+ return kept.length === 1 ? kept : [{ group: g, items: kept }];
92
+ }
93
+
94
+ export function rowMenu(input: RowMenuInput): RowMenuPlan {
95
+ const { session: s, row, remote } = input;
96
+ const plain = !!row && !row.task && !s.task_status;
97
+ const finish = finishActions(s, plain);
98
+ const sections: RowMenuEntry[][] = [];
99
+
100
+ // A quick change has no agent: its PR and the ways to finish it.
101
+ if (s.quick_of) {
102
+ sections.push(s.pr_url ? ["open-pr"] : []);
103
+ sections.push(finish.filter((a) => a !== "archive"));
104
+ sections.push(["archive", "delete"]);
105
+ return {
106
+ sections: sections.filter((x) => x.length),
107
+ remoteNote: false,
108
+ };
109
+ }
110
+
111
+ const orchestrator = s.role === "orchestrator";
112
+ const agentRow = !s.role;
113
+ const live = !remote;
114
+
115
+ sections.push(["rename", s.pinned ? "unpin" : "pin", "select"]);
116
+
117
+ sections.push([
118
+ ...group("open", [
119
+ input.canOpenInTab && "open-tab",
120
+ !!s.pr_url && "open-pr",
121
+ input.hasCard && "card",
122
+ input.recipeRun && "open-recipe",
123
+ ]),
124
+ ...group("share", ["copy-link", s.view === "chat" && "share"]),
125
+ ...group("restart", [
126
+ s.agent_type === "claude" && agentRow && live && "fork",
127
+ agentRow && live && "fresh-start",
128
+ (orchestrator || !!s.has_brief) &&
129
+ s.view === "chat" &&
130
+ !s.archived_at &&
131
+ live &&
132
+ "new-conversation",
133
+ ]),
134
+ ...group("move", [
135
+ input.machineTargets > 0 && "move-machine",
136
+ agentRow && input.otherProjects > 0 && live && "move-project",
137
+ ]),
138
+ ...group("automate", [
139
+ input.hasWorkspace &&
140
+ !orchestrator &&
141
+ (!s.task_status || s.task_status === "running") &&
142
+ live &&
143
+ "check-ins",
144
+ input.recipeRun ? "run-again" : agentRow && "save-recipe",
145
+ ]),
146
+ ]);
147
+
148
+ if (agentRow) {
149
+ const state: RowMenuEntry[] = [];
150
+ if (row?.wakesAt) state.push("unsnooze");
151
+ else if (!s.pinned && row) state.push("snooze");
152
+ const finished = !!s.task_status && s.task_status !== "running";
153
+ if ((s.task_status || s.view === "chat") && !finished) {
154
+ if (s.pause_state === "paused" || s.pause_state === "pausing")
155
+ state.push("resume");
156
+ else if (!s.pause_state) state.push("pause");
157
+ }
158
+ if (plain) state.push(row.settled ? "reopen" : "settle");
159
+ state.push(...finish.filter((a) => a !== "archive"));
160
+ sections.push(state);
161
+ sections.push(["archive", "delete"]);
162
+ }
163
+
164
+ return {
165
+ sections: sections.filter((x) => x.length),
166
+ remoteNote: remote && agentRow,
167
+ };
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.608",
3
+ "version": "0.3.609",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"