@saadnvd1/agent-os 0.3.700 → 0.3.715

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 (185) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/spawn/route.ts +12 -1
  4. package/app/api/claims/[id]/route.ts +20 -0
  5. package/app/api/claims/check/route.ts +23 -0
  6. package/app/api/claims/check.test.ts +113 -0
  7. package/app/api/claims/exchange/route.ts +42 -0
  8. package/app/api/claims/outside.test.ts +90 -0
  9. package/app/api/claims/route.test.ts +193 -0
  10. package/app/api/claims/route.ts +73 -0
  11. package/app/api/hosts/[id]/route.ts +9 -1
  12. package/app/api/peer/unlink/route.ts +17 -0
  13. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  14. package/app/api/peers/[hostId]/route.ts +20 -0
  15. package/app/api/peers/gate.test.ts +64 -1
  16. package/app/api/peers/pending/route.test.ts +37 -0
  17. package/app/api/peers/pending/route.ts +11 -4
  18. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  19. package/app/api/session-groups/[id]/route.ts +25 -0
  20. package/app/api/session-groups/order/route.ts +15 -0
  21. package/app/api/session-groups/route.ts +21 -0
  22. package/app/api/sessions/[id]/fork/route.ts +3 -0
  23. package/app/api/sessions/[id]/group/route.ts +18 -0
  24. package/app/api/sessions/[id]/route.ts +14 -4
  25. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  26. package/app/api/sessions/claims.test.ts +122 -0
  27. package/app/api/sessions/group/route.ts +17 -0
  28. package/app/api/sessions/route.ts +15 -14
  29. package/app/page.tsx +5 -0
  30. package/components/Claims/ClaimFiles.tsx +38 -0
  31. package/components/Claims/ClaimRow.tsx +82 -0
  32. package/components/Claims/ClaimsSheet.tsx +176 -0
  33. package/components/Claims/OverlapPill.tsx +31 -0
  34. package/components/Claims/StartClaimsNotice.tsx +70 -0
  35. package/components/Claims/claims-ui.test.ts +161 -0
  36. package/components/Claims/format.ts +57 -0
  37. package/components/Claims/index.ts +3 -0
  38. package/components/Draft/DraftPanel.tsx +40 -2
  39. package/components/Hosts/AccountMachines.tsx +32 -18
  40. package/components/Hosts/HostRow.tsx +155 -14
  41. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  42. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  43. package/components/SessionList/GroupShelves.tsx +195 -0
  44. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  45. package/components/SessionList/RowSubtitle.tsx +7 -1
  46. package/components/SessionList/SelectionToolbar.tsx +52 -1
  47. package/components/SessionList/SessionRow.tsx +1 -0
  48. package/components/SessionList/SessionRowMenu.tsx +17 -0
  49. package/components/SessionList/SessionShelves.tsx +5 -0
  50. package/components/SessionList/SidebarBody.tsx +6 -0
  51. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  52. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  53. package/components/SessionList/SidebarHeader.tsx +5 -0
  54. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  55. package/components/SessionList/index.tsx +18 -14
  56. package/components/SessionList/useSidebarData.ts +10 -0
  57. package/components/ui/notice.tsx +46 -0
  58. package/components/ui/status-pill.tsx +39 -0
  59. package/data/claims/index.ts +2 -0
  60. package/data/claims/keys.ts +5 -0
  61. package/data/claims/queries.ts +47 -0
  62. package/data/peers/keys.ts +1 -0
  63. package/data/peers/queries.ts +31 -0
  64. package/data/push/topics.ts +4 -0
  65. package/data/session-groups/index.ts +156 -0
  66. package/data/sessions/index.ts +0 -1
  67. package/data/sessions/pin.ts +2 -6
  68. package/data/sessions/queries.ts +10 -29
  69. package/data/sessions/shelf.ts +2 -6
  70. package/data/tasks/index.ts +2 -0
  71. package/hooks/useSessions.ts +0 -12
  72. package/lib/__fixtures__/peer-rpc.ts +65 -33
  73. package/lib/agents/spawn.ts +4 -0
  74. package/lib/bus/index.ts +8 -0
  75. package/lib/claims/collectLocal.ts +121 -0
  76. package/lib/claims/describe.ts +41 -0
  77. package/lib/claims/exchange.ts +91 -0
  78. package/lib/claims/goal.ts +45 -0
  79. package/lib/claims/index.ts +10 -0
  80. package/lib/claims/loop.ts +51 -0
  81. package/lib/claims/machine.ts +21 -0
  82. package/lib/claims/mine.ts +43 -0
  83. package/lib/claims/notifyOverlaps.ts +101 -0
  84. package/lib/claims/onMerged.ts +85 -0
  85. package/lib/claims/outside.ts +36 -0
  86. package/lib/claims/overlap.ts +87 -0
  87. package/lib/claims/overlapCache.ts +86 -0
  88. package/lib/claims/overlapText.ts +36 -0
  89. package/lib/claims/peers.ts +56 -0
  90. package/lib/claims/startCheck.ts +100 -0
  91. package/lib/claims/startWarning.ts +29 -0
  92. package/lib/claims/store.ts +366 -0
  93. package/lib/claims/types.ts +102 -0
  94. package/lib/claims/view.ts +80 -0
  95. package/lib/claims/yield.ts +24 -0
  96. package/lib/db/changes.ts +6 -0
  97. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  98. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  99. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  100. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  101. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  102. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  103. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  104. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  105. package/lib/db/queries.ts +0 -40
  106. package/lib/db/session-parts/columns.ts +1 -0
  107. package/lib/db/types.ts +16 -8
  108. package/lib/doctor/checks.ts +64 -1
  109. package/lib/git-remote.ts +43 -0
  110. package/lib/hosts/index.ts +10 -1
  111. package/lib/orchestration.ts +2 -0
  112. package/lib/orchestrator/act.ts +54 -4
  113. package/lib/orchestrator/brief.ts +2 -1
  114. package/lib/orchestrator/claim-wait.ts +71 -0
  115. package/lib/orchestrator/conditions.ts +28 -1
  116. package/lib/orchestrator/describe.ts +6 -1
  117. package/lib/orchestrator/facts.ts +5 -0
  118. package/lib/orchestrator/serve.ts +37 -2
  119. package/lib/orchestrator/tool-names.ts +1 -0
  120. package/lib/orchestrator/tool-schemas.ts +31 -1
  121. package/lib/orchestrator/tools.ts +3 -1
  122. package/lib/orchestrator/watcher.ts +19 -1
  123. package/lib/peer/account.ts +74 -2
  124. package/lib/peer/allowlist.ts +1 -1
  125. package/lib/peer/client.ts +26 -1
  126. package/lib/peer/link-cli.ts +19 -4
  127. package/lib/peer/links.ts +117 -5
  128. package/lib/peer/machines.ts +14 -2
  129. package/lib/peer/name.ts +32 -0
  130. package/lib/peer/pairing.ts +34 -5
  131. package/lib/peer/pin.ts +10 -3
  132. package/lib/peer/reasons.ts +7 -0
  133. package/lib/peer/revoke.ts +75 -0
  134. package/lib/peer/rpc-server.ts +53 -5
  135. package/lib/peer/unlink.ts +62 -0
  136. package/lib/peer/unlinked-proof.ts +44 -0
  137. package/lib/projects.ts +5 -2
  138. package/lib/security/demo.ts +7 -0
  139. package/lib/session-groups/assignSessions.ts +81 -0
  140. package/lib/session-groups/cli.ts +61 -0
  141. package/lib/session-groups/createGroup.ts +28 -0
  142. package/lib/session-groups/index.ts +21 -0
  143. package/lib/session-groups/removeGroup.ts +36 -0
  144. package/lib/session-groups/reorderGroups.ts +31 -0
  145. package/lib/session-groups/resolveGroup.ts +19 -0
  146. package/lib/session-groups/route.ts +32 -0
  147. package/lib/session-groups/store.ts +85 -0
  148. package/lib/session-groups/types.ts +25 -0
  149. package/lib/session-groups/updateGroup.ts +35 -0
  150. package/lib/session-groups/workspace.ts +21 -0
  151. package/lib/sessions/launch.ts +5 -2
  152. package/lib/sidebar/rollup.ts +1 -0
  153. package/lib/sidebar/row-menu.ts +2 -0
  154. package/lib/sidebar/shelves.ts +90 -15
  155. package/lib/sidebar/visible-order.ts +5 -0
  156. package/lib/tasks/hold-checks.ts +3 -1
  157. package/lib/tasks/holds.ts +3 -0
  158. package/lib/tasks/index.ts +5 -0
  159. package/lib/tasks/queue-rows.ts +8 -1
  160. package/lib/tasks/queue-start.ts +1 -0
  161. package/lib/tasks/queue-types.ts +9 -1
  162. package/lib/tasks/queue.ts +15 -5
  163. package/lib/tasks/schedule-types.ts +1 -0
  164. package/lib/test-temp.ts +19 -3
  165. package/lib/workspaces.ts +11 -3
  166. package/lib/worktree-sweep/testTemp.ts +23 -106
  167. package/package.json +1 -1
  168. package/scripts/agent-os +1 -1
  169. package/scripts/autodeploy +2 -0
  170. package/scripts/autodeploy.test.ts +31 -1
  171. package/scripts/claim-hook.sh +31 -0
  172. package/scripts/claim-hook.test.ts +230 -0
  173. package/scripts/lib/cli-links.sh +36 -0
  174. package/scripts/lib/commands.sh +3 -0
  175. package/scripts/lib/machine.sh +27 -2
  176. package/scripts/lib/machine.test.ts +25 -3
  177. package/scripts/sync-cli-links +7 -0
  178. package/scripts/sync-cli-links.test.ts +69 -0
  179. package/server.ts +3 -0
  180. package/stores/claimsUi.ts +22 -0
  181. package/app/api/groups/[...path]/route.ts +0 -136
  182. package/app/api/groups/route.ts +0 -93
  183. package/data/groups/index.ts +0 -1
  184. package/data/groups/mutations.ts +0 -95
  185. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Check, Folder, Plus, X } from "lucide-react";
