@saadnvd1/agent-os 0.3.700 → 0.3.715

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/spawn/route.ts +12 -1
  4. package/app/api/claims/[id]/route.ts +20 -0
  5. package/app/api/claims/check/route.ts +23 -0
  6. package/app/api/claims/check.test.ts +113 -0
  7. package/app/api/claims/exchange/route.ts +42 -0
  8. package/app/api/claims/outside.test.ts +90 -0
  9. package/app/api/claims/route.test.ts +193 -0
  10. package/app/api/claims/route.ts +73 -0
  11. package/app/api/hosts/[id]/route.ts +9 -1
  12. package/app/api/peer/unlink/route.ts +17 -0
  13. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  14. package/app/api/peers/[hostId]/route.ts +20 -0
  15. package/app/api/peers/gate.test.ts +64 -1
  16. package/app/api/peers/pending/route.test.ts +37 -0
  17. package/app/api/peers/pending/route.ts +11 -4
  18. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  19. package/app/api/session-groups/[id]/route.ts +25 -0
  20. package/app/api/session-groups/order/route.ts +15 -0
  21. package/app/api/session-groups/route.ts +21 -0
  22. package/app/api/sessions/[id]/fork/route.ts +3 -0
  23. package/app/api/sessions/[id]/group/route.ts +18 -0
  24. package/app/api/sessions/[id]/route.ts +14 -4
  25. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  26. package/app/api/sessions/claims.test.ts +122 -0
  27. package/app/api/sessions/group/route.ts +17 -0
  28. package/app/api/sessions/route.ts +15 -14
  29. package/app/page.tsx +5 -0
  30. package/components/Claims/ClaimFiles.tsx +38 -0
  31. package/components/Claims/ClaimRow.tsx +82 -0
  32. package/components/Claims/ClaimsSheet.tsx +176 -0
  33. package/components/Claims/OverlapPill.tsx +31 -0
  34. package/components/Claims/StartClaimsNotice.tsx +70 -0
  35. package/components/Claims/claims-ui.test.ts +161 -0
  36. package/components/Claims/format.ts +57 -0
  37. package/components/Claims/index.ts +3 -0
  38. package/components/Draft/DraftPanel.tsx +40 -2
  39. package/components/Hosts/AccountMachines.tsx +32 -18
  40. package/components/Hosts/HostRow.tsx +155 -14
  41. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  42. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  43. package/components/SessionList/GroupShelves.tsx +195 -0
  44. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  45. package/components/SessionList/RowSubtitle.tsx +7 -1
  46. package/components/SessionList/SelectionToolbar.tsx +52 -1
  47. package/components/SessionList/SessionRow.tsx +1 -0
  48. package/components/SessionList/SessionRowMenu.tsx +17 -0
  49. package/components/SessionList/SessionShelves.tsx +5 -0
  50. package/components/SessionList/SidebarBody.tsx +6 -0
  51. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  52. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  53. package/components/SessionList/SidebarHeader.tsx +5 -0
  54. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  55. package/components/SessionList/index.tsx +18 -14
  56. package/components/SessionList/useSidebarData.ts +10 -0
  57. package/components/ui/notice.tsx +46 -0
  58. package/components/ui/status-pill.tsx +39 -0
  59. package/data/claims/index.ts +2 -0
  60. package/data/claims/keys.ts +5 -0
  61. package/data/claims/queries.ts +47 -0
  62. package/data/peers/keys.ts +1 -0
  63. package/data/peers/queries.ts +31 -0
  64. package/data/push/topics.ts +4 -0
  65. package/data/session-groups/index.ts +156 -0
  66. package/data/sessions/index.ts +0 -1
  67. package/data/sessions/pin.ts +2 -6
  68. package/data/sessions/queries.ts +10 -29
  69. package/data/sessions/shelf.ts +2 -6
  70. package/data/tasks/index.ts +2 -0
  71. package/hooks/useSessions.ts +0 -12
  72. package/lib/__fixtures__/peer-rpc.ts +65 -33
  73. package/lib/agents/spawn.ts +4 -0
  74. package/lib/bus/index.ts +8 -0
  75. package/lib/claims/collectLocal.ts +121 -0
  76. package/lib/claims/describe.ts +41 -0
  77. package/lib/claims/exchange.ts +91 -0
  78. package/lib/claims/goal.ts +45 -0
  79. package/lib/claims/index.ts +10 -0
  80. package/lib/claims/loop.ts +51 -0
  81. package/lib/claims/machine.ts +21 -0
  82. package/lib/claims/mine.ts +43 -0
  83. package/lib/claims/notifyOverlaps.ts +101 -0
  84. package/lib/claims/onMerged.ts +85 -0
  85. package/lib/claims/outside.ts +36 -0
  86. package/lib/claims/overlap.ts +87 -0
  87. package/lib/claims/overlapCache.ts +86 -0
  88. package/lib/claims/overlapText.ts +36 -0
  89. package/lib/claims/peers.ts +56 -0
  90. package/lib/claims/startCheck.ts +100 -0
  91. package/lib/claims/startWarning.ts +29 -0
  92. package/lib/claims/store.ts +366 -0
  93. package/lib/claims/types.ts +102 -0
  94. package/lib/claims/view.ts +80 -0
  95. package/lib/claims/yield.ts +24 -0
  96. package/lib/db/changes.ts +6 -0
  97. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  98. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  99. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  100. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  101. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  102. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  103. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  104. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  105. package/lib/db/queries.ts +0 -40
  106. package/lib/db/session-parts/columns.ts +1 -0
  107. package/lib/db/types.ts +16 -8
  108. package/lib/doctor/checks.ts +64 -1
  109. package/lib/git-remote.ts +43 -0
  110. package/lib/hosts/index.ts +10 -1
  111. package/lib/orchestration.ts +2 -0
  112. package/lib/orchestrator/act.ts +54 -4
  113. package/lib/orchestrator/brief.ts +2 -1
  114. package/lib/orchestrator/claim-wait.ts +71 -0
  115. package/lib/orchestrator/conditions.ts +28 -1
  116. package/lib/orchestrator/describe.ts +6 -1
  117. package/lib/orchestrator/facts.ts +5 -0
  118. package/lib/orchestrator/serve.ts +37 -2
  119. package/lib/orchestrator/tool-names.ts +1 -0
  120. package/lib/orchestrator/tool-schemas.ts +31 -1
  121. package/lib/orchestrator/tools.ts +3 -1
  122. package/lib/orchestrator/watcher.ts +19 -1
  123. package/lib/peer/account.ts +74 -2
  124. package/lib/peer/allowlist.ts +1 -1
  125. package/lib/peer/client.ts +26 -1
  126. package/lib/peer/link-cli.ts +19 -4
  127. package/lib/peer/links.ts +117 -5
  128. package/lib/peer/machines.ts +14 -2
  129. package/lib/peer/name.ts +32 -0
  130. package/lib/peer/pairing.ts +34 -5
  131. package/lib/peer/pin.ts +10 -3
  132. package/lib/peer/reasons.ts +7 -0
  133. package/lib/peer/revoke.ts +75 -0
  134. package/lib/peer/rpc-server.ts +53 -5
  135. package/lib/peer/unlink.ts +62 -0
  136. package/lib/peer/unlinked-proof.ts +44 -0
  137. package/lib/projects.ts +5 -2
  138. package/lib/security/demo.ts +7 -0
  139. package/lib/session-groups/assignSessions.ts +81 -0
  140. package/lib/session-groups/cli.ts +61 -0
  141. package/lib/session-groups/createGroup.ts +28 -0
  142. package/lib/session-groups/index.ts +21 -0
  143. package/lib/session-groups/removeGroup.ts +36 -0
  144. package/lib/session-groups/reorderGroups.ts +31 -0
  145. package/lib/session-groups/resolveGroup.ts +19 -0
  146. package/lib/session-groups/route.ts +32 -0
  147. package/lib/session-groups/store.ts +85 -0
  148. package/lib/session-groups/types.ts +25 -0
  149. package/lib/session-groups/updateGroup.ts +35 -0
  150. package/lib/session-groups/workspace.ts +21 -0
  151. package/lib/sessions/launch.ts +5 -2
  152. package/lib/sidebar/rollup.ts +1 -0
  153. package/lib/sidebar/row-menu.ts +2 -0
  154. package/lib/sidebar/shelves.ts +90 -15
  155. package/lib/sidebar/visible-order.ts +5 -0
  156. package/lib/tasks/hold-checks.ts +3 -1
  157. package/lib/tasks/holds.ts +3 -0
  158. package/lib/tasks/index.ts +5 -0
  159. package/lib/tasks/queue-rows.ts +8 -1
  160. package/lib/tasks/queue-start.ts +1 -0
  161. package/lib/tasks/queue-types.ts +9 -1
  162. package/lib/tasks/queue.ts +15 -5
  163. package/lib/tasks/schedule-types.ts +1 -0
  164. package/lib/test-temp.ts +19 -3
  165. package/lib/workspaces.ts +11 -3
  166. package/lib/worktree-sweep/testTemp.ts +23 -106
  167. package/package.json +1 -1
  168. package/scripts/agent-os +1 -1
  169. package/scripts/autodeploy +2 -0
  170. package/scripts/autodeploy.test.ts +31 -1
  171. package/scripts/claim-hook.sh +31 -0
  172. package/scripts/claim-hook.test.ts +230 -0
  173. package/scripts/lib/cli-links.sh +36 -0
  174. package/scripts/lib/commands.sh +3 -0
  175. package/scripts/lib/machine.sh +27 -2
  176. package/scripts/lib/machine.test.ts +25 -3
  177. package/scripts/sync-cli-links +7 -0
  178. package/scripts/sync-cli-links.test.ts +69 -0
  179. package/server.ts +3 -0
  180. package/stores/claimsUi.ts +22 -0
  181. package/app/api/groups/[...path]/route.ts +0 -136
  182. package/app/api/groups/route.ts +0 -93
  183. package/data/groups/index.ts +0 -1
  184. package/data/groups/mutations.ts +0 -95
  185. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronRight, ExternalLink, GitBranch } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import type { Claim } from "@/lib/claims/types";
