@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
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CheckCircle2, Loader2, XCircle } from "lucide-react";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Input } from "@/components/ui/input";
14
+ import { useCancelLink, useConfirmLink, useOutgoingLink } from "@/data/peers";
15
+ import { showCode } from "@/lib/peer/code";
16
+ import { SHEET } from "./sheet";
17
+
18
+ const ENDED: Record<string, string> = {
19
+ declined: "It was declined there, or the code was typed wrong 3 times.",
20
+ expired: "The request expired before the code was typed.",
21
+ };
22
+
23
+ /** Once the other machine took our code: type the one it shows now. */
24
+ function ConfirmForm({
25
+ id,
26
+ name,
27
+ onCancel,
28
+ }: {
29
+ id: string;
30
+ name: string;
31
+ onCancel: () => void;
32
+ }) {
33
+ const [code, setCode] = useState("");
34
+ const confirm = useConfirmLink();
35
+ const digits = code.replace(/\D/g, "");
36
+ const left = (confirm.error as { attemptsLeft?: number } | null)
37
+ ?.attemptsLeft;
38
+ return (
39
+ <form
40
+ className="flex flex-col gap-2"
41
+ onSubmit={(e) => {
42
+ e.preventDefault();
43
+ if (digits.length === 6) confirm.mutate({ id, code: digits });
44
+ }}
45
+ >
46
+ <Input
47
+ value={code}
48
+ onChange={(e) => setCode(e.target.value.slice(0, 7))}
49
+ inputMode="numeric"
50
+ autoComplete="one-time-code"
51
+ placeholder="6-digit code"
52
+ aria-label={`Code shown on ${name}`}
53
+ className="h-12 text-center font-mono text-2xl tracking-[0.3em]"
54
+ autoFocus
55
+ />
56
+ {confirm.error && (
57
+ <p className="text-destructive text-sm">
58
+ {confirm.error.message}
59
+ {left ? ` ${left} ${left === 1 ? "try" : "tries"} left.` : ""}
60
+ </p>
61
+ )}
62
+ <div className="flex justify-end gap-2">
63
+ <Button
64
+ type="button"
65
+ variant="ghost"
66
+ className="min-h-11 sm:min-h-9"
67
+ onClick={onCancel}
68
+ >
69
+ Cancel
70
+ </Button>
71
+ <Button
72
+ type="submit"
73
+ className="min-h-11 sm:min-h-9"
74
+ disabled={confirm.isPending || digits.length !== 6 || left === 0}
75
+ >
76
+ Finish
77
+ </Button>
78
+ </div>
79
+ </form>
80
+ );
81
+ }
82
+
83
+ /** On the machine that asks: the code to type on the other one. */
84
+ export function LinkCodeSheet({
85
+ linkId,
86
+ onClose,
87
+ onRetry,
88
+ }: {
89
+ linkId: string | null;
90
+ onClose: () => void;
91
+ onRetry: () => void;
92
+ }) {
93
+ const { data: link, error } = useOutgoingLink(linkId);
94
+ const cancel = useCancelLink();
95
+ const close = () => {
96
+ if ((link?.state === "pending" || link?.state === "confirm") && linkId)
97
+ cancel.mutate(linkId);
98
+ onClose();
99
+ };
100
+ const name = link?.name ?? "the other machine";
101
+
102
+ return (
103
+ <Dialog open={!!linkId} onOpenChange={(o) => !o && close()}>
104
+ <DialogContent className={SHEET}>
105
+ <DialogHeader>
106
+ <DialogTitle>Link {name}</DialogTitle>
107
+ <DialogDescription>
108
+ {link?.state === "confirm" ? (
109
+ <>
110
+ Last step: {name} shows a code of its own. Typing it here proves
111
+ it&apos;s really {name}.
112
+ </>
113
+ ) : link?.state === "accepted" ? (
114
+ <>Both machines list each other as linked now.</>
115
+ ) : (
116
+ <>
117
+ Open AgentOS on {name} and type this code there. Its banner, the
118
+ Machines screen, or{" "}
119
+ <code className="font-mono">agent-os link accept</code> in its
120
+ terminal all take it.
121
+ </>
122
+ )}
123
+ </DialogDescription>
124
+ </DialogHeader>
125
+ {link?.state === "confirm" && (
126
+ <ConfirmForm id={link.id} name={name} onCancel={close} />
127
+ )}
128
+ {link && link.state !== "confirm" && link.state !== "accepted" && (
129
+ <p
130
+ className="text-center font-mono text-4xl font-semibold tracking-[0.2em] tabular-nums sm:text-5xl"
131
+ aria-label={`Code ${link.code.split("").join(" ")}`}
132
+ data-testid="link-code"
133
+ >
134
+ {showCode(link.code)}
135
+ </p>
136
+ )}
137
+ {link?.state !== "confirm" && (
138
+ <>
139
+ <div className="flex min-h-11 items-center justify-center gap-2 text-sm">
140
+ {error ? (
141
+ <span className="text-destructive">{error.message}</span>
142
+ ) : !link || link.state === "pending" ? (
143
+ <>
144
+ <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
145
+ <span className="text-muted-foreground">
146
+ Waiting for the code on {name}…
147
+ </span>
148
+ </>
149
+ ) : link.state === "accepted" ? (
150
+ <>
151
+ <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
152
+ <span>Linked. Tasks can run on {name} now.</span>
153
+ </>
154
+ ) : (
155
+ <>
156
+ <XCircle className="text-destructive h-4 w-4" />
157
+ <span className="text-muted-foreground">
158
+ {ENDED[link.state] ?? link.error}
159
+ </span>
160
+ </>
161
+ )}
162
+ </div>
163
+ <div className="flex justify-end gap-2">
164
+ {link && !["pending", "accepted"].includes(link.state) && (
165
+ <Button
166
+ variant="outline"
167
+ className="min-h-11 sm:min-h-9"
168
+ onClick={onRetry}
169
+ >
170
+ Try again
171
+ </Button>
172
+ )}
173
+ <Button
174
+ variant={link?.state === "accepted" ? "default" : "ghost"}
175
+ className="min-h-11 sm:min-h-9"
176
+ onClick={close}
177
+ >
178
+ {link?.state === "pending" ? "Cancel" : "Done"}
179
+ </Button>
180
+ </div>
181
+ </>
182
+ )}
183
+ </DialogContent>
184
+ </Dialog>
185
+ );
186
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { usePeerAudit } from "@/data/peers";
12
+ import type { AuditRow, Outcome } from "@/lib/peer/audit";
13
+ import { relativeTime } from "@/lib/schedules/when";
14
+ import { cn } from "@/lib/utils";
15
+ import { SHEET } from "./sheet";
16
+
17
+ const REFUSED: Record<Exclude<Outcome, "ok">, string> = {
18
+ denied_route: "route not open to it",
19
+ bad_envelope: "malformed",
20
+ too_large: "too large",
21
+ too_slow: "too slow",
22
+ busy: "busy",
23
+ bad_sig: "bad signature",
24
+ stale: "clocks differ",
25
+ replay: "replayed",
26
+ unknown_peer: "not linked",
27
+ suspended: "suspended",
28
+ };
29
+
30
+ function Row({ row }: { row: AuditRow }) {
31
+ const Arrow = row.direction === "in" ? ArrowDownLeft : ArrowUpRight;
32
+ const refused = row.outcome !== "ok";
33
+ return (
34
+ <li className="flex items-start gap-2 py-2">
35
+ <Arrow
36
+ className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
37
+ aria-label={row.direction === "in" ? "Called here" : "Called there"}
38
+ />
39
+ <div className="min-w-0 flex-1">
40
+ <p className="truncate font-mono text-xs">
41
+ {row.method} {row.route}
42
+ </p>
43
+ <p className="text-muted-foreground text-xs">
44
+ {relativeTime(row.at)}
45
+ {row.count > 1 && ` · ${row.count} calls this hour`}
46
+ {` · ${Math.round(row.ms)} ms`}
47
+ </p>
48
+ </div>
49
+ <span
50
+ className={cn(
51
+ "shrink-0 rounded-md px-1.5 py-0.5 text-xs tabular-nums",
52
+ refused
53
+ ? "bg-destructive/10 text-destructive"
54
+ : row.status >= 400
55
+ ? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
56
+ : "bg-muted text-muted-foreground"
57
+ )}
58
+ >
59
+ {refused ? REFUSED[row.outcome as Exclude<Outcome, "ok">] : row.status}
60
+ </span>
61
+ </li>
62
+ );
63
+ }
64
+
65
+ /** Machines → a linked machine → Activity: every call, both ways. */
66
+ export function PeerActivitySheet({
67
+ hostId,
68
+ name,
69
+ onClose,
70
+ }: {
71
+ hostId: string | null;
72
+ name: string;
73
+ onClose: () => void;
74
+ }) {
75
+ const { data: rows, isPending, error } = usePeerAudit(hostId);
76
+ return (
77
+ <Dialog open={!!hostId} onOpenChange={(o) => !o && onClose()}>
78
+ <DialogContent className={cn(SHEET, "max-h-[85vh] [&>*]:min-w-0")}>
79
+ <DialogHeader>
80
+ <DialogTitle>Activity with {name}</DialogTitle>
81
+ <DialogDescription>
82
+ Calls between this machine and {name} over the last 30 days. Reads
83
+ repeated within an hour are counted on one line.
84
+ </DialogDescription>
85
+ </DialogHeader>
86
+ <div className="-mx-1 max-h-[60vh] overflow-y-auto px-1">
87
+ {isPending && hostId ? (
88
+ <div className="space-y-2" aria-label="Loading">
89
+ {[0, 1, 2].map((i) => (
90
+ <div
91
+ key={i}
92
+ className="bg-muted/40 h-10 animate-pulse rounded-md"
93
+ />
94
+ ))}
95
+ </div>
96
+ ) : error ? (
97
+ <p className="text-destructive text-sm">{error.message}</p>
98
+ ) : !rows?.length ? (
99
+ <p className="text-muted-foreground text-sm">No calls yet.</p>
100
+ ) : (
101
+ <ul className="space-y-0.5">
102
+ {rows.map((r, i) => (
103
+ <Row key={`${r.at}-${i}`} row={r} />
104
+ ))}
105
+ </ul>
106
+ )}
107
+ </div>
108
+ </DialogContent>
109
+ </Dialog>
110
+ );
111
+ }
@@ -0,0 +1,3 @@
1
+ /** A dialog that sits at the bottom of a phone's screen, centred above it. */
2
+ export const SHEET =
3
+ "max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none max-sm:pb-[max(1.5rem,env(safe-area-inset-bottom))]";
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, Folder, MoreHorizontal } from "lucide-react";
5
+ import type { GroupShelf } from "@/lib/sidebar/shelves";
6
+ import {
7
+ useDeleteGroup,
8
+ useReorderGroups,
9
+ useUpdateGroup,
10
+ } from "@/data/session-groups";
11
+ import { Input } from "@/components/ui/input";
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuSeparator,
17
+ DropdownMenuTrigger,
18
+ } from "@/components/ui/dropdown-menu";
19
+ import { cn } from "@/lib/utils";
20
+ import { SessionRow } from "./SessionRow";
21
+
22
+ function GroupHeader({
23
+ shelf,
24
+ siblings,
25
+ workspaceName,
26
+ }: {
27
+ shelf: GroupShelf;
28
+ siblings: GroupShelf[];
29
+ workspaceName?: string;
30
+ }) {
31
+ const { group, rows, rollup } = shelf;
32
+ const update = useUpdateGroup();
33
+ const reorder = useReorderGroups();
34
+ const remove = useDeleteGroup();
35
+ const [renaming, setRenaming] = useState(false);
36
+ const [name, setName] = useState(group.name);
37
+ const at = siblings.findIndex((s) => s.group.id === group.id);
38
+ const move = (by: -1 | 1) => {
39
+ const ids = siblings.map((s) => s.group.id);
40
+ ids.splice(at + by, 0, ids.splice(at, 1)[0]);
41
+ reorder.mutate({ workspaceId: group.workspace_id, ids });
42
+ };
43
+ const commit = () => {
44
+ const next = name.trim();
45
+ setRenaming(false);
46
+ if (next && next !== group.name)
47
+ update.mutate({ id: group.id, name: next });
48
+ else setName(group.name);
49
+ };
50
+
51
+ if (renaming)
52
+ return (
53
+ <div className="px-2.5 pt-3.5 pb-1.5">
54
+ <Input
55
+ autoFocus
56
+ value={name}
57
+ maxLength={60}
58
+ aria-label="Group name"
59
+ className="h-11 md:h-8"
60
+ onChange={(e) => setName(e.target.value)}
61
+ onBlur={commit}
62
+ onKeyDown={(e) => {
63
+ if (e.key === "Enter") commit();
64
+ if (e.key === "Escape") {
65
+ setName(group.name);
66
+ setRenaming(false);
67
+ }
68
+ }}
69
+ />
70
+ </div>
71
+ );
72
+
73
+ return (
74
+ <div className="text-muted-foreground/70 group/header flex items-center pr-1 pl-2.5">
75
+ <button
76
+ type="button"
77
+ onClick={() =>
78
+ update.mutate({ id: group.id, collapsed: !group.collapsed })
79
+ }
80
+ aria-expanded={!group.collapsed}
81
+ className="hover:text-foreground flex min-h-11 min-w-0 flex-1 items-center gap-1 pt-2 text-left text-[11px] font-medium tracking-[0.08em] uppercase md:min-h-8"
82
+ >
83
+ <Folder aria-hidden className="h-3 w-3 shrink-0" />
84
+ <span className="truncate">{group.name}</span>
85
+ <span className="shrink-0">· {rows.length}</span>
86
+ {rollup.needsYou > 0 && (
87
+ <span className="shrink-0 text-amber-600 normal-case dark:text-amber-400">
88
+ · {rollup.needsYou} needs you
89
+ </span>
90
+ )}
91
+ {workspaceName && (
92
+ <span className="bg-muted text-muted-foreground ml-1 truncate rounded px-1.5 py-0.5 text-[10px] normal-case">
93
+ {workspaceName}
94
+ </span>
95
+ )}
96
+ <ChevronDown
97
+ aria-hidden
98
+ className={cn(
99
+ "h-3 w-3 shrink-0 transition-transform",
100
+ group.collapsed && "-rotate-90"
101
+ )}
102
+ />
103
+ </button>
104
+ <DropdownMenu>
105
+ <DropdownMenuTrigger asChild>
106
+ <button
107
+ type="button"
108
+ aria-label={`${group.name} options`}
109
+ className="hover:bg-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-md md:h-8 md:w-8 md:opacity-0 md:group-hover/header:opacity-100 md:focus-visible:opacity-100 md:data-[state=open]:opacity-100 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11 [@media(hover:none)]:md:opacity-100"
110
+ >
111
+ <MoreHorizontal className="h-4 w-4" />
112
+ </button>
113
+ </DropdownMenuTrigger>
114
+ <DropdownMenuContent align="end" className="w-44">
115
+ <DropdownMenuItem
116
+ className="min-h-11 md:min-h-8"
117
+ onSelect={() => setRenaming(true)}
118
+ >
119
+ Rename
120
+ </DropdownMenuItem>
121
+ <DropdownMenuItem
122
+ className="min-h-11 md:min-h-8"
123
+ disabled={at <= 0}
124
+ onSelect={() => move(-1)}
125
+ >
126
+ Move up
127
+ </DropdownMenuItem>
128
+ <DropdownMenuItem
129
+ className="min-h-11 md:min-h-8"
130
+ disabled={at < 0 || at >= siblings.length - 1}
131
+ onSelect={() => move(1)}
132
+ >
133
+ Move down
134
+ </DropdownMenuItem>
135
+ <DropdownMenuItem
136
+ className="min-h-11 md:min-h-8"
137
+ onSelect={() =>
138
+ update.mutate({ id: group.id, collapsed: !group.collapsed })
139
+ }
140
+ >
141
+ {group.collapsed ? "Unfold" : "Fold"}
142
+ </DropdownMenuItem>
143
+ <DropdownMenuSeparator />
144
+ <DropdownMenuItem
145
+ variant="destructive"
146
+ className="min-h-11 md:min-h-8"
147
+ onSelect={() => remove.mutate({ id: group.id, name: group.name })}
148
+ >
149
+ Delete group
150
+ </DropdownMenuItem>
151
+ </DropdownMenuContent>
152
+ </DropdownMenu>
153
+ </div>
154
+ );
155
+ }
156
+
157
+ // The user's groups, in order, between Pinned and the status shelves. A
158
+ // folded group keeps its header and counts; an auto-settled member is muted.
159
+ export function GroupShelves({
160
+ groups,
161
+ workspaceNames,
162
+ }: {
163
+ groups: GroupShelf[];
164
+ // Set when more than one workspace's groups show, to tag each header.
165
+ workspaceNames?: Map<string, string>;
166
+ }) {
167
+ return groups.map((shelf) => {
168
+ const { group } = shelf;
169
+ const siblings = groups.filter(
170
+ (g) => g.group.workspace_id === group.workspace_id
171
+ );
172
+ const empty = !group.collapsed && shelf.rows.length === 0;
173
+ return (
174
+ <section key={group.id} aria-label={group.name}>
175
+ <GroupHeader
176
+ shelf={shelf}
177
+ siblings={siblings}
178
+ workspaceName={
179
+ workspaceNames && group.workspace_id
180
+ ? workspaceNames.get(group.workspace_id)
181
+ : undefined
182
+ }
183
+ />
184
+ {empty && (
185
+ <p className="text-muted-foreground/70 px-2.5 pb-2 text-xs">
186
+ Nothing here yet · move a session in from its ⋯ menu
187
+ </p>
188
+ )}
189
+ {(group.collapsed ? [] : shelf.rows).map((r) => (
190
+ <SessionRow key={r.session.id} row={r} muted={r.settled === "auto"} />
191
+ ))}
192
+ </section>
193
+ );
194
+ });
195
+ }
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Repeat, Layers, Pause } from "lucide-react";
3
+ import { Repeat, Layers, Pause, Hourglass } from "lucide-react";
4
4
  import { HostBadge } from "@/components/Hosts";
