@saadnvd1/agent-os 0.3.606 → 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,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
  }