@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
@@ -3,13 +3,14 @@
3
3
  import { useState } from "react";
4
4
  import { ExternalLink, Link2, Loader2, RefreshCw } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { Notice } from "@/components/ui/notice";
6
7
  import { LinkForm } from "@/components/Welcome/ConnectBlock";
7
8
  import {
8
9
  useAccountMachines,
9
10
  useRefreshAccount,
10
11
  useStartLink,
11
12
  } from "@/data/peers";
12
- import { CONNECT_SITE } from "@/lib/connect/view";
13
+ import { UPGRADE_URL } from "@/lib/peer/reasons";
13
14
  import type { AccountMachine } from "@/lib/peer/machines";
14
15
  import { cn } from "@/lib/utils";
15
16
  import { WaitingLinks } from "./IncomingLinkSheet";
@@ -57,7 +58,12 @@ function MachineRow({
57
58
  Linked
58
59
  </span>
59
60
  ) : machine.linkState === "suspended" ? (
60
- <span className="text-muted-foreground text-xs">Suspended</span>
61
+ <span
62
+ className="text-xs text-amber-700 dark:text-amber-400"
63
+ title={machine.suspendedReason ?? undefined}
64
+ >
65
+ Suspended
66
+ </span>
61
67
  ) : (
62
68
  <Button
63
69
  variant="outline"
@@ -122,23 +128,31 @@ export function AccountMachines() {
122
128
  />
123
129
  </Button>
124
130
  </div>
131
+ {data.uncheckedSince && (
132
+ <Notice tone="warning">
133
+ Couldn&apos;t check your account since{" "}
134
+ {new Date(data.uncheckedSince).toLocaleString([], {
135
+ dateStyle: "medium",
136
+ timeStyle: "short",
137
+ })}
138
+ . Machines already linked keep working; new links wait until it&apos;s
139
+ back.
140
+ </Notice>
141
+ )}
125
142
  {data.entitlements && !entitled && (
126
- <div className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center">
127
- <p className="flex-1 text-sm">
128
- Link your machines with Pro: tasks move between them over any
129
- network, end-to-end encrypted.
130
- </p>
131
- <Button asChild variant="outline" className="min-h-11 sm:min-h-9">
132
- <a
133
- href={`${CONNECT_SITE}/settings/billing`}
134
- target="_blank"
135
- rel="noopener noreferrer"
136
- >
137
- Upgrade
138
- <ExternalLink className="h-3.5 w-3.5" />
139
- </a>
140
- </Button>
141
- </div>
143
+ <Notice
144
+ action={
145
+ <Button asChild variant="outline" className="min-h-11 sm:min-h-9">
146
+ <a href={UPGRADE_URL} target="_blank" rel="noopener noreferrer">
147
+ Upgrade
148
+ <ExternalLink className="h-3.5 w-3.5" />
149
+ </a>
150
+ </Button>
151
+ }
152
+ >
153
+ Link your machines with Pro: tasks move between them over any network,
154
+ end-to-end encrypted.
155
+ </Notice>
142
156
  )}
143
157
  {entitled && <WaitingLinks />}
144
158
  {data.error && (
@@ -1,17 +1,77 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { Button } from "@/components/ui/button";
4
- import { Monitor, Server, Trash2, PlugZap, Loader2, Link2 } from "lucide-react";
5
+ import {
6
+ Activity,
7
+ ExternalLink,
8
+ Monitor,
9
+ Server,
10
+ Trash2,
11
+ PlugZap,
12
+ Loader2,
13
+ Link2,
14
+ Unlink,
15
+ } from "lucide-react";
16
+ import { Notice } from "@/components/ui/notice";
5
17
  import type { Host } from "@/lib/db";
6
18
  import { useDeleteHost, useLinkHost, useTestHost } from "@/data/hosts";
19
+ import { useUnlinkPeer } from "@/data/peers";
20
+ import { NEEDS_PRO, UPGRADE_URL } from "@/lib/peer/reasons";
7
21
  import { cn } from "@/lib/utils";
22
+ import { PeerActivitySheet } from "./PeerActivitySheet";
23
+
24
+ function connectLine(host: Host): string {
25
+ if (host.peer_state === "active")
26
+ return "Linked through Connect · tasks run on its AgentOS";
27
+ if (host.peer_state === "suspended") return "Through Connect · suspended";
28
+ if (host.peer_state === "unlinked")
29
+ return `Through Connect · ${host.peer_note ?? "unlinked"}`;
30
+ return "Through Connect · not linked";
31
+ }
32
+
33
+ /** Why a Connect link is suspended, with the way out when there is one. */
34
+ function SuspendedNote({ reason }: { reason: string | null | undefined }) {
35
+ const plan = reason === NEEDS_PRO;
36
+ return (
37
+ <Notice
38
+ tone="warning"
39
+ className="mt-2"
40
+ action={
41
+ plan && (
42
+ <Button
43
+ asChild
44
+ variant="outline"
45
+ size="sm"
46
+ className="min-h-11 sm:min-h-8"
47
+ >
48
+ <a href={UPGRADE_URL} target="_blank" rel="noopener noreferrer">
49
+ Upgrade
50
+ <ExternalLink className="h-3.5 w-3.5" />
51
+ </a>
52
+ </Button>
53
+ )
54
+ }
55
+ >
56
+ {plan
57
+ ? "Linking machines needs Pro. Calls both ways are refused until you upgrade; it comes back without linking again."
58
+ : `Suspended: ${reason ?? "no reason given"}. Calls both ways are refused; it comes back on its own if the machine returns to your account.`}
59
+ </Notice>
60
+ );
61
+ }
8
62
 
9
63
  export function HostRow({ host }: { host: Host }) {
10
64
  const deleteHost = useDeleteHost();
11
65
  const testHost = useTestHost();
12
66
  const linkHost = useLinkHost();
67
+ const unlink = useUnlinkPeer();
68
+ const [activity, setActivity] = useState(false);
69
+ const [confirmUnlink, setConfirmUnlink] = useState(false);
13
70
  const isLocal = host.id === "local";
14
71
  const viaConnect = host.kind === "connect";
72
+ const pinned =
73
+ viaConnect &&
74
+ (host.peer_state === "active" || host.peer_state === "suspended");
15
75
  const result = testHost.data;
16
76
  const Icon = isLocal ? Monitor : Server;
17
77
 
@@ -25,9 +85,7 @@ export function HostRow({ host }: { host: Host }) {
25
85
  {isLocal
26
86
  ? "Where agent-os is running"
27
87
  : viaConnect
28
- ? host.linked
29
- ? "Linked through Connect · tasks run on its AgentOS"
30
- : "Through Connect · not linked"
88
+ ? connectLine(host)
31
89
  : host.linked
32
90
  ? `${host.ssh_target} · tasks run on its AgentOS`
33
91
  : host.ssh_target}
@@ -70,16 +128,46 @@ export function HostRow({ host }: { host: Host }) {
70
128
  </Button>
71
129
  </>
72
130
  )}
73
- <Button
74
- variant="ghost"
75
- size="icon-sm"
76
- className="h-11 w-11 sm:h-8 sm:w-8"
77
- onClick={() => deleteHost.mutate(host.id)}
78
- disabled={deleteHost.isPending}
79
- aria-label={viaConnect ? "Unlink and remove" : "Remove machine"}
80
- >
81
- <Trash2 className="h-4 w-4" />
82
- </Button>
131
+ {viaConnect && host.peer_state && (
132
+ <Button
133
+ variant="ghost"
134
+ size="icon-sm"
135
+ className="h-11 w-11 sm:h-8 sm:w-8"
136
+ onClick={() => setActivity(true)}
137
+ aria-label="Activity"
138
+ title="Every call to and from this machine"
139
+ >
140
+ <Activity className="h-4 w-4" />
141
+ </Button>
142
+ )}
143
+ {pinned ? (
144
+ <Button
145
+ variant="ghost"
146
+ size="icon-sm"
147
+ className="h-11 w-11 sm:h-8 sm:w-8"
148
+ onClick={() => setConfirmUnlink(true)}
149
+ disabled={unlink.isPending || confirmUnlink}
150
+ aria-label="Unlink"
151
+ title="Unlink: it can no longer call this machine, nor this one it"
152
+ >
153
+ {unlink.isPending ? (
154
+ <Loader2 className="h-4 w-4 animate-spin" />
155
+ ) : (
156
+ <Unlink className="h-4 w-4" />
157
+ )}
158
+ </Button>
159
+ ) : (
160
+ <Button
161
+ variant="ghost"
162
+ size="icon-sm"
163
+ className="h-11 w-11 sm:h-8 sm:w-8"
164
+ onClick={() => deleteHost.mutate(host.id)}
165
+ disabled={deleteHost.isPending}
166
+ aria-label="Remove machine"
167
+ >
168
+ <Trash2 className="h-4 w-4" />
169
+ </Button>
170
+ )}
83
171
  </>
84
172
  )}
85
173
  </div>
@@ -107,6 +195,59 @@ export function HostRow({ host }: { host: Host }) {
107
195
  {linkHost.error.message}
108
196
  </p>
109
197
  )}
198
+ {host.peer_state === "suspended" && (
199
+ <SuspendedNote reason={host.peer_note} />
200
+ )}
201
+ {confirmUnlink && (
202
+ <Notice
203
+ tone="warning"
204
+ className="mt-2"
205
+ action={
206
+ <div className="flex gap-2">
207
+ <Button
208
+ variant="ghost"
209
+ size="sm"
210
+ className="min-h-11 sm:min-h-8"
211
+ onClick={() => setConfirmUnlink(false)}
212
+ >
213
+ Cancel
214
+ </Button>
215
+ <Button
216
+ variant="destructive"
217
+ size="sm"
218
+ className="min-h-11 sm:min-h-8"
219
+ onClick={() => {
220
+ setConfirmUnlink(false);
221
+ unlink.mutate(host.id);
222
+ }}
223
+ >
224
+ Unlink
225
+ </Button>
226
+ </div>
227
+ }
228
+ >
229
+ Unlink {host.name}? It&apos;s unlinked on both machines, and linking
230
+ them again needs a fresh pairing with new codes.
231
+ </Notice>
232
+ )}
233
+ {unlink.data && !unlink.data.told && (
234
+ <p className="text-muted-foreground mt-1.5 text-xs">
235
+ Unlinked here. {host.name} couldn&apos;t be told now; its next call is
236
+ refused and it will show as unlinked.
237
+ </p>
238
+ )}
239
+ {unlink.error && (
240
+ <p className="text-destructive mt-1.5 text-xs">
241
+ {unlink.error.message}
242
+ </p>
243
+ )}
244
+ {viaConnect && (
245
+ <PeerActivitySheet
246
+ hostId={activity ? host.id : null}
247
+ name={host.name}
248
+ onClose={() => setActivity(false)}
249
+ />
250
+ )}
110
251
  {deleteHost.error && (
111
252
  <p className="text-destructive mt-1.5 text-xs">
112
253
  {deleteHost.error.message}
@@ -28,6 +28,9 @@ function Origin({ request }: { request: PendingView }) {
28
28
  );
29
29
  }
30
30
 
31
+ const asking = (r: PendingView) =>
32
+ r.state === "accepted" ? "is linked: finish there" : "wants to link";
33
+
31
34
  /** On the machine being asked: type the code the other one shows. */
32
35
  export function IncomingLinkSheet({
33
36
  request,
@@ -46,7 +49,12 @@ export function IncomingLinkSheet({
46
49
  onClose();
47
50
  };
48
51
  const left = (accept.error as { attemptsLeft?: number } | null)?.attemptsLeft;
49
- const done = accept.data;
52
+ // Held by the server too, so a reload on this machine still shows it.
53
+ const done =
54
+ accept.data ??
55
+ (request?.confirmCode
56
+ ? { name: request.name, confirmCode: request.confirmCode }
57
+ : null);
50
58
 
51
59
  if (done)
52
60
  return (
@@ -154,8 +162,9 @@ export function IncomingLinks() {
154
162
  const [open, setOpen] = useState<PendingView | null>(null);
155
163
  // Hidden until a request not seen yet arrives; it covers the header on phones.
156
164
  const [later, setLater] = useState<string | null>(null);
157
- const first = requests[0];
158
- const hidden = later !== null && requests.every((r) => later.includes(r.id));
165
+ const shown = requests.filter((r) => !later?.includes(r.id));
166
+ const first = shown[0];
167
+ const hidden = shown.length === 0;
159
168
  return (
160
169
  <>
161
170
  {first && !open && !hidden && (
@@ -166,8 +175,8 @@ export function IncomingLinks() {
166
175
  <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
167
176
  <div className="flex min-w-0 flex-1 flex-col">
168
177
  <p className="truncate text-sm">
169
- <span className="font-medium">{first.name}</span> wants to link
170
- {requests.length > 1 ? ` (+${requests.length - 1} more)` : ""}
178
+ <span className="font-medium">{first.name}</span> {asking(first)}
179
+ {shown.length > 1 ? ` (+${shown.length - 1} more)` : ""}
171
180
  </p>
172
181
  <Origin request={first} />
173
182
  </div>
@@ -176,20 +185,29 @@ export function IncomingLinks() {
176
185
  className="min-h-11 sm:min-h-8"
177
186
  onClick={() => setOpen(first)}
178
187
  >
179
- Review
188
+ {first.state === "accepted" ? "Show code" : "Review"}
180
189
  </Button>
181
190
  <Button
182
191
  size="icon"
183
192
  variant="ghost"
184
193
  aria-label="Later"
185
194
  className="h-11 w-11 shrink-0 sm:h-8 sm:w-8"
186
- onClick={() => setLater(requests.map((r) => r.id).join(" "))}
195
+ onClick={() =>
196
+ setLater(`${later ?? ""} ${shown.map((r) => r.id).join(" ")}`)
197
+ }
187
198
  >
188
199
  <X className="h-4 w-4" />
189
200
  </Button>
190
201
  </div>
191
202
  )}
192
- <IncomingLinkSheet request={open} onClose={() => setOpen(null)} />
203
+ <IncomingLinkSheet
204
+ request={open}
205
+ onClose={() => {
206
+ // Answered, or put off: either way it stops covering the page.
207
+ if (open) setLater(`${later ?? ""} ${open.id}`);
208
+ setOpen(null);
209
+ }}
210
+ />
193
211
  </>
194
212
  );
195
213
  }
@@ -209,7 +227,7 @@ export function WaitingLinks() {
209
227
  <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
210
228
  <div className="flex min-w-0 flex-1 flex-col">
211
229
  <p className="truncate text-sm">
212
- <span className="font-medium">{r.name}</span> wants to link
230
+ <span className="font-medium">{r.name}</span> {asking(r)}
213
231
  </p>
214
232
  <Origin request={r} />
215
233
  </div>
@@ -218,7 +236,7 @@ export function WaitingLinks() {
218
236
  className="min-h-11 sm:min-h-8"
219
237
  onClick={() => setOpen(r)}
220
238
  >
221
- Type code
239
+ {r.state === "accepted" ? "Show code" : "Type code"}
222
240
  </Button>
223
241
  </div>
224
242
  ))}
@@ -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,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
+ }