7
+ import { ClaimFiles } from "./ClaimFiles";
8
+ import { startedText } from "./format";
9
+
10
+ // One piece of claimed work: what, where, since when; its files fold open.
11
+ export function ClaimRow({
12
+ claim,
13
+ here,
14
+ now,
15
+ shared,
16
+ }: {
17
+ claim: Claim;
18
+ here: boolean;
19
+ now: number;
20
+ shared: ReadonlySet<string>;
21
+ }) {
22
+ const [open, setOpen] = useState(false);
23
+ const count = claim.files.length + claim.filesMore;
24
+ return (
25
+ <li className="bg-muted/40 rounded-xl" data-claim={claim.id}>
26
+ <button
27
+ type="button"
28
+ onClick={() => setOpen((v) => !v)}
29
+ aria-expanded={open}
30
+ className="flex min-h-11 w-full items-start gap-2 rounded-xl p-3 text-left"
31
+ >
32
+ <ChevronRight
33
+ aria-hidden
34
+ className={cn(
35
+ "text-muted-foreground mt-0.5 h-4 w-4 shrink-0 transition-transform",
36
+ open && "rotate-90"
37
+ )}
38
+ />
39
+ <span className="min-w-0 flex-1">
40
+ <span className="block text-sm font-medium break-words">
41
+ {claim.goal}
42
+ </span>
43
+ <span className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
44
+ <span>
45
+ {here ? `${claim.originName} (here)` : claim.originName}
46
+ </span>
47
+ {claim.branch && (
48
+ <span className="flex min-w-0 items-center gap-1">
49
+ <GitBranch aria-hidden className="h-3 w-3 shrink-0" />
50
+ <span className="truncate">{claim.branch}</span>
51
+ </span>
52
+ )}
53
+ <span>{startedText(claim, now)}</span>
54
+ <span>
55
+ {count} file{count === 1 ? "" : "s"}
56
+ </span>
57
+ </span>
58
+ </span>
59
+ </button>
60
+ {claim.prUrl && (
61
+ <a
62
+ href={claim.prUrl}
63
+ target="_blank"
64
+ rel="noreferrer"
65
+ className="text-primary flex min-h-11 items-center gap-1 px-3 pb-2 pl-9 text-xs hover:underline"
66
+ >
67
+ <ExternalLink aria-hidden className="h-3 w-3" />
68
+ Pull request
69
+ </a>
70
+ )}
71
+ {open && (
72
+ <div className="px-3 pb-3 pl-9">
73
+ <ClaimFiles
74
+ files={claim.files}
75
+ more={claim.filesMore}
76
+ shared={shared}
77
+ />
78
+ </div>
79
+ )}
80
+ </li>
81
+ );
82
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { GitCompareArrows } from "lucide-react";
6
+ import { StatusPill } from "@/components/ui/status-pill";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import { useNow } from "@/components/Chat/useNow";
15
+ import { useClaims } from "@/data/claims";
16
+ import { claimsUi, claimsUiActions } from "@/stores/claimsUi";
17
+ import { cn } from "@/lib/utils";
18
+ import { ClaimRow } from "./ClaimRow";
19
+ import { QUIET_MS, focusView, groupByRepo, lastHeardText } from "./format";
20
+
21
+ const Skeleton = () => (
22
+ <div className="space-y-2" aria-label="Loading claims">
23
+ {[0, 1, 2].map((i) => (
24
+ <div key={i} className="bg-muted/40 h-16 animate-pulse rounded-xl" />
25
+ ))}
26
+ </div>
27
+ );
28
+
29
+ // Work across machines: what each linked machine's sessions are on, the
30
+ // overlaps first, and when each machine was last heard from.
31
+ export function ClaimsSheet() {
32
+ const snap = useSnapshot(claimsUi);
33
+ const { data, isPending, isError, dataUpdatedAt } = useClaims({
34
+ enabled: snap.open,
35
+ });
36
+ const tick = useNow(snap.open);
37
+ const now = tick + (data ? data.now - dataUpdatedAt : 0);
38
+ const view = useMemo(
39
+ () => (data ? focusView(data, snap.focus ? [...snap.focus] : null) : null),
40
+ [data, snap.focus]
41
+ );
42
+ const byId = useMemo(
43
+ () => new Map((view?.claims ?? []).map((c) => [c.id, c])),
44
+ [view]
45
+ );
46
+ const sharedOf = (id: string) =>
47
+ new Set(
48
+ (view?.overlaps ?? [])
49
+ .filter((o) => o.a === id || o.b === id)
50
+ .flatMap((o) => o.shared)
51
+ );
52
+ const here = data?.machine.id;
53
+
54
+ return (
55
+ <Dialog open={snap.open} onOpenChange={claimsUiActions.setOpen}>
56
+ <DialogContent className="max-h-[90vh] max-w-xl overflow-y-auto [&>*]:min-w-0">
57
+ <DialogHeader>
58
+ <DialogTitle>Work across machines</DialogTitle>
59
+ <DialogDescription className="sr-only">
60
+ What sessions on this and linked machines are working on, and where
61
+ the work overlaps.
62
+ </DialogDescription>
63
+ </DialogHeader>
64
+ {isPending && <Skeleton />}
65
+ {isError && (
66
+ <p className="text-destructive text-sm">Couldn’t load claims.</p>
67
+ )}
68
+ {data && view && (
69
+ <div className="space-y-4">
70
+ <ul className="flex flex-wrap gap-2" aria-label="Machines">
71
+ {data.machines.map((m) => {
72
+ const quiet =
73
+ !m.self &&
74
+ (m.lastHeardAt === null || now - m.lastHeardAt > QUIET_MS);
75
+ return (
76
+ <li
77
+ key={m.id}
78
+ className="bg-muted/40 flex items-center gap-2 rounded-lg px-3 py-2 text-xs"
79
+ >
80
+ <span className="font-medium">{m.name}</span>
81
+ <span
82
+ className={cn(
83
+ "text-muted-foreground",
84
+ quiet && "text-amber-700 dark:text-amber-400"
85
+ )}
86
+ >
87
+ {lastHeardText(m.lastHeardAt, now)}
88
+ </span>
89
+ </li>
90
+ );
91
+ })}
92
+ </ul>
93
+ {snap.focus && (
94
+ <button
95
+ type="button"
96
+ onClick={claimsUiActions.showAll}
97
+ className="text-primary min-h-11 text-sm"
98
+ >
99
+ Showing one pair. Show all work
100
+ </button>
101
+ )}
102
+ {view.overlaps.length > 0 && (
103
+ <section className="space-y-2" aria-label="Overlaps">
104
+ <h3 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
105
+ Overlaps
106
+ </h3>
107
+ <ul className="space-y-2">
108
+ {view.overlaps.map((o) => {
109
+ const a = byId.get(o.a);
110
+ const b = byId.get(o.b);
111
+ if (!a || !b) return null;
112
+ return (
113
+ <li
114
+ key={`${o.a}|${o.b}`}
115
+ className="bg-muted/40 flex items-start gap-2 rounded-xl p-3 text-sm"
116
+ >
117
+ <StatusPill
118
+ tone={o.strength === "strong" ? "amber" : "muted"}
119
+ >
120
+ <GitCompareArrows
121
+ aria-hidden
122
+ className="h-2.5 w-2.5"
123
+ />
124
+ {o.strength}
125
+ </StatusPill>
126
+ <span className="min-w-0 flex-1 break-words">
127
+ {a.goal} on {a.originName} and {b.goal} on{" "}
128
+ {b.originName}
129
+ <span className="text-muted-foreground block text-xs">
130
+ {[
131
+ o.kinds.join(" + "),
132
+ o.shared.length
133
+ ? `${o.shared.length} shared file${o.shared.length === 1 ? "" : "s"}`
134
+ : null,
135
+ ]
136
+ .filter(Boolean)
137
+ .join(" · ")}
138
+ </span>
139
+ </span>
140
+ </li>
141
+ );
142
+ })}
143
+ </ul>
144
+ </section>
145
+ )}
146
+ {view.claims.length === 0 ? (
147
+ <p className="text-muted-foreground py-6 text-center text-sm">
148
+ Nothing is claimed right now. Work shows up here while a session
149
+ has changes in a repository.
150
+ </p>
151
+ ) : (
152
+ groupByRepo(view.claims).map((g) => (
153
+ <section key={g.repo} className="space-y-2" aria-label={g.repo}>
154
+ <h3 className="text-muted-foreground truncate text-xs font-semibold">
155
+ {g.repo}
156
+ </h3>
157
+ <ul className="space-y-2">
158
+ {g.claims.map((c) => (
159
+ <ClaimRow
160
+ key={c.id}
161
+ claim={c}
162
+ here={c.origin === here}
163
+ now={now}
164
+ shared={sharedOf(c.id)}
165
+ />
166
+ ))}
167
+ </ul>
168
+ </section>
169
+ ))
170
+ )}
171
+ </div>
172
+ )}
173
+ </DialogContent>
174
+ </Dialog>
175
+ );
176
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { GitCompareArrows } from "lucide-react";
4
+ import { StatusPill } from "@/components/ui/status-pill";
5
+ import type { Session } from "@/lib/db";
6
+ import { claimsUiActions } from "@/stores/claimsUi";
7
+
8
+ // "Overlaps: box" on a session row: amber when the overlap is strong, muted
9
+ // when weak. Tapping opens the claims sheet on that pair.
10
+ export function OverlapPill({
11
+ overlap,
12
+ }: {
13
+ overlap: NonNullable<Session["overlap"]>;
14
+ }) {
15
+ const strong = overlap.strength === "strong";
16
+ const files = overlap.shared
17
+ ? ` · ${overlap.shared} shared file${overlap.shared === 1 ? "" : "s"}`
18
+ : "";
19
+ return (
20
+ <StatusPill
21
+ tone={strong ? "amber" : "muted"}
22
+ data-tip={`${strong ? "Strong" : "Weak"} overlap with "${overlap.goal}" on ${overlap.machine}${files}. Tap to see both.`}
23
+ aria-label={`Overlaps with ${overlap.machine}`}
24
+ data-overlap={overlap.strength}
25
+ onClick={() => claimsUiActions.open([overlap.mineId, overlap.claimId])}
26
+ >
27
+ <GitCompareArrows aria-hidden className="h-2.5 w-2.5 shrink-0" />
28
+ <span className="truncate">Overlaps: {overlap.machine}</span>
29
+ </StatusPill>
30
+ );
31
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Notice } from "@/components/ui/notice";
5
+ import type { StartWarning } from "@/lib/claims/startWarning";
6
+
7
+ // Above the composer when a person's task start overlaps claimed work. They
8
+ // choose: start anyway, queue it behind that work, or go back and edit.
9
+ export function StartClaimsNotice({
10
+ warnings,
11
+ busy,
12
+ onStart,
13
+ onDismiss,
14
+ }: {
15
+ warnings: StartWarning[];
16
+ busy: boolean;
17
+ onStart: (after?: string) => void;
18
+ onDismiss: () => void;
19
+ }) {
20
+ const top = warnings[0];
21
+ if (!top) return null;
22
+ const files = top.shared.slice(0, 3).join(", ");
23
+ return (
24
+ <Notice
25
+ tone="warning"
26
+ action={
27
+ <div className="flex flex-wrap gap-2">
28
+ <Button
29
+ size="sm"
30
+ variant="outline"
31
+ className="min-h-11 md:min-h-8"
32
+ disabled={busy}
33
+ onClick={() => onStart()}
34
+ >
35
+ Start anyway
36
+ </Button>
37
+ <Button
38
+ size="sm"
39
+ className="min-h-11 md:min-h-8"
40
+ disabled={busy}
41
+ onClick={() => onStart(`claim:${top.claim.id}`)}
42
+ >
43
+ Start after it
44
+ </Button>
45
+ <Button
46
+ size="sm"
47
+ variant="ghost"
48
+ className="min-h-11 md:min-h-8"
49
+ disabled={busy}
50
+ onClick={onDismiss}
51
+ >
52
+ Edit
53
+ </Button>
54
+ </div>
55
+ }
56
+ >
57
+ <p className="font-medium">
58
+ {top.strength === "strong"
59
+ ? "Someone is already on this"
60
+ : "This may overlap"}
61
+ {warnings.length > 1 ? ` (${warnings.length} claims)` : ""}
62
+ </p>
63
+ <p className="text-muted-foreground text-xs break-words">
64
+ “{top.claim.goal}” on {top.claim.machine}
65
+ {top.claim.branch ? ` (${top.claim.branch})` : ""}
66
+ {files ? ` touches ${files}` : ""}
67
+ </p>
68
+ </Notice>
69
+ );
70
+ }
@@ -0,0 +1,161 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { claimKeys } from "@/data/claims/keys";
5
+ import { claimsUi, claimsUiActions } from "@/stores/claimsUi";
6
+ import { ClaimFiles } from "./ClaimFiles";
7
+ import { OverlapPill } from "./OverlapPill";
8
+ import { StartClaimsNotice } from "./StartClaimsNotice";
9
+ import { focusView, groupByRepo, lastHeardText, startedText } from "./format";
10
+ import type { Claim } from "@/lib/claims/types";
11
+
12
+ const overlap = {
13
+ strength: "strong" as const,
14
+ machine: "box",
15
+ goal: "Rework login",
16
+ shared: 3,
17
+ claimId: "r-1",
18
+ mineId: "s-1",
19
+ };
20
+
21
+ beforeEach(() => claimsUiActions.setOpen(false));
22
+
23
+ describe("OverlapPill", () => {
24
+ it("reads Overlaps: <machine>, amber when strong", () => {
25
+ const html = renderToStaticMarkup(createElement(OverlapPill, { overlap }));
26
+ expect(html).toContain("Overlaps: box");
27
+ expect(html).toContain("text-amber-700");
28
+ expect(html).toContain('data-overlap="strong"');
29
+ });
30
+
31
+ it("is muted when weak", () => {
32
+ const html = renderToStaticMarkup(
33
+ createElement(OverlapPill, { overlap: { ...overlap, strength: "weak" } })
34
+ );
35
+ expect(html).toContain("text-muted-foreground");
36
+ expect(html).not.toContain("text-amber-700");
37
+ });
38
+
39
+ it("opens the claims sheet on that pair when tapped", () => {
40
+ const el = OverlapPill({ overlap }) as { props: { onClick: () => void } };
41
+ el.props.onClick();
42
+ expect(claimsUi.open).toBe(true);
43
+ expect([...(claimsUi.focus ?? [])]).toEqual(["s-1", "r-1"]);
44
+ claimsUiActions.showAll();
45
+ expect(claimsUi.focus).toBeNull();
46
+ });
47
+ });
48
+
49
+ describe("StartClaimsNotice", () => {
50
+ const warnings = [
51
+ {
52
+ strength: "strong" as const,
53
+ kinds: ["files" as const],
54
+ shared: ["a.ts", "b.ts"],
55
+ claim: {
56
+ id: "r-1",
57
+ goal: "Rework login",
58
+ machine: "box",
59
+ branch: "feature/login",
60
+ sessionName: null,
61
+ },
62
+ },
63
+ ];
64
+ const html = renderToStaticMarkup(
65
+ createElement(StartClaimsNotice, {
66
+ warnings,
67
+ busy: false,
68
+ onStart: () => {},
69
+ onDismiss: () => {},
70
+ })
71
+ );
72
+
73
+ it("names the work and offers both choices", () => {
74
+ expect(html).toContain("Rework login");
75
+ expect(html).toContain("on box");
76
+ expect(html).toContain("a.ts, b.ts");
77
+ expect(html).toContain("Start anyway");
78
+ expect(html).toContain("Start after it");
79
+ });
80
+
81
+ it("renders nothing without a warning", () => {
82
+ expect(
83
+ renderToStaticMarkup(
84
+ createElement(StartClaimsNotice, {
85
+ warnings: [],
86
+ busy: false,
87
+ onStart: () => {},
88
+ onDismiss: () => {},
89
+ })
90
+ )
91
+ ).toBe("");
92
+ });
93
+ });
94
+
95
+ describe("claims keys and helpers", () => {
96
+ it("keys the list by project", () => {
97
+ expect(claimKeys.list()).toEqual(["claims", "list", ""]);
98
+ expect(claimKeys.list("p1")).toEqual(["claims", "list", "p1"]);
99
+ expect(claimKeys.list("p1").slice(0, 1)).toEqual(claimKeys.all);
100
+ });
101
+
102
+ it("words ages and last heard", () => {
103
+ expect(startedText({ startedAt: 0 }, 12 * 60_000)).toBe("started 12m ago");
104
+ expect(startedText({ startedAt: 0 }, 5_000)).toBe("just started");
105
+ expect(lastHeardText(0, 6 * 60_000)).toBe("last heard 6m ago");
106
+ expect(lastHeardText(null, 0)).toBe("this machine");
107
+ });
108
+
109
+ const c = (id: string, repo: string, startedAt: number) =>
110
+ ({ id, repo, startedAt, files: [], filesMore: 0 }) as unknown as Claim;
111
+
112
+ it("groups by repo, oldest work first", () => {
113
+ const groups = groupByRepo([
114
+ c("a", "r2", 5),
115
+ c("b", "r1", 9),
116
+ c("c", "r2", 1),
117
+ ]);
118
+ expect(groups.map((g) => [g.repo, g.claims.map((x) => x.id)])).toEqual([
119
+ ["r2", ["c", "a"]],
120
+ ["r1", ["b"]],
121
+ ]);
122
+ });
123
+
124
+ it("narrows to one pair", () => {
125
+ const view = {
126
+ claims: [c("a", "r", 1), c("b", "r", 2), c("z", "r", 3)],
127
+ overlaps: [
128
+ {
129
+ a: "a",
130
+ b: "b",
131
+ kinds: ["files" as const],
132
+ shared: [],
133
+ strength: "weak" as const,
134
+ },
135
+ {
136
+ a: "b",
137
+ b: "z",
138
+ kinds: ["files" as const],
139
+ shared: [],
140
+ strength: "weak" as const,
141
+ },
142
+ ],
143
+ };
144
+ const f = focusView(view, ["a", "b"]);
145
+ expect(f.claims.map((x) => x.id)).toEqual(["a", "b"]);
146
+ expect(f.overlaps).toHaveLength(1);
147
+ expect(focusView(view, null)).toBe(view);
148
+ });
149
+
150
+ it("marks shared files in a claim's list", () => {
151
+ const html = renderToStaticMarkup(
152
+ createElement(ClaimFiles, {
153
+ files: ["a.ts", "b.ts"],
154
+ more: 2,
155
+ shared: new Set(["a.ts"]),
156
+ })
157
+ );
158
+ expect(html).toContain("and 2 more");
159
+ expect(html.match(/text-amber-700/g)).toHaveLength(1);
160
+ });
161
+ });
@@ -0,0 +1,57 @@
1
+ import type { Claim } from "@/lib/claims/types";
2
+ import type { ClaimsView } from "@/lib/claims/view";
3
+
4
+ const span = (ms: number) => {
5
+ const mins = Math.floor(Math.max(0, ms) / 60_000);
6
+ if (mins < 1) return "under a minute";
7
+ if (mins < 60) return `${mins}m`;
8
+ if (mins < 24 * 60) return `${Math.floor(mins / 60)}h`;
9
+ return `${Math.floor(mins / (24 * 60))}d`;
10
+ };
11
+
12
+ /** "started 12m ago". */
13
+ export const startedText = (c: Pick<Claim, "startedAt">, now: number) => {
14
+ const s = span(now - c.startedAt);
15
+ return s === "under a minute" ? "just started" : `started ${s} ago`;
16
+ };
17
+
18
+ /** "last heard 6m ago". */
19
+ export const lastHeardText = (at: number | null, now: number) => {
20
+ if (at === null) return "this machine";
21
+ const s = span(now - at);
22
+ return s === "under a minute" ? "heard just now" : `last heard ${s} ago`;
23
+ };
24
+
25
+ /** A machine not heard from for this long is shown as quiet. */
26
+ export const QUIET_MS = 3 * 60_000;
27
+
28
+ export interface RepoGroup {
29
+ repo: string;
30
+ claims: Claim[];
31
+ }
32
+
33
+ /** Claims by repository, the repository with the oldest work first. */
34
+ export function groupByRepo(claims: Claim[]): RepoGroup[] {
35
+ const byRepo = new Map<string, Claim[]>();
36
+ for (const c of claims)
37
+ byRepo.set(c.repo, [...(byRepo.get(c.repo) ?? []), c]);
38
+ return [...byRepo]
39
+ .map(([repo, list]) => ({
40
+ repo,
41
+ claims: [...list].sort((a, b) => a.startedAt - b.startedAt),
42
+ }))
43
+ .sort((a, b) => a.claims[0].startedAt - b.claims[0].startedAt);
44
+ }
45
+
46
+ /** The claims and overlaps of one pair, or everything when no pair is set. */
47
+ export function focusView(
48
+ view: Pick<ClaimsView, "claims" | "overlaps">,
49
+ focus: string[] | null
50
+ ) {
51
+ if (!focus) return view;
52
+ const ids = new Set(focus);
53
+ return {
54
+ claims: view.claims.filter((c) => ids.has(c.id)),
55
+ overlaps: view.overlaps.filter((o) => ids.has(o.a) && ids.has(o.b)),
56
+ };
57
+ }
@@ -0,0 +1,3 @@
1
+ export { ClaimsSheet } from "./ClaimsSheet";
2
+ export { OverlapPill } from "./OverlapPill";
3
+ export { StartClaimsNotice } from "./StartClaimsNotice";
@@ -15,6 +15,9 @@ import { useGitCheck } from "@/data/git/queries";
15
15
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
16
16
  import { useLaunchSession } from "@/data/sessions";
17
17
  import { useCreateTask } from "@/data/tasks";
18
+ import { useCheckStart } from "@/data/claims";
19
+ import { StartClaimsNotice } from "@/components/Claims";
20
+ import type { StartWarning } from "@/lib/claims/startWarning";
18
21
  import { usePanes } from "@/contexts/PaneContext";
19
22
  import { setPendingPrompt } from "@/stores/initialPrompt";
20
23
  import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
@@ -53,8 +56,15 @@ export function DraftPanel({
53
56
  const launch = useLaunchSession();
54
57
  const { workspace } = useSelectedWorkspace();
55
58
  const createTask = useCreateTask();
59
+ const checkStart = useCheckStart();
56
60
  const [prefill, setPrefill] = useState<{ text: string; at: number }>();
57
- const pending = launch.isPending || createTask.isPending;
61
+ // A task start that overlaps claimed work waits here for the person's choice.
62
+ const [held, setHeld] = useState<{
63
+ text: string;
64
+ warnings: StartWarning[];
65
+ } | null>(null);
66
+ const pending =
67
+ launch.isPending || createTask.isPending || checkStart.isPending;
58
68
 
59
69
  if (!hydrated) return null;
60
70
  if (!draft)
@@ -67,15 +77,32 @@ export function DraftPanel({
67
77
  </div>
68
78
  );
69
79
 
70
- const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
80
+ const send = async (
81
+ text: string,
82
+ images: ChatImage[],
83
+ files: ChatFile[],
84
+ claim?: { after?: string }
85
+ ) => {
71
86
  try {
72
87
  if (draft.openPr) {
73
88
  if (!project) throw new Error("Pick a project for a task");
74
89
  if (images.length || files.length)
75
90
  throw new Error("A task takes text only");
91
+ if (!claim && draft.hostId === project.host_id) {
92
+ const warnings = await checkStart.mutateAsync({
93
+ projectId: project.id,
94
+ prompt: text,
95
+ });
96
+ if (warnings.length) {
97
+ setHeld({ text, warnings });
98
+ return;
99
+ }
100
+ }
101
+ setHeld(null);
76
102
  const { session, queued } = await createTask.mutateAsync({
77
103
  projectId: project.id,
78
104
  prompt: text,
105
+ after: claim?.after,
79
106
  model: draft.taskModel ?? undefined,
80
107
  priority: draft.taskPriority,
81
108
  baseBranch: draft.baseBranch ?? undefined,
@@ -145,6 +172,17 @@ export function DraftPanel({
145
172
  git={git}
146
173
  onChange={(patch) => draftsActions.update(draft.id, patch)}
147
174
  />
175
+ {held && (
176
+ <StartClaimsNotice
177
+ warnings={held.warnings}
178
+ busy={pending}
179
+ onStart={(after) => void send(held.text, [], [], { after })}
180
+ onDismiss={() => {
181
+ setPrefill({ text: held.text, at: Date.now() });
182
+ setHeld(null);
183
+ }}
184
+ />
185
+ )}
148
186
  <Composer
149
187
  draftKey={draftComposerKey(draft.id)}
150
188
  attachScope="draft"