@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,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
|
+
}
|