@saadnvd1/agent-os 0.3.723 → 0.3.728
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/loops/route.ts +17 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +6 -1
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { AlertTriangle, Check, Loader2, RotateCcw, Send } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
8
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
9
|
+
import { useBookmarkChat } from "@/components/Bookmarks/context";
|
|
10
|
+
import { useChatItem } from "@/components/Chat/itemContext";
|
|
11
|
+
import {
|
|
12
|
+
useAskAgain,
|
|
13
|
+
useDismissRecommendation,
|
|
14
|
+
useSendRecommendation,
|
|
15
|
+
useSessionAnalyst,
|
|
16
|
+
} from "@/data/analyst";
|
|
17
|
+
import { parseReviewJson } from "@/lib/analyst/parseReview";
|
|
18
|
+
import type { Issue, Recommendation } from "@/lib/analyst/types";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
20
|
+
import { cardView } from "./cardState";
|
|
21
|
+
import { SignalChips } from "./SignalChips";
|
|
22
|
+
|
|
23
|
+
const SEVERITY_DOT: Record<Issue["severity"], string> = {
|
|
24
|
+
high: "bg-red-500",
|
|
25
|
+
medium: "bg-amber-500",
|
|
26
|
+
low: "bg-muted-foreground/50",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const fail = (e: Error) => toast.error(e.message);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An analyst's `agentos-review` block, drawn as a card: its summary, the
|
|
33
|
+
* issues it flagged and the prompt it recommends, with Send to Coding Agent,
|
|
34
|
+
* Edit and Dismiss while the stored card is open.
|
|
35
|
+
*/
|
|
36
|
+
export function ReviewCard({ code }: { code: string }) {
|
|
37
|
+
const item = useChatItem();
|
|
38
|
+
const sessionId = useBookmarkChat()?.sessionId;
|
|
39
|
+
const { data: link } = useSessionAnalyst(sessionId);
|
|
40
|
+
const parsed = parseReviewJson(code);
|
|
41
|
+
const rec =
|
|
42
|
+
(item &&
|
|
43
|
+
link &&
|
|
44
|
+
link.analyst.id === sessionId &&
|
|
45
|
+
link.recommendations.find((r) => r.analystItemId === item.itemId)) ||
|
|
46
|
+
null;
|
|
47
|
+
|
|
48
|
+
if (!parsed.ok)
|
|
49
|
+
return (
|
|
50
|
+
<div className="bg-foreground/[0.04] text-muted-foreground not-prose my-3 space-y-2 rounded-xl px-3 py-2.5 text-xs">
|
|
51
|
+
<div className="flex items-center gap-2">
|
|
52
|
+
{item?.streaming ? (
|
|
53
|
+
<>
|
|
54
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Writing the
|
|
55
|
+
review…
|
|
56
|
+
</>
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
60
|
+
{parsed.error}
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
{rec && link && !item?.streaming && (
|
|
65
|
+
<Actions
|
|
66
|
+
linkId={link.id}
|
|
67
|
+
coderName={link.coder.name}
|
|
68
|
+
rec={rec}
|
|
69
|
+
prompt={null}
|
|
70
|
+
view={cardView(rec, null, link.coderLastTurn)}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const review = parsed.review;
|
|
77
|
+
const view = cardView(
|
|
78
|
+
rec,
|
|
79
|
+
rec?.verdict ?? review.verdict,
|
|
80
|
+
link?.coderLastTurn ?? null
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="bg-card hairline not-prose my-3 space-y-3 rounded-xl p-3 text-sm">
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<StatusPill tone={view.tone}>{view.label}</StatusPill>
|
|
87
|
+
<span className="text-muted-foreground text-xs">Analyst review</span>
|
|
88
|
+
</div>
|
|
89
|
+
<p className="leading-relaxed">{review.summary}</p>
|
|
90
|
+
{rec?.signals && <SignalChips signals={rec.signals} />}
|
|
91
|
+
{review.issues.length > 0 && (
|
|
92
|
+
<ul className="space-y-2">
|
|
93
|
+
{review.issues.map((issue, i) => (
|
|
94
|
+
<li key={i} className="flex gap-2">
|
|
95
|
+
<span
|
|
96
|
+
aria-label={`${issue.severity} severity`}
|
|
97
|
+
className={cn(
|
|
98
|
+
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
|
|
99
|
+
SEVERITY_DOT[issue.severity]
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
<div className="min-w-0">
|
|
103
|
+
<div className="font-medium">{issue.title}</div>
|
|
104
|
+
{issue.where && (
|
|
105
|
+
<div className="text-muted-foreground truncate font-mono text-[11px]">
|
|
106
|
+
{issue.where}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{issue.detail && (
|
|
110
|
+
<div className="text-muted-foreground text-xs leading-relaxed">
|
|
111
|
+
{issue.detail}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
)}
|
|
119
|
+
{view.notes.map((note) => (
|
|
120
|
+
<div
|
|
121
|
+
key={note}
|
|
122
|
+
className="flex items-start gap-2 rounded-lg bg-amber-500/10 px-2.5 py-2 text-xs text-amber-700 dark:text-amber-400"
|
|
123
|
+
>
|
|
124
|
+
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
|
125
|
+
{note}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
{rec && link ? (
|
|
129
|
+
<Actions
|
|
130
|
+
linkId={link.id}
|
|
131
|
+
coderName={link.coder.name}
|
|
132
|
+
rec={rec}
|
|
133
|
+
prompt={review.prompt ?? null}
|
|
134
|
+
view={view}
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
review.prompt && <PromptBox text={review.prompt} />
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function PromptBox({ text, muted }: { text: string; muted?: boolean }) {
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
className={cn(
|
|
147
|
+
"bg-foreground/[0.04] max-h-60 overflow-y-auto rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap",
|
|
148
|
+
muted && "text-muted-foreground"
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{text}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function Actions({
|
|
157
|
+
linkId,
|
|
158
|
+
coderName,
|
|
159
|
+
rec,
|
|
160
|
+
prompt,
|
|
161
|
+
view,
|
|
162
|
+
}: {
|
|
163
|
+
linkId: string;
|
|
164
|
+
coderName: string;
|
|
165
|
+
rec: Recommendation;
|
|
166
|
+
prompt: string | null;
|
|
167
|
+
view: ReturnType<typeof cardView>;
|
|
168
|
+
}) {
|
|
169
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
170
|
+
const send = useSendRecommendation(linkId);
|
|
171
|
+
const dismiss = useDismissRecommendation(linkId);
|
|
172
|
+
const again = useAskAgain(linkId);
|
|
173
|
+
const busy = send.isPending || dismiss.isPending || again.isPending;
|
|
174
|
+
|
|
175
|
+
if (rec.state === "sent")
|
|
176
|
+
return (
|
|
177
|
+
<div className="space-y-1.5">
|
|
178
|
+
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
179
|
+
<Check className="h-3.5 w-3.5 text-green-500" /> Sent to {coderName}
|
|
180
|
+
</div>
|
|
181
|
+
{rec.sentText && <PromptBox text={rec.sentText} muted />}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (draft !== null)
|
|
186
|
+
return (
|
|
187
|
+
<div className="space-y-2">
|
|
188
|
+
<Textarea
|
|
189
|
+
autoFocus
|
|
190
|
+
value={draft}
|
|
191
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
192
|
+
aria-label="Prompt for the coding agent"
|
|
193
|
+
className="min-h-[160px]"
|
|
194
|
+
/>
|
|
195
|
+
<div className="flex flex-wrap gap-2">
|
|
196
|
+
<Button
|
|
197
|
+
size="sm"
|
|
198
|
+
className="h-11 md:h-8"
|
|
199
|
+
disabled={busy || !draft.trim()}
|
|
200
|
+
onClick={() =>
|
|
201
|
+
send.mutate(
|
|
202
|
+
{ recId: rec.id, text: draft.trim() },
|
|
203
|
+
{ onError: fail, onSuccess: () => setDraft(null) }
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
<Send /> Send to Coding Agent
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
size="sm"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
className="h-11 md:h-8"
|
|
213
|
+
onClick={() => setDraft(null)}
|
|
214
|
+
>
|
|
215
|
+
Cancel
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
{prompt && <PromptBox text={prompt} muted={rec.state !== "open"} />}
|
|
224
|
+
<div className="flex flex-wrap gap-2">
|
|
225
|
+
{view.canSend && (
|
|
226
|
+
<Button
|
|
227
|
+
size="sm"
|
|
228
|
+
className="h-11 md:h-8"
|
|
229
|
+
disabled={busy}
|
|
230
|
+
onClick={() => send.mutate({ recId: rec.id }, { onError: fail })}
|
|
231
|
+
>
|
|
232
|
+
{send.isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
|
233
|
+
Send to Coding Agent
|
|
234
|
+
</Button>
|
|
235
|
+
)}
|
|
236
|
+
{view.canWrite && (
|
|
237
|
+
<Button
|
|
238
|
+
size="sm"
|
|
239
|
+
variant="outline"
|
|
240
|
+
className="h-11 md:h-8"
|
|
241
|
+
disabled={busy}
|
|
242
|
+
onClick={() => setDraft(prompt ?? "")}
|
|
243
|
+
>
|
|
244
|
+
{view.canSend ? "Edit" : "Ask coding agent anyway"}
|
|
245
|
+
</Button>
|
|
246
|
+
)}
|
|
247
|
+
{view.canAskAgain && (
|
|
248
|
+
<Button
|
|
249
|
+
size="sm"
|
|
250
|
+
variant="outline"
|
|
251
|
+
className="h-11 md:h-8"
|
|
252
|
+
disabled={busy}
|
|
253
|
+
onClick={() => again.mutate(rec.id, { onError: fail })}
|
|
254
|
+
>
|
|
255
|
+
<RotateCcw /> Ask again
|
|
256
|
+
</Button>
|
|
257
|
+
)}
|
|
258
|
+
{view.canDismiss && (
|
|
259
|
+
<Button
|
|
260
|
+
size="sm"
|
|
261
|
+
variant="ghost"
|
|
262
|
+
className="h-11 md:h-8"
|
|
263
|
+
disabled={busy}
|
|
264
|
+
onClick={() => dismiss.mutate(rec.id, { onError: fail })}
|
|
265
|
+
>
|
|
266
|
+
Dismiss
|
|
267
|
+
</Button>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DigestSignals } from "@/lib/analyst/types";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
5
|
+
|
|
6
|
+
const shortCommand = (c: string) => (c.length > 40 ? `${c.slice(0, 39)}…` : c);
|
|
7
|
+
|
|
8
|
+
/** What the reviewed turns did, read without a model: the facts under the card. */
|
|
9
|
+
export function SignalChips({ signals: s }: { signals: DigestSignals }) {
|
|
10
|
+
const chips: { text: string; tone?: "good" | "bad" }[] = [];
|
|
11
|
+
chips.push({ text: plural(s.toolCalls, "tool call") });
|
|
12
|
+
if (s.filesChanged.length)
|
|
13
|
+
chips.push({ text: plural(s.filesChanged.length, "file") + " changed" });
|
|
14
|
+
for (const c of s.checks)
|
|
15
|
+
chips.push({
|
|
16
|
+
text: `${shortCommand(c.command)} ${c.passed ? "passed" : "failed"}`,
|
|
17
|
+
tone: c.passed ? "good" : "bad",
|
|
18
|
+
});
|
|
19
|
+
if (s.failedTools)
|
|
20
|
+
chips.push({ text: plural(s.failedTools, "failed tool"), tone: "bad" });
|
|
21
|
+
if (s.errors) chips.push({ text: plural(s.errors, "error"), tone: "bad" });
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex flex-wrap gap-1.5">
|
|
24
|
+
{chips.map((c) => (
|
|
25
|
+
<span
|
|
26
|
+
key={c.text}
|
|
27
|
+
className={cn(
|
|
28
|
+
"bg-foreground/[0.05] text-muted-foreground max-w-full truncate rounded-md px-1.5 py-0.5 font-mono text-[11px]",
|
|
29
|
+
c.tone === "good" &&
|
|
30
|
+
"bg-green-500/10 text-green-700 dark:text-green-400",
|
|
31
|
+
c.tone === "bad" && "bg-red-500/10 text-red-700 dark:text-red-400"
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{c.text}
|
|
35
|
+
</span>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { Loader2 } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { Switch } from "@/components/ui/switch";
|
|
15
|
+
import { RoleModelSelect } from "@/components/Models/RoleModelSelect";
|
|
16
|
+
import { analystUi, analystUiActions } from "@/stores/analystUi";
|
|
17
|
+
import { useStartAnalystBeside } from "./pair";
|
|
18
|
+
|
|
19
|
+
// Pairs an analyst with a chat: it reviews each turn the coding agent
|
|
20
|
+
// finishes and recommends the next prompt, which the user sends or not.
|
|
21
|
+
export function StartAnalystSheet() {
|
|
22
|
+
const ui = useSnapshot(analystUi);
|
|
23
|
+
return (
|
|
24
|
+
<Dialog open={ui.open} onOpenChange={(o) => !o && analystUiActions.close()}>
|
|
25
|
+
{ui.open && ui.sessionId && (
|
|
26
|
+
<StartAnalystForm
|
|
27
|
+
key={ui.sessionId}
|
|
28
|
+
sessionId={ui.sessionId}
|
|
29
|
+
name={ui.name}
|
|
30
|
+
paneId={ui.paneId}
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
</Dialog>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function StartAnalystForm({
|
|
38
|
+
sessionId,
|
|
39
|
+
name,
|
|
40
|
+
paneId,
|
|
41
|
+
}: {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
name: string;
|
|
44
|
+
paneId: string | null;
|
|
45
|
+
}) {
|
|
46
|
+
const [model, setModel] = useState<string | null>(null);
|
|
47
|
+
const [reviewLast, setReviewLast] = useState(false);
|
|
48
|
+
const start = useStartAnalystBeside();
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<DialogContent className="sm:max-w-[460px]">
|
|
52
|
+
<DialogHeader>
|
|
53
|
+
<DialogTitle>Add an analyst</DialogTitle>
|
|
54
|
+
<DialogDescription>
|
|
55
|
+
A second chat that reviews each turn of{" "}
|
|
56
|
+
<span className="text-foreground font-medium">{name}</span>, explains
|
|
57
|
+
it in plain English, flags issues and drafts the next prompt. Nothing
|
|
58
|
+
reaches the coding agent until you press Send.
|
|
59
|
+
</DialogDescription>
|
|
60
|
+
</DialogHeader>
|
|
61
|
+
<div className="space-y-1.5">
|
|
62
|
+
<label
|
|
63
|
+
htmlFor="analyst-model"
|
|
64
|
+
className="text-muted-foreground text-xs font-medium uppercase"
|
|
65
|
+
>
|
|
66
|
+
Analyst model
|
|
67
|
+
</label>
|
|
68
|
+
<RoleModelSelect
|
|
69
|
+
id="analyst-model"
|
|
70
|
+
value={model}
|
|
71
|
+
inheritLabel="The analyst role's model"
|
|
72
|
+
claudeOnly
|
|
73
|
+
onChange={setModel}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
77
|
+
<span>Review the last turn now</span>
|
|
78
|
+
<Switch checked={reviewLast} onCheckedChange={setReviewLast} />
|
|
79
|
+
</label>
|
|
80
|
+
<div className="flex justify-end gap-2">
|
|
81
|
+
<Button
|
|
82
|
+
variant="ghost"
|
|
83
|
+
className="h-11 md:h-9"
|
|
84
|
+
onClick={() => analystUiActions.close()}
|
|
85
|
+
>
|
|
86
|
+
Cancel
|
|
87
|
+
</Button>
|
|
88
|
+
<Button
|
|
89
|
+
className="h-11 md:h-9"
|
|
90
|
+
disabled={start.isPending}
|
|
91
|
+
onClick={async () => {
|
|
92
|
+
const link = await start.run(
|
|
93
|
+
{
|
|
94
|
+
coderSessionId: sessionId,
|
|
95
|
+
analystModel: model ?? undefined,
|
|
96
|
+
reviewLast,
|
|
97
|
+
},
|
|
98
|
+
paneId ?? undefined
|
|
99
|
+
);
|
|
100
|
+
if (link) analystUiActions.close();
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{start.isPending && <Loader2 className="animate-spin" />}
|
|
104
|
+
Add analyst
|
|
105
|
+
</Button>
|
|
106
|
+
</div>
|
|
107
|
+
</DialogContent>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { DigestSignals, Recommendation } from "@/lib/analyst/types";
|
|
3
|
+
import { cardView } from "./cardState";
|
|
4
|
+
|
|
5
|
+
const signals = (over: Partial<DigestSignals> = {}): DigestSignals => ({
|
|
6
|
+
turns: 1,
|
|
7
|
+
toolCalls: 3,
|
|
8
|
+
failedTools: 0,
|
|
9
|
+
checks: [],
|
|
10
|
+
filesChanged: [],
|
|
11
|
+
durationMs: 1000,
|
|
12
|
+
costUsd: 0,
|
|
13
|
+
interrupted: false,
|
|
14
|
+
errors: 0,
|
|
15
|
+
noToolTurns: 0,
|
|
16
|
+
readWeb: false,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const rec = (over: Partial<Recommendation> = {}): Recommendation => ({
|
|
21
|
+
id: "r1",
|
|
22
|
+
linkId: "l1",
|
|
23
|
+
analystItemId: "a1",
|
|
24
|
+
coderThrough: "t1",
|
|
25
|
+
verdict: "continue",
|
|
26
|
+
summary: "Did the thing",
|
|
27
|
+
issues: [],
|
|
28
|
+
signals: signals(),
|
|
29
|
+
prompt: "Run the tests",
|
|
30
|
+
state: "open",
|
|
31
|
+
error: null,
|
|
32
|
+
sentText: null,
|
|
33
|
+
sentItemId: null,
|
|
34
|
+
edited: false,
|
|
35
|
+
createdAt: 1,
|
|
36
|
+
decidedAt: null,
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("cardView", () => {
|
|
41
|
+
it("offers Send, Edit and Dismiss on an open card with a prompt", () => {
|
|
42
|
+
const v = cardView(rec(), "continue", "t1");
|
|
43
|
+
expect(v).toMatchObject({
|
|
44
|
+
label: "Next prompt",
|
|
45
|
+
canSend: true,
|
|
46
|
+
canWrite: true,
|
|
47
|
+
canDismiss: true,
|
|
48
|
+
canAskAgain: false,
|
|
49
|
+
notes: [],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("is read-only without a stored card", () => {
|
|
54
|
+
const v = cardView(null, "continue", "t1");
|
|
55
|
+
expect(v.canSend || v.canWrite || v.canDismiss).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("has no Send on a blocked or needs-you card, but lets the user write one", () => {
|
|
59
|
+
for (const verdict of ["blocked", "needs_you"] as const) {
|
|
60
|
+
const v = cardView(rec({ verdict }), verdict, "t1");
|
|
61
|
+
expect(v.canSend).toBe(false);
|
|
62
|
+
expect(v.canWrite).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("warns when the coding agent has moved on", () => {
|
|
67
|
+
const v = cardView(rec(), "continue", "t2");
|
|
68
|
+
expect(v.canSend).toBe(true);
|
|
69
|
+
expect(v.notes).toContain(
|
|
70
|
+
"The coding agent has moved on since this review"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("flags a turn with no tool calls and web reads", () => {
|
|
75
|
+
const v = cardView(
|
|
76
|
+
rec({ signals: signals({ noToolTurns: 1, readWeb: true }) }),
|
|
77
|
+
"continue",
|
|
78
|
+
"t1"
|
|
79
|
+
);
|
|
80
|
+
expect(v.notes).toEqual([
|
|
81
|
+
"The coding agent's turn ended without a single tool call",
|
|
82
|
+
"It read web content: check the prompt before sending",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("offers Ask again on an invalid card, with its error", () => {
|
|
87
|
+
const v = cardView(
|
|
88
|
+
rec({ state: "invalid", error: "No block", verdict: null }),
|
|
89
|
+
null,
|
|
90
|
+
"t1"
|
|
91
|
+
);
|
|
92
|
+
expect(v).toMatchObject({
|
|
93
|
+
canAskAgain: true,
|
|
94
|
+
canSend: false,
|
|
95
|
+
notes: ["No block"],
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("is settled once sent, dismissed or superseded", () => {
|
|
100
|
+
for (const state of ["sent", "dismissed", "superseded"] as const) {
|
|
101
|
+
const v = cardView(rec({ state }), "continue", "t2");
|
|
102
|
+
expect(v.canSend || v.canWrite || v.canDismiss).toBe(false);
|
|
103
|
+
expect(v.notes).toEqual([]);
|
|
104
|
+
}
|
|
105
|
+
expect(
|
|
106
|
+
cardView(rec({ state: "sent", edited: true }), "continue", "t1").label
|
|
107
|
+
).toBe("Sent, edited");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Recommendation, Verdict } from "@/lib/analyst/types";
|
|
2
|
+
|
|
3
|
+
export type CardTone = "primary" | "green" | "amber" | "red" | "muted";
|
|
4
|
+
|
|
5
|
+
export interface CardView {
|
|
6
|
+
label: string;
|
|
7
|
+
tone: CardTone;
|
|
8
|
+
// Send the prompt as written (or edited).
|
|
9
|
+
canSend: boolean;
|
|
10
|
+
// A blocked or needs-you card has no Send; the user can still write one.
|
|
11
|
+
canWrite: boolean;
|
|
12
|
+
canDismiss: boolean;
|
|
13
|
+
canAskAgain: boolean;
|
|
14
|
+
// Why to look twice before sending, or what happened to it.
|
|
15
|
+
notes: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VERDICT_LABEL: Record<Verdict, [string, CardTone]> = {
|
|
19
|
+
continue: ["Next prompt", "primary"],
|
|
20
|
+
done: ["Looks done", "green"],
|
|
21
|
+
blocked: ["Blocked", "red"],
|
|
22
|
+
needs_you: ["Needs you", "amber"],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a review card shows and offers. `rec` is the stored card for the
|
|
29
|
+
* block, or null when there is none (an old review, an ended pairing): then
|
|
30
|
+
* it's read-only.
|
|
31
|
+
*/
|
|
32
|
+
export function cardView(
|
|
33
|
+
rec: Recommendation | null,
|
|
34
|
+
verdict: Verdict | null,
|
|
35
|
+
coderLastTurn: string | null
|
|
36
|
+
): CardView {
|
|
37
|
+
const [vLabel, vTone] = verdict
|
|
38
|
+
? VERDICT_LABEL[verdict]
|
|
39
|
+
: (["Review", "muted"] as const);
|
|
40
|
+
const view: CardView = {
|
|
41
|
+
label: vLabel,
|
|
42
|
+
tone: vTone,
|
|
43
|
+
canSend: false,
|
|
44
|
+
canWrite: false,
|
|
45
|
+
canDismiss: false,
|
|
46
|
+
canAskAgain: false,
|
|
47
|
+
notes: [],
|
|
48
|
+
};
|
|
49
|
+
if (!rec) return view;
|
|
50
|
+
|
|
51
|
+
const s = rec.signals;
|
|
52
|
+
if (s?.noToolTurns && s.noToolTurns === s.turns)
|
|
53
|
+
view.notes.push(
|
|
54
|
+
s.turns === 1
|
|
55
|
+
? "The coding agent's turn ended without a single tool call"
|
|
56
|
+
: `None of the ${plural(s.turns, "turn")} made a tool call`
|
|
57
|
+
);
|
|
58
|
+
if (s?.interrupted) view.notes.push("The coding agent was interrupted");
|
|
59
|
+
|
|
60
|
+
switch (rec.state) {
|
|
61
|
+
case "sent":
|
|
62
|
+
return {
|
|
63
|
+
...view,
|
|
64
|
+
label: rec.edited ? "Sent, edited" : "Sent",
|
|
65
|
+
tone: "muted",
|
|
66
|
+
notes: [],
|
|
67
|
+
};
|
|
68
|
+
case "dismissed":
|
|
69
|
+
return { ...view, label: "Dismissed", tone: "muted", notes: [] };
|
|
70
|
+
case "superseded":
|
|
71
|
+
return { ...view, label: "Superseded", tone: "muted", notes: [] };
|
|
72
|
+
case "invalid":
|
|
73
|
+
return {
|
|
74
|
+
...view,
|
|
75
|
+
label: "Couldn't read",
|
|
76
|
+
tone: "red",
|
|
77
|
+
canAskAgain: true,
|
|
78
|
+
canDismiss: true,
|
|
79
|
+
notes: [rec.error || "The review block was invalid"],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Open.
|
|
84
|
+
if (rec.coderThrough && coderLastTurn && rec.coderThrough !== coderLastTurn)
|
|
85
|
+
view.notes.push("The coding agent has moved on since this review");
|
|
86
|
+
if (s?.readWeb)
|
|
87
|
+
view.notes.push("It read web content: check the prompt before sending");
|
|
88
|
+
const sendable = verdict === "continue" || verdict === "done";
|
|
89
|
+
return {
|
|
90
|
+
...view,
|
|
91
|
+
canSend: sendable && !!rec.prompt,
|
|
92
|
+
canWrite: true,
|
|
93
|
+
canDismiss: true,
|
|
94
|
+
};
|
|
95
|
+
}
|