@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.
Files changed (116) hide show
  1. package/README.md +1 -0
  2. package/app/api/loops/route.ts +17 -0
  3. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  4. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  5. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  6. package/app/api/tasks/[id]/loop/route.ts +72 -0
  7. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  8. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  9. package/app/api/tasks/[id]/mode/route.ts +29 -0
  10. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  11. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  12. package/app/api/tasks/[id]/report/route.ts +39 -0
  13. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  14. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  15. package/app/page.tsx +8 -0
  16. package/components/Analyst/PairChip.tsx +131 -0
  17. package/components/Analyst/PairSwitch.tsx +76 -0
  18. package/components/Analyst/ReviewCard.tsx +272 -0
  19. package/components/Analyst/SignalChips.tsx +39 -0
  20. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  21. package/components/Analyst/cardState.test.ts +109 -0
  22. package/components/Analyst/cardState.ts +95 -0
  23. package/components/Analyst/pair.ts +81 -0
  24. package/components/Chat/Code.tsx +3 -0
  25. package/components/Chat/Messages.tsx +6 -1
  26. package/components/Chat/itemContext.ts +14 -0
  27. package/components/Draft/DraftChips.tsx +10 -0
  28. package/components/Draft/DraftPanel.tsx +7 -0
  29. package/components/Pane/DesktopTabBar.tsx +6 -0
  30. package/components/Pane/MobileTabBar.tsx +17 -0
  31. package/components/Pane/MobileViewPicker.tsx +4 -0
  32. package/components/Pane/PaneMenu.test.ts +21 -0
  33. package/components/Pane/PaneMenu.tsx +16 -0
  34. package/components/Pane/PaneViewToggle.tsx +12 -0
  35. package/components/Pane/index.tsx +19 -0
  36. package/components/Report/DraftCard.tsx +89 -0
  37. package/components/Report/EstimateBar.tsx +51 -0
  38. package/components/Report/EvidenceSheet.tsx +98 -0
  39. package/components/Report/ModeChooser.tsx +43 -0
  40. package/components/Report/ReportView.tsx +289 -0
  41. package/components/SessionList/SessionRowMenu.tsx +6 -0
  42. package/components/Sessions/RolePill.tsx +7 -0
  43. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  44. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  45. package/components/Tasks/Loop/LoopView.tsx +350 -0
  46. package/components/Tasks/Loop/labels.ts +17 -0
  47. package/components/Tasks/TaskRow.tsx +2 -0
  48. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  49. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  50. package/components/ui/segmented.tsx +55 -0
  51. package/components/ui/status-pill.tsx +3 -0
  52. package/contexts/PaneContext.tsx +21 -1
  53. package/data/loops/index.ts +3 -0
  54. package/data/loops/keys.ts +4 -0
  55. package/data/loops/mutations.ts +68 -0
  56. package/data/loops/queries.ts +40 -0
  57. package/data/push/topics.ts +6 -0
  58. package/data/reports/index.ts +115 -0
  59. package/lib/analyst/forward.ts +1 -1
  60. package/lib/db/changes.ts +7 -0
  61. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  62. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  63. package/lib/done/index.ts +2 -0
  64. package/lib/drafts.ts +2 -0
  65. package/lib/loops/analyse.ts +46 -0
  66. package/lib/loops/analyst-prompt.ts +203 -0
  67. package/lib/loops/checkCriteria.ts +114 -0
  68. package/lib/loops/compactEvidence.ts +140 -0
  69. package/lib/loops/controls.ts +317 -0
  70. package/lib/loops/decideStop.ts +113 -0
  71. package/lib/loops/deps.ts +128 -0
  72. package/lib/loops/diffMap.ts +39 -0
  73. package/lib/loops/draftCriteria.ts +71 -0
  74. package/lib/loops/gatherEvidence.ts +265 -0
  75. package/lib/loops/hooks.ts +40 -0
  76. package/lib/loops/index.ts +7 -0
  77. package/lib/loops/route.ts +83 -0
  78. package/lib/loops/runCycle.ts +323 -0
  79. package/lib/loops/screenInstruction.ts +59 -0
  80. package/lib/loops/sendInstruction.ts +83 -0
  81. package/lib/loops/snapshot.ts +38 -0
  82. package/lib/loops/stopLoop.ts +86 -0
  83. package/lib/loops/store.ts +481 -0
  84. package/lib/loops/summary.ts +27 -0
  85. package/lib/loops/tickLoops.ts +141 -0
  86. package/lib/loops/types.ts +223 -0
  87. package/lib/loops/world.test-helpers.ts +223 -0
  88. package/lib/models/plan-step.ts +4 -1
  89. package/lib/orchestrator/digest.ts +2 -1
  90. package/lib/orchestrator/loop-guard.ts +5 -2
  91. package/lib/orchestrator/pr-wake.ts +4 -0
  92. package/lib/orchestrator/watcher.ts +8 -1
  93. package/lib/panes.ts +52 -0
  94. package/lib/plans/acceptance.ts +23 -0
  95. package/lib/reports/drafts.ts +87 -0
  96. package/lib/reports/end-hook.ts +17 -0
  97. package/lib/reports/estimate.ts +107 -0
  98. package/lib/reports/evidence/build.ts +193 -0
  99. package/lib/reports/evidence/gather.ts +229 -0
  100. package/lib/reports/fixtures.ts +17 -0
  101. package/lib/reports/generate.ts +159 -0
  102. package/lib/reports/hooks.ts +30 -0
  103. package/lib/reports/mode.ts +65 -0
  104. package/lib/reports/model.ts +115 -0
  105. package/lib/reports/read.ts +26 -0
  106. package/lib/reports/store.ts +180 -0
  107. package/lib/reports/template.ts +117 -0
  108. package/lib/reports/types.ts +135 -0
  109. package/lib/reports/validate.ts +142 -0
  110. package/lib/sidebar/row-menu.ts +9 -0
  111. package/lib/tasks/contract/routes.ts +5 -0
  112. package/lib/tasks/contract/types.ts +3 -0
  113. package/lib/tasks/finish.ts +2 -0
  114. package/lib/tasks/move-carry.ts +1 -0
  115. package/package.json +2 -2
  116. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,51 @@
