@saadnvd1/agent-os 0.3.698 → 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 (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -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
@@ -3,9 +3,11 @@
3
3
  import { useCallback, useMemo } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import type { Session } from "@/lib/db";
6
+ import type { SessionGroup } from "@/lib/sidebar/shelves";
6
7
  import {
7
8
  buildShelves,
8
9
  inWorkspace,
10
+ orderGroups,
9
11
  projectsInWorkspace,
10
12
  } from "@/lib/sidebar/shelves";
11
13
  import type { TaskState } from "@/lib/tasks/state";
@@ -25,6 +27,7 @@ import type { SessionStatus } from "./SessionList.types";
25
27
 
26
28
  // One empty list for queries still loading, so memos below hold.
27
29
  const NONE: never[] = [];
30
+ const NONE_GROUPS: SessionGroup[] = [];
28
31
 
29
32
  // Everything the flat list needs: the current workspace's sessions, sorted
30
33
  // onto shelves by the remembered filters.
@@ -55,6 +58,7 @@ export function useSidebarData(
55
58
  () => sessionsQuery.data?.sessions ?? [],
56
59
  [sessionsQuery.data]
57
60
  );
61
+ const allGroups = sessionsQuery.data?.sessionGroups ?? NONE_GROUPS;
58
62
  // Keyed by its contents: the hosts query hands back a new object each time.
59
63
  const linkedKey = JSON.stringify(useLinkedHostNames());
60
64
  const linked = useMemo(
@@ -102,6 +106,10 @@ export function useSidebarData(
102
106
  ),
103
107
  projectId: project?.id ?? null,
104
108
  currentOrchestrators,
109
+ groups: orderGroups(
110
+ allGroups.filter((g) => !workspace || g.workspace_id === workspace.id),
111
+ workspaces.map((w) => w.id)
112
+ ),
105
113
  };
106
114
  // The rollups count the whole list; the filter only narrows what shows.
107
115
  const all = buildShelves(input);
@@ -120,6 +128,7 @@ export function useSidebarData(
120
128
  now,
121
129
  linked,
122
130
  sessions,
131
+ allGroups,
123
132
  projects,
124
133
  workspaces,
125
134
  tasks,
@@ -235,6 +244,7 @@ export function useSidebarData(
235
244
  return {
236
245
  ui,
237
246
  shelves,
247
+ groups: allGroups,
238
248
  queued,
239
249
  toStart,
240
250
  machines,
@@ -29,7 +29,7 @@ function Command() {
29
29
  );
30
30
  }
31
31
 
32
- function LinkForm() {
32
+ export function LinkForm() {
33
33
  const [code, setCode] = useState("");
34
34
  const link = useLinkConnect();
35
35
  const submit = () => {
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from "react";
2
+ import { AlertTriangle, Info } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const TONES = {
6
+ info: {
7
+ box: "bg-muted/60",
8
+ icon: <Info className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />,
9
+ },
10
+ warning: {
11
+ box: "bg-amber-500/10",
12
+ icon: (
13
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
14
+ ),
15
+ },
16
+ } as const;
17
+
18
+ /** A short message in a tinted box, with an optional action on the right. */
19
+ export function Notice({
20
+ tone = "info",
21
+ action,
22
+ className,
23
+ children,
24
+ }: {
25
+ tone?: keyof typeof TONES;
26
+ action?: ReactNode;
27
+ className?: string;
28
+ children: ReactNode;
29
+ }) {
30
+ return (
31
+ <div
32
+ role="status"
33
+ className={cn(
34
+ "flex flex-col gap-2 rounded-lg p-3 text-sm sm:flex-row sm:items-center",
35
+ TONES[tone].box,
36
+ className
37
+ )}
38
+ >
39
+ <div className="flex min-w-0 flex-1 gap-2">
40
+ {TONES[tone].icon}
41
+ <div className="min-w-0 flex-1">{children}</div>
42
+ </div>
43
+ {action}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,39 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ const TONES = {
5
+ amber: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
6
+ muted: "bg-muted text-muted-foreground",
7
+ } as const;
8
+
9
+ /** A small coloured pill for a status. A button when it has an onClick. */
10
+ export function StatusPill({
11
+ tone,
12
+ className,
13
+ onClick,
14
+ ...props
15
+ }: Omit<ComponentProps<"span">, "onClick"> & {
16
+ tone: keyof typeof TONES;
17
+ onClick?: () => void;
18
+ }) {
19
+ const classes = cn(
20
+ "inline-flex max-w-full min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold",
21
+ TONES[tone],
22
+ className
23
+ );
24
+ if (!onClick) return <span className={classes} {...props} />;
25
+ return (
26
+ <button
27
+ type="button"
28
+ onClick={(e) => {
29
+ e.stopPropagation();
30
+ onClick();
31
+ }}
32
+ className={cn(
33
+ classes,
34
+ "relative cursor-pointer before:absolute before:-inset-x-1 before:-inset-y-3 hover:brightness-110"
35
+ )}
36
+ {...(props as ComponentProps<"button">)}
37
+ />
38
+ );
39
+ }
@@ -0,0 +1,2 @@
1
+ export { claimKeys } from "./keys";
2
+ export { useClaims, useCheckStart } from "./queries";
@@ -0,0 +1,5 @@
1
+ export const claimKeys = {
2
+ all: ["claims"] as const,
3
+ list: (project?: string) =>
4
+ [...claimKeys.all, "list", project ?? ""] as const,
5
+ };