5
5
  import { rowGroup } from "@/lib/recipes/chip";
6
6
  import type { Session } from "@/lib/db";
@@ -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" />
@@ -77,6 +82,16 @@ export function RowSubtitle({
77
82
  </span>
78
83
  </span>
79
84
  )}
85
+ {(session.waiting_on || session.waited_by) && (
86
+ <span className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
87
+ <Hourglass aria-hidden className="h-3 w-3 shrink-0" />
88
+ <span className="truncate">
89
+ {session.waiting_on
90
+ ? `waiting on ${session.waiting_on}`
91
+ : `${session.waited_by} waiting on it`}
92
+ </span>
93
+ </span>
94
+ )}
80
95
  {group && (
81
96
  <span
82
97
  className="text-muted-foreground/70 flex min-w-0 shrink-0 items-center gap-1 truncate text-xs @[14rem]:shrink"
@@ -92,13 +107,14 @@ export function RowSubtitle({
92
107
  </span>
93
108
  </span>
94
109
  )}
95
- {(project || detail || usage || session.placed_note) && (
110
+ {(project || detail || usage || session.placed_note || groupName) && (
96
111
  <span className="text-muted-foreground/70 min-w-0 truncate text-xs">
97
112
  {[
98
113
  usage && usageLabel(usage),
99
114
  detail?.slice(0, 200),
100
115
  session.placed_note,
101
116
  project,
117
+ groupName,
102
118
  ]
103
119
  .filter(Boolean)
104
120
  .join(" · ")}