5
+ import { useSessionsQuery } from "@/data/sessions";
6
+ import { useAssignGroup, useCreateGroup } from "@/data/session-groups";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Expander, MenuItem, icon, type MenuKind } from "./RowMenuParts";
9
+
10
+ // "Move to group": the groups of the session's workspace with the current
11
+ // one ticked, a way out, and an inline field that makes a new group and
12
+ // files the session into it.
13
+ export function MoveToGroupMenu({
14
+ kind,
15
+ sessionId,
16
+ groupId,
17
+ workspaceId,
18
+ }: {
19
+ kind: MenuKind;
20
+ sessionId: string;
21
+ groupId: string | null | undefined;
22
+ workspaceId: string | null;
23
+ }) {
24
+ const { data } = useSessionsQuery();
25
+ const assign = useAssignGroup();
26
+ const create = useCreateGroup();
27
+ const [naming, setNaming] = useState(false);
28
+ const [name, setName] = useState("");
29
+ const groups = (data?.sessionGroups ?? [])
30
+ .filter((g) => g.workspace_id === workspaceId)
31
+ .sort((a, b) => a.sort_order - b.sort_order);
32
+
33
+ const make = async () => {
34
+ const next = name.trim();
35
+ if (!next) return;
36
+ const { sessionGroup } = await create.mutateAsync({
37
+ name: next,
38
+ workspaceId,
39
+ });
40
+ assign.mutate({ ids: [sessionId], groupId: sessionGroup.id });
41
+ setNaming(false);
42
+ setName("");
43
+ };
44
+
45
+ return (
46
+ <Expander kind={kind} icon={Folder} label="Move to group">
47
+ {groups.map((g) => (
48
+ <MenuItem
49
+ key={g.id}
50
+ kind={kind}
51
+ onClick={() =>
52
+ assign.mutate({
53
+ ids: [sessionId],
54
+ groupId: g.id === groupId ? null : g.id,
55
+ })
56
+ }
57
+ >
58
+ <Check className={`${icon} ${g.id === groupId ? "" : "invisible"}`} />
59
+ <span className="truncate">{g.name}</span>
60
+ </MenuItem>
61
+ ))}
62
+ {groupId && (
63
+ <MenuItem
64
+ kind={kind}
65
+ onClick={() => assign.mutate({ ids: [sessionId], groupId: null })}
66
+ >
67
+ <X className={icon} />
68
+ Remove from group
69
+ </MenuItem>
70
+ )}
71
+ {naming ? (
72
+ <div className="px-1.5 py-1">
73
+ <Input
74
+ autoFocus
75
+ value={name}
76
+ maxLength={60}
77
+ placeholder="Group name"
78
+ aria-label="New group name"
79
+ className="h-11 md:h-8"
80
+ onChange={(e) => setName(e.target.value)}
81
+ onKeyDown={(e) => {
82
+ // Keys here are typing, not the menu's typeahead.
83
+ e.stopPropagation();
84
+ if (e.key === "Enter") void make();
85
+ }}
86
+ />
87
+ <p className="text-muted-foreground/70 px-1 pt-1 text-[11px]">
88
+ ↵ create
89
+ </p>
90
+ </div>
91
+ ) : (
92
+ <MenuItem
93
+ kind={kind}
94
+ onSelect={(e) => {
95
+ e.preventDefault();
96
+ setNaming(true);
97
+ }}
98
+ >
99
+ <Plus className={icon} />
100
+ New group…
101
+ </MenuItem>
102
+ )}
103
+ </Expander>
104
+ );
105
+ }
@@ -9,6 +9,7 @@ import { headerParts } from "@/lib/orchestrator/header-line";
9
9
  import { cn } from "@/lib/utils";