1
+ import type { Estimate } from "@/lib/reports/types";
2
+ import { estimateLabel, estimatePercent } from "@/lib/reports/types";
3
+
4
+ // The percentage never shows without its label: it is an estimate, and the
5
+ // label says how it was counted.
6
+ export function EstimateBar({ estimate }: { estimate: Estimate }) {
7
+ const pct = estimatePercent(estimate);
8
+ const how =
9
+ estimate.basis === "acceptance"
10
+ ? "Acceptance items the plan listed, counted when a passing test, check or review backs them."
11
+ : "The plan has no acceptance items, so this counts delivery stages: plan, push, pull request, CI, review, merge.";
12
+ return (
13
+ <div className="space-y-2" title={how}>
14
+ <div className="flex items-baseline justify-between gap-3">
15
+ <span className="text-muted-foreground text-xs">
16
+ {estimateLabel(estimate)}
17
+ </span>
18
+ <span className="text-sm font-semibold tabular-nums">{pct}%</span>
19
+ </div>
20
+ <div
21
+ role="progressbar"
22
+ aria-valuemin={0}
23
+ aria-valuemax={estimate.total}
24
+ aria-valuenow={estimate.done}
25
+ aria-label={estimateLabel(estimate)}
26
+ className="bg-muted h-2 overflow-hidden rounded-full"
27
+ >
28
+ <div
29
+ className="bg-primary h-full rounded-full"
30
+ style={{ width: `${pct}%` }}
31
+ />
32
+ </div>
33
+ <details className="text-xs">
34
+ <summary className="text-muted-foreground flex min-h-11 cursor-pointer items-center">
35
+ How this is counted
36
+ </summary>
37
+ <p className="text-muted-foreground pb-2">{how}</p>
38
+ <ul className="space-y-1 pb-1">
39
+ {estimate.items.map((i) => (
40
+ <li key={i.label} className="flex gap-2">
41
+ <span aria-hidden>{i.verified ? "✓" : "○"}</span>
42
+ <span className={i.verified ? "" : "text-muted-foreground"}>
43
+ {i.label}
44
+ </span>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ </details>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { ExternalLink } from "lucide-react";
4
+ import {
5
+ ASheet,
6
+ ASheetContent,
7
+ ASheetDescription,
8
+ ASheetHeader,
9
+ ASheetTitle,
10
+ } from "@/components/a/ASheet";
11
+ import { StatusPill } from "@/components/ui/status-pill";
12
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
13
+ import type { EvidenceRef } from "@/lib/reports/types";
14
+
15
+ const TONE = {
16
+ pass: "green",
17
+ fail: "red",
18
+ pending: "amber",
19
+ info: "muted",
20
+ } as const;
21
+ const WORD = {
22
+ pass: "Passed",
23
+ fail: "Failed",
24
+ pending: "Waiting",
25
+ info: "Info",
26
+ } as const;
27
+
28
+ // What a report line rests on. The text is untrusted output: shown, never run.
29
+ export function EvidenceSheet({
30
+ evidence,
31
+ stale,
32
+ onClose,
33
+ }: {
34
+ evidence: EvidenceRef | null;
35
+ stale: boolean;
36
+ onClose: () => void;
37
+ }) {
38
+ const wide = useMediaQuery("(min-width: 768px)");
39
+ return (
40
+ <ASheet open={!!evidence} onOpenChange={(o) => !o && onClose()}>
41
+ <ASheetContent
42
+ side={wide ? "right" : "bottom"}
43
+ className={wide ? "w-full max-w-md" : "max-h-[80dvh] rounded-t-2xl"}
44
+ >
45
+ {evidence && (
46
+ <>
47
+ <ASheetHeader className="pr-12">
48
+ <ASheetTitle className="break-words">
49
+ {evidence.label}
50
+ </ASheetTitle>
51
+ <ASheetDescription className="flex flex-wrap items-center gap-2">
52
+ <StatusPill tone={TONE[evidence.status]}>
53
+ {WORD[evidence.status]}
54
+ </StatusPill>
55
+ <span>{evidence.kind}</span>
56
+ {evidence.sha && (
57
+ <span className="font-mono">{evidence.sha.slice(0, 7)}</span>
58
+ )}
59
+ {stale && evidence.sha && <span>older push</span>}
60
+ </ASheetDescription>
61
+ </ASheetHeader>
62
+ <div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 pb-6">
63
+ {evidence.excerpt ? (
64
+ <pre className="bg-muted/60 max-h-[50dvh] overflow-auto rounded-lg p-3 text-xs break-words whitespace-pre-wrap">
65
+ {evidence.excerpt}
66
+ </pre>
67
+ ) : (
68
+ <p className="text-muted-foreground text-sm">
69
+ No output was kept for this one.
70
+ </p>
71
+ )}
72
+ <div className="flex flex-wrap gap-2">
73
+ {evidence.url && (
74
+ <a
75
+ href={evidence.url}
76
+ target="_blank"
77
+ rel="noopener noreferrer"
78
+ className="bg-secondary inline-flex min-h-11 items-center gap-1.5 rounded-lg px-3 text-sm"
79
+ >
80
+ Open in CI <ExternalLink className="h-3.5 w-3.5" />
81
+ </a>
82
+ )}
83
+ {evidence.href && (
84
+ <a
85
+ href={evidence.href}
86
+ className="bg-secondary inline-flex min-h-11 items-center rounded-lg px-3 text-sm"
87
+ >
88
+ Show in the task
89
+ </a>
90
+ )}
91
+ </div>
92
+ </div>
93
+ </>
94
+ )}
95
+ </ASheetContent>
96
+ </ASheet>
97
+ );
98
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { Segmented } from "@/components/ui/segmented";
4
+ import {
5
+ MODE_COPY,
6
+ OPERATING_MODES,
7
+ type OperatingMode,
8
+ } from "@/lib/reports/types";
9
+
10
+ const OPTIONS = OPERATING_MODES.map((m) => ({
11
+ value: m,
12
+ label: MODE_COPY[m].label,
13
+ hint: MODE_COPY[m].blurb,
14
+ }));
15
+
16
+ // Manual / Assisted / Autonomous, with what the chosen one does underneath.
17
+ export function ModeChooser({
18
+ mode,
19
+ onChange,
20
+ disabled,
21
+ note,
22
+ }: {
23
+ mode: OperatingMode;
24
+ onChange: (m: OperatingMode) => void;
25
+ disabled?: boolean;
26
+ note?: string;
27
+ }) {
28
+ return (
29
+ <div className="space-y-1.5">
30
+ <Segmented
31
+ label="Operating mode"
32
+ value={mode}
33
+ options={OPTIONS}
34
+ onChange={onChange}
35
+ disabled={disabled}
36
+ />
37
+ <p className="text-muted-foreground text-xs">
38
+ {MODE_COPY[mode].blurb}
39
+ {note ? ` ${note}` : ""}
40
+ </p>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,289 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import {
5
+ AlertTriangle,
6
+ ArrowRight,
7
+ CheckCircle2,
8
+ Hand,
9
+ Loader2,
10
+ RefreshCw,
11
+ type LucideIcon,
12
+ } from "lucide-react";
13
+ import { Button } from "@/components/ui/button";
14
+ import { StatusPill } from "@/components/ui/status-pill";
15
+ import { useGenerateReport, useTaskMode, useTaskReport } from "@/data/reports";
16
+ import {
17
+ MODE_COPY,
18
+ type EvidenceRef,
19
+ type HealthReport,
20
+ type LineTone,
21
+ } from "@/lib/reports/types";
22
+ import { DraftCard } from "./DraftCard";
23
+ import { EstimateBar } from "./EstimateBar";
24
+ import { EvidenceSheet } from "./EvidenceSheet";
25
+ import { ModeChooser } from "./ModeChooser";
26
+
27
+ const TONES: Record<LineTone, { icon: LucideIcon; cls: string; word: string }> =
28
+ {
29
+ done: {
30
+ icon: CheckCircle2,
31
+ cls: "text-green-600 dark:text-green-400",
32
+ word: "Done",
33
+ },
34
+ warn: {
35
+ icon: AlertTriangle,
36
+ cls: "text-amber-600 dark:text-amber-400",
37
+ word: "Needs attention",
38
+ },
39
+ blocked: {
40
+ icon: Hand,
41
+ cls: "text-red-600 dark:text-red-400",
42
+ word: "Blocked",
43
+ },
44
+ next: { icon: ArrowRight, cls: "text-primary", word: "Next" },
45
+ };
46
+
47
+ const STATE_TONE = {
48
+ merged: "green",
49
+ done: "green",
50
+ working: "primary",
51
+ review: "primary",
52
+ "checks-failing": "red",
53
+ blocked: "red",
54
+ } as const;
55
+
56
+ const when = (iso: string) =>
57
+ new Date(
58
+ iso.includes("T") ? iso : `${iso.replace(" ", "T")}Z`
59
+ ).toLocaleTimeString([], {
60
+ hour: "numeric",
61
+ minute: "2-digit",
62
+ });
63
+
64
+ // A task's plain-English health report: what's done, what needs attention,
65
+ // what's next, each line backed by evidence that opens underneath.
66
+ export function ReportView({
67
+ sessionId,
68
+ name,
69
+ }: {
70
+ sessionId: string;
71
+ name?: string;
72
+ }) {
73
+ const { data, isPending, isError, error } = useTaskReport(sessionId);
74
+ const generate = useGenerateReport(sessionId);
75
+ const setMode = useTaskMode(sessionId);
76
+ const [open, setOpen] = useState<EvidenceRef | null>(null);
77
+ const [changingMode, setChangingMode] = useState(false);
78
+ const asked = useRef(false);
79
+
80
+ const report = data?.report ?? null;
81
+ // Opening the view builds the first report, or a fresh one when the head moved.
82
+ useEffect(() => {
83
+ if (!data || asked.current || data.generating) return;
84
+ if (!report || report.stale) {
85
+ asked.current = true;
86
+ generate.mutate();
87
+ }
88
+ }, [data, report, generate]);
89
+
90
+ if (isPending)
91
+ return <div className="bg-muted/40 m-4 h-40 animate-pulse rounded-lg" />;
92
+ if (isError)
93
+ return <p className="text-destructive p-4 text-sm">{error.message}</p>;
94
+
95
+ const mode = data.mode;
96
+ const busy = data.generating || generate.isPending;
97
+
98
+ return (
99
+ <div className="h-full overflow-y-auto">
100
+ <div className="mx-auto max-w-2xl space-y-5 px-4 py-4">
101
+ <header className="space-y-2">
102
+ <div className="flex flex-wrap items-center gap-2">
103
+ <h2 className="text-lg font-semibold">Your Project Progress</h2>
104
+ {report && (
105
+ <StatusPill
106
+ tone={
107
+ STATE_TONE[report.state as keyof typeof STATE_TONE] ?? "muted"
108
+ }
109
+ >
110
+ {report.state.replace("-", " ")}
111
+ </StatusPill>
112
+ )}
113
+ <button
114
+ type="button"
115
+ onClick={() => setChangingMode((c) => !c)}
116
+ aria-expanded={changingMode}
117
+ className="bg-secondary ml-auto inline-flex min-h-11 items-center rounded-lg px-3 text-xs"
118
+ >
119
+ {MODE_COPY[mode.mode].label}
120
+ {mode.source === "task" ? " · this task" : ""}
121
+ </button>
122
+ </div>
123
+ {name && (
124
+ <p className="text-muted-foreground truncate text-sm">{name}</p>
125
+ )}
126
+ {changingMode && (
127
+ <div className="bg-muted/40 space-y-2 rounded-lg p-3">
128
+ <ModeChooser
129
+ mode={mode.mode}
130
+ onChange={(m) => setMode.mutate(m)}
131
+ disabled={setMode.isPending}
132
+ />
133
+ {mode.source === "task" && (
134
+ <Button
135
+ variant="ghost"
136
+ className="min-h-11"
137
+ onClick={() => setMode.mutate(null)}
138
+ >
139
+ Use the workspace default
140
+ </Button>
141
+ )}
142
+ </div>
143
+ )}
144
+ </header>
145
+
146
+ {!report ? (
147
+ <div className="bg-card space-y-3 rounded-xl p-5 text-center shadow-sm">
148
+ <p className="text-sm">
149
+ {busy
150
+ ? "Reading the evidence…"
151
+ : "No report has been built for this task yet."}
152
+ </p>
153
+ {busy ? (
154
+ <Loader2 className="mx-auto h-5 w-5 animate-spin" />
155
+ ) : (
156
+ <Button className="min-h-11" onClick={() => generate.mutate()}>
157
+ Build the report
158
+ </Button>
159
+ )}
160
+ </div>
161
+ ) : (
162
+ <ReportBody
163
+ report={report}
164
+ sessionId={sessionId}
165
+ mode={mode.mode}
166
+ busy={busy}
167
+ onRefresh={() => generate.mutate()}
168
+ onOpen={setOpen}
169
+ />
170
+ )}
171
+ </div>
172
+ <EvidenceSheet
173
+ evidence={open}
174
+ stale={!!open?.sha && !!report?.head && open.sha !== report.head}
175
+ onClose={() => setOpen(null)}
176
+ />
177
+ </div>
178
+ );
179
+ }
180
+
181
+ function ReportBody({
182
+ report,
183
+ sessionId,
184
+ mode,
185
+ busy,
186
+ onRefresh,
187
+ onOpen,
188
+ }: {
189
+ report: HealthReport;
190
+ sessionId: string;
191
+ mode: string;
192
+ busy: boolean;
193
+ onRefresh: () => void;
194
+ onOpen: (e: EvidenceRef) => void;
195
+ }) {
196
+ const byId = new Map(report.evidence.map((e) => [e.id, e]));
197
+ const chips = (ids: string[]) =>
198
+ ids
199
+ .map((id) => byId.get(id))
200
+ .filter((e): e is EvidenceRef => !!e)
201
+ .map((e) => (
202
+ <button
203
+ key={e.id}
204
+ type="button"
205
+ onClick={() => onOpen(e)}
206
+ className="bg-secondary text-muted-foreground hover:text-foreground relative max-w-full truncate rounded-md px-2 py-0.5 text-[11px] before:absolute before:inset-x-0 before:-inset-y-3"
207
+ >
208
+ {e.label}
209
+ </button>
210
+ ));
211
+
212
+ return (
213
+ <>
214
+ {report.stale && (
215
+ <p className="rounded-lg bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
216
+ New work was pushed since this report.
217
+ </p>
218
+ )}
219
+ <section className="bg-card space-y-4 rounded-xl p-4 shadow-sm">
220
+ <EstimateBar estimate={report.estimate} />
221
+ <p className="text-base font-medium">{report.headline}</p>
222
+ <ul className="space-y-3">
223
+ {report.lines.map((l) => {
224
+ const t = TONES[l.tone];
225
+ return (
226
+ <li key={l.id} className="flex gap-3">
227
+ <t.icon
228
+ className={`mt-0.5 h-5 w-5 shrink-0 ${t.cls}`}
229
+ aria-label={t.word}
230
+ />
231
+ <div className="min-w-0 flex-1 space-y-1.5">
232
+ <p className="text-sm">{l.text}</p>
233
+ <div className="flex flex-wrap gap-1.5">
234
+ {chips(l.evidence)}
235
+ </div>
236
+ </div>
237
+ </li>
238
+ );
239
+ })}
240
+ </ul>
241
+ </section>
242
+
243
+ {report.recommendation && (
244
+ <section className="bg-card space-y-2 rounded-xl p-4 shadow-sm">
245
+ <h3 className="text-sm font-semibold">Analyst recommendation</h3>
246
+ <p className="text-sm">{report.recommendation.text}</p>
247
+ <div className="flex flex-wrap gap-1.5">
248
+ {chips(report.recommendation.evidence)}
249
+ </div>
250
+ </section>
251
+ )}
252
+
253
+ {report.draft && mode !== "manual" && (
254
+ <DraftCard
255
+ key={`${report.draft.source}:${report.draft.id}`}
256
+ sessionId={sessionId}
257
+ draft={report.draft}
258
+ />
259
+ )}
260
+
261
+ <footer className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
262
+ <span>
263
+ Built from {report.evidence.length} item
264
+ {report.evidence.length === 1 ? "" : "s"} · {when(report.createdAt)}
265
+ {report.source === "model" && report.model
266
+ ? ` · ${report.model}`
267
+ : " · no model"}
268
+ </span>
269
+ {report.dropped > 0 && (
270
+ <span>
271
+ {report.dropped} unsupported claim{report.dropped === 1 ? "" : "s"}{" "}
272
+ left out
273
+ </span>
274
+ )}
275
+ <Button
276
+ variant="ghost"
277
+ className="ml-auto min-h-11"
278
+ disabled={busy}
279
+ onClick={onRefresh}
280
+ >
281
+ <RefreshCw
282
+ className={`mr-1.5 h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`}
283
+ />
284
+ Refresh
285
+ </Button>
286
+ </footer>
287
+ </>
288
+ );
289
+ }
@@ -27,6 +27,7 @@ import {
27
27
  RotateCcw,
28
28
  Sparkles,
29
29
  Trash2,
30
+ Eye,
30
31
  } from "lucide-react";
31
32
  import type { Session } from "@/lib/db";
32
33
  import { selectionActions } from "@/stores/sessionSelection";
@@ -51,6 +52,7 @@ import { useRowContext } from "./RowContext";
51
52
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
52
53
  import { shareUiActions } from "@/stores/shareUi";
53
54
  import { exportUiActions } from "@/stores/exportUi";
55
+ import { analystUiActions } from "@/stores/analystUi";
54
56
  import { copySessionLink } from "@/hooks/useCopyToClipboard";
55
57
  import { useLinkedHostNames } from "@/data/hosts";
56
58
  import { remoteBlocks, remoteNote } from "@/lib/hosts/remote-menu";
@@ -202,6 +204,10 @@ export function SessionRowMenu({
202
204
  return item("Export for AI…", Download, () =>
203
205
  exportUiActions.open(session.id, session.name)
204
206
  );
207
+ case "add-analyst":
208
+ return item("Add analyst…", Eye, () =>
209
+ analystUiActions.open(session.id, session.name)
210
+ );
205
211
  case "fork":
206
212
  return item("Fork session", Copy, () => ctx.onFork(session.id));
207
213
  case "fresh-start":
@@ -18,6 +18,7 @@ import { RoleModelSelect } from "@/components/Models/RoleModelSelect";
18
18
  import { useCrewQuery, useSwitchSessionModel } from "@/data/models";
19
19
  import { useLaunchSession } from "@/data/sessions";
20
20
  import { usePanes } from "@/contexts/PaneContext";
21
+ import { analystUiActions } from "@/stores/analystUi";
21
22
  import type { Session } from "@/lib/db";
22
23
  import {
23
24
  AGENT_ROLES,
@@ -55,6 +56,12 @@ export function RolePill({
55
56
  const others = crew.data?.members.filter((m) => m.id !== session.id) ?? [];
56
57
 
57
58
  const add = async (next: (typeof AGENT_ROLES)[number]) => {
59
+ // A chat's analyst is paired with it, to review each turn.
60
+ if (next === "analyst" && session.view === "chat") {
61
+ setOpen(false);
62
+ analystUiActions.open(session.id, session.name, paneId ?? null);
63
+ return;
64
+ }
58
65
  try {
59
66
  const { session: made } = await launch.mutateAsync({
60
67
  id: uuid(),
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Repeat } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { StatusPill } from "@/components/ui/status-pill";
14
+ import { useLoopSummaries, useTaskLoop } from "@/data/loops";
15
+ import { LoopSetup } from "./LoopSetup";
16
+ import { LoopView } from "./LoopView";
17
+
18
+ function LoopBody({ taskId }: { taskId: string }) {
19
+ const q = useTaskLoop(taskId);
20
+ const [setup, setSetup] = useState(false);
21
+ if (q.isPending)
22
+ return <p className="text-muted-foreground text-sm">Loading…</p>;
23
+ if (q.isError)
24
+ return <p className="text-sm text-red-500">{q.error.message}</p>;
25
+ const view = q.data;
26
+ const live = view?.loop.state === "running";
27
+ if (!view || setup) {
28
+ return <LoopSetup taskId={taskId} onStarted={() => setSetup(false)} />;
29
+ }
30
+ return (
31
+ <div className="space-y-4">
32
+ <LoopView taskId={taskId} view={view} />
33
+ {!live && (
34
+ <Button
35
+ className="h-11 w-full md:h-9"
36
+ variant="outline"
37
+ onClick={() => setSetup(true)}
38
+ >
39
+ Start a new loop
40
+ </Button>
41
+ )}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ // The pill and button on a task row: the analyst loop for this task.
47
+ export function LoopButton({ taskId, name }: { taskId: string; name: string }) {
48
+ const [open, setOpen] = useState(false);
49
+ const summary = useLoopSummaries().data?.[taskId];
50
+ const live = summary?.state === "running";
51
+ return (
52
+ <>
53
+ {live && summary && (
54
+ <StatusPill tone="amber">
55
+ Loop {summary.cycle}/{summary.maxCycles} · {summary.met}/
56
+ {summary.total}
57
+ </StatusPill>
58
+ )}
59
+ <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
60
+ <Repeat className="h-3.5 w-3.5" />
61
+ Loop
62
+ </Button>
63
+ <Dialog open={open} onOpenChange={setOpen}>
64
+ <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
65
+ <DialogHeader>
66
+ <DialogTitle>Analyst loop</DialogTitle>
67
+ <DialogDescription className="truncate">{name}</DialogDescription>
68
+ </DialogHeader>
69
+ {open && <LoopBody taskId={taskId} />}
70
+ </DialogContent>
71
+ </Dialog>
72
+ </>
73
+ );
74
+ }