@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.
- package/README.md +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +15 -14
- package/app/page.tsx +5 -0
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftPanel.tsx +40 -2
- package/components/Hosts/AccountMachines.tsx +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +17 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +4 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +10 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/index.ts +8 -0
- package/lib/claims/collectLocal.ts +121 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +1 -0
- package/lib/db/types.ts +16 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +6 -1
- package/lib/orchestrator/facts.ts +5 -0
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/launch.ts +5 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +5 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +1 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +3 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +3 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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 (
|
|
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"
|