10
10
  import { usageLabel, useSessionUsage } from "@/data/load";
11
11
  import { useRowContext } from "./RowContext";
12
+ import { OverlapPill } from "@/components/Claims/OverlapPill";
12
13
  import { pauseSubtitle } from "@/lib/sidebar/paused";
13
14
 
14
15
  const ATTENTION = (part: string) => part === "paused" || /\basks?$/.test(part);
@@ -22,11 +23,14 @@ export function RowSubtitle({
22
23
  detail,
23
24
  live,
24
25
  hideHost = false,
26
+ groupName,
25
27
  }: {
26
28
  session: Session;
27
29
  detail?: string | null;
28
30
  live?: LiveInput;
29
31
  hideHost?: boolean;
32
+ // Set on a finished session that keeps its group: Done shows where it goes back to.
33
+ groupName?: string;
30
34
  }) {
31
35
  const ctx = useRowContext();
32
36
  // What its processes use, on a busy session: "3.1 cores · 4.2 GB".
@@ -63,6 +67,7 @@ export function RowSubtitle({
63
67
  return (
64
68
  <span className="flex min-w-0 items-center gap-1.5">
65
69
  <LiveLine {...live} />
70
+ {session.overlap && <OverlapPill overlap={session.overlap} />}
66
71
  {pause && (
67
72
  <span className="flex min-w-0 items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
68
73
  <Pause aria-hidden className="h-3 w-3 shrink-0" />
@@ -102,13 +107,14 @@ export function RowSubtitle({
102
107
  </span>
103
108
  </span>
104
109
  )}
105
- {(project || detail || usage || session.placed_note) && (
110
+ {(project || detail || usage || session.placed_note || groupName) && (
106
111
  <span className="text-muted-foreground/70 min-w-0 truncate text-xs">
107
112
  {[
108
113
  usage && usageLabel(usage),
109
114
  detail?.slice(0, 200),
110
115
  session.placed_note,
111
116
  project,
117
+ groupName,
112
118
  ]
113
119
  .filter(Boolean)
114
120
  .join(" · ")}
@@ -3,7 +3,17 @@
3
3
  import { useEffect, useCallback, useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import { Button } from "@/components/ui/button";
6
- import { Trash2, X } from "lucide-react";
6
+ import { Check, Folder, Trash2, X } from "lucide-react";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from "@/components/ui/dropdown-menu";
14
+ import { useSessionsQuery } from "@/data/sessions";
15
+ import { useAssignGroup } from "@/data/session-groups";
16
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
7
17
  import { selectionStore, selectionActions } from "@/stores/sessionSelection";
8
18
  import {
9
19
  Dialog,
@@ -32,6 +42,19 @@ export function SelectionToolbar({
32
42
  const selectedCount = selectedIds.size;
33
43
  const [showDeleteDialog, setShowDeleteDialog] = useState(false);
34
44
  const [isDeleting, setIsDeleting] = useState(false);
45
+ const { workspace } = useSelectedWorkspace();
46
+ const { data } = useSessionsQuery();
47
+ const assign = useAssignGroup();
48
+ const groups = (data?.sessionGroups ?? []).filter(
49
+ (g) => !workspace || g.workspace_id === workspace.id
50
+ );
51
+ const moveTo = (groupId: string | null) => {
52
+ const ids = selectionActions.getSelectedIds();
53
+ assign.mutate(
54
+ { ids, groupId },
55
+ { onSuccess: () => selectionActions.clear() }
56
+ );
57
+ };
35
58
 
36
59
  const handleSelectAll = useCallback(() => {
37
60
  selectionActions.selectAll(allSessionIds);
@@ -101,6 +124,34 @@ export function SelectionToolbar({
101
124
  Select all
102
125
  </Button>
103
126
  )}
127
+ <DropdownMenu>
128
+ <DropdownMenuTrigger asChild>
129
+ <Button variant="ghost" size="sm" className="h-7 gap-1 text-xs">
130
+ <Folder className="h-3 w-3" />
131
+ Move to group
132
+ </Button>
133
+ </DropdownMenuTrigger>
134
+ <DropdownMenuContent align="end" className="w-52">
135
+ {groups.map((g) => (
136
+ <DropdownMenuItem
137
+ key={g.id}
138
+ className="min-h-11 md:min-h-8"
139
+ onSelect={() => moveTo(g.id)}
140
+ >
141
+ <Check className="invisible mr-2 h-3.5 w-3.5" />
142
+ <span className="truncate">{g.name}</span>
143
+ </DropdownMenuItem>
144
+ ))}
145
+ {groups.length > 0 && <DropdownMenuSeparator />}
146
+ <DropdownMenuItem
147
+ className="min-h-11 md:min-h-8"
148
+ onSelect={() => moveTo(null)}
149
+ >
150
+ <X className="mr-2 h-3.5 w-3.5" />
151
+ Remove from group
152
+ </DropdownMenuItem>
153
+ </DropdownMenuContent>
154
+ </DropdownMenu>
104
155
  <Tooltip>
105
156
  <TooltipTrigger asChild>
106
157
  <Button
@@ -139,6 +139,7 @@ export const SessionRow = memo(function SessionRow({
139
139
  <RowSubtitle
140
140
  session={session}
141
141
  hideHost={inMachineShelf}
142
+ groupName={row.groupName}
142
143
  detail={row.status?.detail}
143
144
  live={
144
145
  row.need
@@ -61,6 +61,7 @@ import {
61
61
  type RowMenuItem,
62
62
  } from "@/lib/sidebar/row-menu";
63
63
  import { Expander, MenuItem, PARTS, icon, type MenuKind } from "./RowMenuParts";
64
+ import { MoveToGroupMenu } from "./MoveToGroupMenu";
64
65
 
65
66
  const GROUP_ICON: Record<RowMenuGroup, typeof Copy> = {
66
67
  open: ExternalLink,
@@ -105,6 +106,12 @@ export function SessionRowMenu({
105
106
  ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
106
107
  null;
107
108
 
109
+ // The server files by the project's workspace first.
110
+ const groupWorkspaceId =
111
+ ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
112
+ session.workspace_id ??
113
+ null;
114
+
108
115
  const plan = rowMenu({
109
116
  session,
110
117
  row,
@@ -222,6 +229,16 @@ export function SessionRowMenu({
222
229
  {projects}
223
230
  </Expander>
224
231
  );
232
+ case "move-group":
233
+ return (
234
+ <MoveToGroupMenu
235
+ key={it}
236
+ kind={kind}
237
+ sessionId={session.id}
238
+ groupId={session.session_group_id}
239
+ workspaceId={groupWorkspaceId}
240
+ />
241
+ );
225
242
  case "check-ins":
226
243
  return item("Schedule check-ins", Clock, () =>
227
244
  schedulesUiActions.openDraft(workspaceId!, {
@@ -14,6 +14,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
14
14
  import { cn } from "@/lib/utils";
15
15
  import { usePendingCardSessions } from "@/data/learning-cards";
16
16
  import { SessionRow } from "./SessionRow";
17
+ import { GroupShelves } from "./GroupShelves";
17
18
  import { OrchestratorPinRow, OrchestratorStartRow } from "./OrchestratorPinRow";
18
19
  import { QueuedRow } from "./QueuedRow";
19
20
  import type { QueuedRow as QueuedRowData } from "@/lib/sidebar/queued";
@@ -117,6 +118,7 @@ export function SessionShelves({
117
118
  doneCollapsed,
118
119
  snoozedCollapsed,
119
120
  donePages,
121
+ workspaceNames,
120
122
  }: {
121
123
  shelves: Shelves;
122
124
  queued: QueuedRowData[];
@@ -124,6 +126,7 @@ export function SessionShelves({
124
126
  doneCollapsed: boolean;
125
127
  snoozedCollapsed: boolean;
126
128
  donePages: number;
129
+ workspaceNames?: Map<string, string>;
127
130
  }) {
128
131
  const folds = { doneCollapsed, snoozedCollapsed, donePages };
129
132
  const shown = shelves.done.slice(0, doneLimit(donePages));
@@ -142,6 +145,7 @@ export function SessionShelves({
142
145
  shelves.orchestrators.length +
143
146
  toStart.length +
144
147
  shelves.pinned.length +
148
+ shelves.groups.length +
145
149
  shelves.needsYou.length +
146
150
  shelves.working.length +
147
151
  shelves.inReview.length +
@@ -167,6 +171,7 @@ export function SessionShelves({
167
171
  ))}
168
172
  </Shelf>
169
173
  )}
174
+ <GroupShelves groups={shelves.groups} workspaceNames={workspaceNames} />
170
175
  {shelves.needsYou.length > 0 && (
171
176
  <Shelf label="Needs you" count={shelves.needsYou.length} accent>
172
177
  {shelves.needsYou.map((r) => (
@@ -63,6 +63,7 @@ export function SidebarBody({
63
63
  const count =
64
64
  shelves.orchestrators.length +
65
65
  shelves.pinned.length +
66
+ shelves.groups.length +
66
67
  shelves.needsYou.length +
67
68
  shelves.working.length +
68
69
  shelves.inReview.length +
@@ -125,6 +126,11 @@ export function SidebarBody({
125
126
  doneCollapsed={ui.doneCollapsed}
126
127
  snoozedCollapsed={ui.snoozedCollapsed}
127
128
  donePages={ui.donePages}
129
+ workspaceNames={
130
+ data.workspace
131
+ ? undefined
132
+ : new Map(data.workspaces.map((w) => [w.id, w.name]))
133
+ }
128
134
  />
129
135
  <MachineShelves groups={data.machines} />
130
136
  <SidebarExtras
@@ -0,0 +1,104 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const mutate = vi.fn();
7
+ vi.mock("@/data/session-groups", () => ({
8
+ useCreateGroup: () => ({ mutate }),
9
+ }));
10
+
11
+ // Radix menus need a real browser; render the item inline.
12
+ vi.mock("@/components/ui/context-menu", async () => {
13
+ const { createElement: h } = await import("react");
14
+ const part = (p: { children?: unknown }) =>
15
+ h("div", null, p.children as never);
16
+ return {
17
+ ContextMenu: part,
18
+ ContextMenuTrigger: part,
19
+ ContextMenuContent: part,
20
+ ContextMenuItem: (p: { children?: unknown; onSelect?: () => void }) =>
21
+ h("button", { onClick: p.onSelect }, p.children as never),
22
+ };
23
+ });
24
+
25
+ vi.mock("@/components/ui/dialog", async () => {
26
+ const { createElement: h } = await import("react");
27
+ const part = (p: { children?: unknown; open?: boolean }) =>
28
+ p.open === false ? null : h("div", null, p.children as never);
29
+ return {
30
+ Dialog: part,
31
+ DialogContent: part,
32
+ DialogHeader: part,
33
+ DialogTitle: part,
34
+ };
35
+ });
36
+
37
+ import { SidebarContextMenu, isEditableTarget } from "./SidebarContextMenu";
38
+
39
+ (
40
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
41
+ ).IS_REACT_ACT_ENVIRONMENT = true;
42
+
43
+ let root: Root;
44
+ let host: HTMLElement;
45
+ beforeEach(() => {
46
+ mutate.mockClear();
47
+ host = document.createElement("div");
48
+ document.body.append(host);
49
+ root = createRoot(host);
50
+ act(() =>
51
+ root.render(createElement(SidebarContextMenu, { workspaceId: "w1" }, "x"))
52
+ );
53
+ });
54
+ afterEach(() => {
55
+ act(() => root.unmount());
56
+ host.remove();
57
+ document.body.innerHTML = "";
58
+ });
59
+
60
+ const openDialog = () =>
61
+ act(() => {
62
+ host.querySelector("button")!.click();
63
+ });
64
+ const type = (value: string) => {
65
+ const input = document.body.querySelector("input")!;
66
+ act(() => {
67
+ Object.getOwnPropertyDescriptor(
68
+ HTMLInputElement.prototype,
69
+ "value"
70
+ )!.set!.call(input, value);
71
+ input.dispatchEvent(new window.Event("input", { bubbles: true }));
72
+ });
73
+ };
74
+ const submit = () =>
75
+ act(() => {
76
+ document.body
77
+ .querySelector("form")!
78
+ .dispatchEvent(
79
+ new window.Event("submit", { bubbles: true, cancelable: true })
80
+ );
81
+ });
82
+
83
+ describe("SidebarContextMenu", () => {
84
+ it("creates a group in the workspace from the typed name", () => {
85
+ openDialog();
86
+ type(" Docs ");
87
+ submit();
88
+ expect(mutate).toHaveBeenCalledWith({ name: "Docs", workspaceId: "w1" });
89
+ });
90
+
91
+ it("does nothing for an empty name", () => {
92
+ openDialog();
93
+ type(" ");
94
+ submit();
95
+ expect(mutate).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("leaves text fields to the browser's menu", () => {
99
+ const input = document.createElement("input");
100
+ const div = document.createElement("div");
101
+ expect(isEditableTarget(input)).toBe(true);
102
+ expect(isEditableTarget(div)).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useState, type MouseEvent, type ReactNode } from "react";
4
+ import { FolderPlus } from "lucide-react";
5
+ import * as CM from "@/components/ui/context-menu";
6
+ import { WorkspaceNameDialog } from "@/components/Workspaces";
7
+ import { useCreateGroup } from "@/data/session-groups";
8
+
9
+ export function isEditableTarget(target: EventTarget | null): boolean {
10
+ return (
11
+ target instanceof HTMLElement &&
12
+ !!target.closest("input, textarea, select, [contenteditable='true']")
13
+ );
14
+ }
15
+
16
+ // Right-click anywhere in the session list (empty space or a group header)
17
+ // offers "New group". Rows keep their own menu, which wins because it has
18
+ // already handled the event; text fields keep the browser's.
19
+ export function SidebarContextMenu({
20
+ workspaceId,
21
+ children,
22
+ }: {
23
+ workspaceId: string | null;
24
+ children?: ReactNode;
25
+ }) {
26
+ const createGroup = useCreateGroup();
27
+ const [naming, setNaming] = useState(false);
28
+
29
+ return (
30
+ <>
31
+ <CM.ContextMenu>
32
+ <CM.ContextMenuTrigger
33
+ asChild
34
+ onContextMenuCapture={(e: MouseEvent) => {
35
+ if (isEditableTarget(e.target)) e.stopPropagation();
36
+ }}
37
+ >
38
+ {children}
39
+ </CM.ContextMenuTrigger>
40
+ <CM.ContextMenuContent className="w-44">
41
+ <CM.ContextMenuItem
42
+ className="min-h-11 md:min-h-8"
43
+ onSelect={() => setNaming(true)}
44
+ >
45
+ <FolderPlus className="mr-2 h-3.5 w-3.5" />
46
+ New group
47
+ </CM.ContextMenuItem>
48
+ </CM.ContextMenuContent>
49
+ </CM.ContextMenu>
50
+ <WorkspaceNameDialog
51
+ open={naming}
52
+ title="New group"
53
+ submitLabel="Create"
54
+ onSubmit={(name) => createGroup.mutate({ name, workspaceId })}
55
+ onClose={() => setNaming(false)}
56
+ />
57
+ </>
58
+ );
59
+ }
@@ -15,6 +15,7 @@ import {
15
15
  MoreHorizontal,
16
16
  Pin,
17
17
  PinOff,
18
+ Network,
18
19
  Plus,
19
20
  Server,
20
21
  Settings,
@@ -30,6 +31,7 @@ import {
30
31
  import { Button } from "@/components/ui/button";
31
32
  import type { Workspace } from "@/lib/db";
32
33
  import { archivedUiActions } from "@/stores/archivedUi";
34
+ import { claimsUiActions } from "@/stores/claimsUi";
33
35
  import { bookmarksUiActions } from "@/stores/bookmarksUi";
34
36
  import { inboxUiActions } from "@/stores/inboxUi";
35
37
  import { newDraft } from "@/stores/drafts";
@@ -134,6 +136,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
134
136
  }),
135
137
  separator(),
136
138
  menuItem("Machines", props.onManageHosts, { icon: Server }),
139
+ menuItem("Work across machines", () => claimsUiActions.open(), {
140
+ icon: Network,
141
+ }),
137
142
  menuItem("Settings", () => settingsUiActions.open(), {
138
143
  icon: Settings,
139
144
  }),
@@ -8,6 +8,7 @@ import {
8
8
  ChevronDown,
9
9
  CircleCheck,
10
10
  Clock,
11
+ Folder,
11
12
  FolderPlus,
12
13
  Gauge,
13
14
  LayoutGrid,
@@ -32,6 +33,7 @@ import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
32
33
  import { sidebarUiActions } from "@/stores/sidebarUi";
33
34
  import { schedulesUiActions } from "@/stores/schedulesUi";
34
35
  import { recipesUiActions } from "@/stores/recipesUi";
36
+ import { useCreateGroup } from "@/data/session-groups";
35
37
  import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
36
38
 
37
39
  const icon = "mr-2 h-3.5 w-3.5";
@@ -49,7 +51,8 @@ export function WorkspaceSwitcher({
49
51
  const update = useUpdateWorkspace();
50
52
  const remove = useDeleteWorkspace();
51
53
  const addExisting = useAddExistingProject();
52
- const [dialog, setDialog] = useState<"new" | "rename" | null>(null);
54
+ const createGroup = useCreateGroup();
55
+ const [dialog, setDialog] = useState<"new" | "rename" | "group" | null>(null);
53
56
  const name = current?.name ?? "All workspaces";
54
57
 
55
58
  return (
@@ -89,6 +92,12 @@ export function WorkspaceSwitcher({
89
92
  <Plus className={icon} />
90
93
  New workspace
91
94
  </DM.DropdownMenuItem>
95
+ {current && (
96
+ <DM.DropdownMenuItem onClick={() => setDialog("group")}>
97
+ <Folder className={icon} />
98
+ New group
99
+ </DM.DropdownMenuItem>
100
+ )}
92
101
  {!current && (
93
102
  <>
94
103
  <DM.DropdownMenuItem onClick={() => recipesUiActions.open(null)}>
@@ -175,14 +184,22 @@ export function WorkspaceSwitcher({
175
184
  </DM.DropdownMenuContent>
176
185
  </DM.DropdownMenu>
177
186
  <WorkspaceNameDialog
178
- open={dialog === "new" || dialog === "rename"}
179
- title={dialog === "rename" ? "Rename workspace" : "New workspace"}
187
+ open={dialog !== null}
188
+ title={
189
+ dialog === "rename"
190
+ ? "Rename workspace"
191
+ : dialog === "group"
192
+ ? "New group"
193
+ : "New workspace"
194
+ }
180
195
  initialName={dialog === "rename" ? current?.name : ""}
181
196
  submitLabel={dialog === "rename" ? "Rename" : "Create"}
182
197
  onSubmit={(name) =>
183
- dialog === "rename" && current
184
- ? update.mutate({ id: current.id, name })
185
- : create.mutate(name)
198
+ dialog === "group"
199
+ ? createGroup.mutate({ name, workspaceId: current?.id ?? null })
200
+ : dialog === "rename" && current
201
+ ? update.mutate({ id: current.id, name })
202
+ : create.mutate(name)
186
203
  }
187
204
  onClose={() => setDialog(null)}
188
205
  />
@@ -21,6 +21,7 @@ import { SidebarSearch } from "./SidebarSearch";
21
21
  import { ProjectFilter } from "./ProjectFilter";
22
22
  import { SelectionToolbar } from "./SelectionToolbar";
23
23
  import { SidebarBody } from "./SidebarBody";
24
+ import { SidebarContextMenu } from "./SidebarContextMenu";
24
25
  import { SidebarDialogs, type SidebarDialog } from "./SidebarDialogs";
25
26
  import type { SessionListProps } from "./SessionList.types";
26
27
 
@@ -193,20 +194,23 @@ export function SessionList({
193
194
  )}
194
195
 
195
196
  <ScrollArea className="w-full flex-1">
196
- <div className="max-w-full px-1.5 pb-6">
197
- <RowProvider value={rowContext}>
198
- <SidebarBody
199
- data={data}
200
- onNewProject={addProject}
201
- devServerHandlers={{
202
- onStop: mutations.handleStopDevServer,
203
- onRestart: mutations.handleRestartDevServer,
204
- onRemove: mutations.handleRemoveDevServer,
205
- onViewLogs: (serverId) => setDialog({ kind: "logs", serverId }),
206
- }}
207
- />
208
- </RowProvider>
209
- </div>
197
+ <SidebarContextMenu workspaceId={data.workspace?.id ?? null}>
198
+ <div className="min-h-[60vh] max-w-full px-1.5 pb-6">
199
+ <RowProvider value={rowContext}>
200
+ <SidebarBody
201
+ data={data}
202
+ onNewProject={addProject}
203
+ devServerHandlers={{
204
+ onStop: mutations.handleStopDevServer,
205
+ onRestart: mutations.handleRestartDevServer,
206
+ onRemove: mutations.handleRemoveDevServer,
207
+ onViewLogs: (serverId) =>
208
+ setDialog({ kind: "logs", serverId }),
209
+ }}
210
+ />
211
+ </RowProvider>
212
+ </div>
213
+ </SidebarContextMenu>
210
214
  </ScrollArea>
211
215
 
212
216
  <SidebarDialogs