@saadnvd1/agent-os 0.3.723 → 0.3.731
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/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -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 +8 -1
- package/components/Chat/TurnCopy.tsx +84 -0
- 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/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -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/exports/queries.ts +11 -0
- 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/agents/brief.ts +1 -1
- 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/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.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/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- 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/groupDrop.ts +36 -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/lib/windows-ports.ts +36 -18
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type DragEvent } from "react";
|
|
2
|
+
import { useAssignGroup } from "@/data/session-groups";
|
|
3
|
+
import {
|
|
4
|
+
DRAG_TYPE,
|
|
5
|
+
dragging,
|
|
6
|
+
dropOutcome,
|
|
7
|
+
setDragging,
|
|
8
|
+
type DraggedSession,
|
|
9
|
+
type DropTarget,
|
|
10
|
+
} from "@/lib/sidebar/groupDrop";
|
|
11
|
+
|
|
12
|
+
// A session row you can pick up. Whole-row drag; the row's own click and
|
|
13
|
+
// menus are untouched, and touch keeps using Move to group.
|
|
14
|
+
export function useRowDrag(dragged: DraggedSession, enabled: boolean) {
|
|
15
|
+
return {
|
|
16
|
+
draggable: enabled,
|
|
17
|
+
onDragStart: (e: DragEvent) => {
|
|
18
|
+
setDragging(dragged);
|
|
19
|
+
e.dataTransfer.effectAllowed = "move";
|
|
20
|
+
e.dataTransfer.setData(DRAG_TYPE, dragged.id);
|
|
21
|
+
},
|
|
22
|
+
onDragEnd: () => setDragging(null),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXPAND_AFTER_MS = 600;
|
|
27
|
+
|
|
28
|
+
// Where a dragged session can land. A group passes itself as the target; the
|
|
29
|
+
// list around the groups passes null, which takes the session out of its group.
|
|
30
|
+
export function useDropZone(
|
|
31
|
+
target: DropTarget | null,
|
|
32
|
+
// A folded group opens when a valid drag rests on it.
|
|
33
|
+
onRest?: () => void
|
|
34
|
+
) {
|
|
35
|
+
const assign = useAssignGroup();
|
|
36
|
+
const [over, setOver] = useState(false);
|
|
37
|
+
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
38
|
+
const stopRest = () => clearTimeout(timer.current);
|
|
39
|
+
useEffect(() => stopRest, []);
|
|
40
|
+
|
|
41
|
+
const outcome = () => {
|
|
42
|
+
const d = dragging();
|
|
43
|
+
return d ? { d, out: dropOutcome(d, target) } : null;
|
|
44
|
+
};
|
|
45
|
+
const leave = () => {
|
|
46
|
+
stopRest();
|
|
47
|
+
setOver(false);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
over,
|
|
52
|
+
props: {
|
|
53
|
+
onDragOver: (e: DragEvent) => {
|
|
54
|
+
const o = outcome();
|
|
55
|
+
if (!o?.out) return;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
e.dataTransfer.dropEffect = "move";
|
|
59
|
+
if (target) {
|
|
60
|
+
setOver(true);
|
|
61
|
+
if (onRest && !timer.current)
|
|
62
|
+
timer.current = setTimeout(onRest, EXPAND_AFTER_MS);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
onDragLeave: (e: DragEvent) => {
|
|
66
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
67
|
+
leave();
|
|
68
|
+
timer.current = undefined;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
onDrop: (e: DragEvent) => {
|
|
72
|
+
const o = outcome();
|
|
73
|
+
leave();
|
|
74
|
+
timer.current = undefined;
|
|
75
|
+
if (!o?.out) return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
setDragging(null);
|
|
79
|
+
assign.mutate({ ids: [o.d.id], groupId: o.out.groupId });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Plus, Sparkles, X } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Notice } from "@/components/ui/notice";
|
|
7
|
+
import { useDraftCriteria, useStartLoop } from "@/data/loops";
|
|
8
|
+
import { DEFAULT_LIMITS, LIMIT_BOUNDS, type Limits } from "@/lib/loops/types";
|
|
9
|
+
|
|
10
|
+
const FIELDS: {
|
|
11
|
+
key: "maxCycles" | "maxCostUsd" | "maxMinutes";
|
|
12
|
+
label: string;
|
|
13
|
+
unit: string;
|
|
14
|
+
}[] = [
|
|
15
|
+
{ key: "maxCycles", label: "Cycles", unit: "" },
|
|
16
|
+
{ key: "maxCostUsd", label: "Cost cap", unit: "$" },
|
|
17
|
+
{ key: "maxMinutes", label: "Time", unit: "min" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const input =
|
|
21
|
+
"bg-foreground/[0.05] focus-visible:ring-primary/40 min-h-11 w-full rounded-lg px-3 text-sm outline-none focus-visible:ring-[3px] md:min-h-9";
|
|
22
|
+
|
|
23
|
+
export function LoopSetup({
|
|
24
|
+
taskId,
|
|
25
|
+
onStarted,
|
|
26
|
+
}: {
|
|
27
|
+
taskId: string;
|
|
28
|
+
onStarted: () => void;
|
|
29
|
+
}) {
|
|
30
|
+
const draft = useDraftCriteria(taskId);
|
|
31
|
+
const start = useStartLoop(taskId);
|
|
32
|
+
const [criteria, setCriteria] = useState<string[]>([""]);
|
|
33
|
+
const [limits, setLimits] = useState<
|
|
34
|
+
Pick<Limits, "maxCycles" | "maxCostUsd" | "maxMinutes">
|
|
35
|
+
>({
|
|
36
|
+
maxCycles: DEFAULT_LIMITS.maxCycles,
|
|
37
|
+
maxCostUsd: DEFAULT_LIMITS.maxCostUsd,
|
|
38
|
+
maxMinutes: DEFAULT_LIMITS.maxMinutes,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
draft.mutate(undefined, {
|
|
43
|
+
onSuccess: (found) =>
|
|
44
|
+
found.length && setCriteria(found.map((c) => c.text)),
|
|
45
|
+
});
|
|
46
|
+
// Once, when the setup opens.
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const filled = criteria.map((c) => c.trim()).filter(Boolean);
|
|
51
|
+
const set = (i: number, text: string) =>
|
|
52
|
+
setCriteria((all) => all.map((c, j) => (j === i ? text : c)));
|
|
53
|
+
const error =
|
|
54
|
+
start.error?.message ??
|
|
55
|
+
(draft.isError ? "Couldn't draft the checklist" : null);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="space-y-4">
|
|
59
|
+
<p className="text-muted-foreground text-sm">
|
|
60
|
+
After each turn the analyst reads the real diff and test output, marks
|
|
61
|
+
this checklist with evidence, and sends the coding agent its next
|
|
62
|
+
instruction. It never merges or publishes.
|
|
63
|
+
</p>
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<div className="flex items-center justify-between">
|
|
66
|
+
<h3 className="text-sm font-medium">Acceptance criteria</h3>
|
|
67
|
+
{draft.isPending && (
|
|
68
|
+
<span className="text-muted-foreground text-xs">Drafting…</span>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
{criteria.map((text, i) => (
|
|
72
|
+
<div key={i} className="flex items-center gap-2">
|
|
73
|
+
<input
|
|
74
|
+
value={text}
|
|
75
|
+
onChange={(e) => set(i, e.target.value)}
|
|
76
|
+
placeholder="Something the analyst can check"
|
|
77
|
+
aria-label={`Criterion ${i + 1}`}
|
|
78
|
+
className={input}
|
|
79
|
+
/>
|
|
80
|
+
<Button
|
|
81
|
+
size="icon"
|
|
82
|
+
variant="ghost"
|
|
83
|
+
className="size-11 md:size-9"
|
|
84
|
+
aria-label="Remove criterion"
|
|
85
|
+
onClick={() =>
|
|
86
|
+
setCriteria((all) =>
|
|
87
|
+
all.length > 1 ? all.filter((_, j) => j !== i) : [""]
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
<X />
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
))}
|
|
95
|
+
<Button
|
|
96
|
+
size="sm"
|
|
97
|
+
variant="ghost"
|
|
98
|
+
className="h-11 md:h-8"
|
|
99
|
+
onClick={() => setCriteria((all) => [...all, ""])}
|
|
100
|
+
>
|
|
101
|
+
<Plus /> Add criterion
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="grid grid-cols-3 gap-2">
|
|
105
|
+
{FIELDS.map((f) => (
|
|
106
|
+
<label key={f.key} className="space-y-1 text-xs">
|
|
107
|
+
<span className="text-muted-foreground">
|
|
108
|
+
{f.label}
|
|
109
|
+
{f.unit && ` (${f.unit})`}
|
|
110
|
+
</span>
|
|
111
|
+
<input
|
|
112
|
+
type="number"
|
|
113
|
+
inputMode="numeric"
|
|
114
|
+
min={LIMIT_BOUNDS[f.key][0]}
|
|
115
|
+
max={LIMIT_BOUNDS[f.key][1]}
|
|
116
|
+
value={limits[f.key]}
|
|
117
|
+
onChange={(e) =>
|
|
118
|
+
setLimits((l) => ({ ...l, [f.key]: Number(e.target.value) }))
|
|
119
|
+
}
|
|
120
|
+
className={input}
|
|
121
|
+
/>
|
|
122
|
+
</label>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
{error && <Notice tone="warning">{error}</Notice>}
|
|
126
|
+
<Button
|
|
127
|
+
className="h-11 w-full md:h-9"
|
|
128
|
+
disabled={!filled.length || start.isPending}
|
|
129
|
+
onClick={() =>
|
|
130
|
+
start.mutate(
|
|
131
|
+
{
|
|
132
|
+
criteria: filled.map((text) => ({
|
|
133
|
+
text,
|
|
134
|
+
source: "user" as const,
|
|
135
|
+
})),
|
|
136
|
+
limits,
|
|
137
|
+
},
|
|
138
|
+
{ onSuccess: onStarted }
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
<Sparkles /> {start.isPending ? "Starting…" : "Start loop"}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Check,
|
|
6
|
+
CircleDashed,
|
|
7
|
+
CircleX,
|
|
8
|
+
Pause,
|
|
9
|
+
Play,
|
|
10
|
+
Send,
|
|
11
|
+
SkipForward,
|
|
12
|
+
Square,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import { Button } from "@/components/ui/button";
|
|
15
|
+
import { Notice } from "@/components/ui/notice";
|
|
16
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
17
|
+
import {
|
|
18
|
+
useEditLoop,
|
|
19
|
+
useNextInstruction,
|
|
20
|
+
useRunLoop,
|
|
21
|
+
useStopLoop,
|
|
22
|
+
} from "@/data/loops";
|
|
23
|
+
import type { Criterion, CycleRow, LoopView as View } from "@/lib/loops/types";
|
|
24
|
+
import { STOP_LABEL, minutesText } from "./labels";
|
|
25
|
+
|
|
26
|
+
const CYCLE_LABEL: Record<CycleRow["state"], string> = {
|
|
27
|
+
claimed: "Gathering evidence",
|
|
28
|
+
decided: "Analyst decided",
|
|
29
|
+
counting: "Sending soon",
|
|
30
|
+
sending: "Sending",
|
|
31
|
+
sent: "Sent",
|
|
32
|
+
skipped: "Nothing to send",
|
|
33
|
+
stopped: "Stopped",
|
|
34
|
+
superseded: "You took over",
|
|
35
|
+
error: "Error",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function Stat({ label, value }: { label: string; value: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="bg-foreground/[0.04] min-w-0 rounded-lg px-3 py-2">
|
|
41
|
+
<p className="text-muted-foreground text-[11px]">{label}</p>
|
|
42
|
+
<p className="truncate text-sm font-semibold tabular-nums">{value}</p>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function CriterionRow({
|
|
48
|
+
c,
|
|
49
|
+
canEdit,
|
|
50
|
+
onWaive,
|
|
51
|
+
}: {
|
|
52
|
+
c: Criterion;
|
|
53
|
+
canEdit: boolean;
|
|
54
|
+
onWaive: () => void;
|
|
55
|
+
}) {
|
|
56
|
+
const Icon =
|
|
57
|
+
c.status === "met"
|
|
58
|
+
? Check
|
|
59
|
+
: c.status === "failing"
|
|
60
|
+
? CircleX
|
|
61
|
+
: CircleDashed;
|
|
62
|
+
return (
|
|
63
|
+
<li className="flex items-start gap-2 text-sm">
|
|
64
|
+
<Icon
|
|
65
|
+
className={`mt-0.5 h-4 w-4 shrink-0 ${
|
|
66
|
+
c.status === "met"
|
|
67
|
+
? "text-emerald-500"
|
|
68
|
+
: c.status === "failing"
|
|
69
|
+
? "text-red-500"
|
|
70
|
+
: "text-muted-foreground"
|
|
71
|
+
}`}
|
|
72
|
+
/>
|
|
73
|
+
<div className="min-w-0 flex-1">
|
|
74
|
+
<p
|
|
75
|
+
className={
|
|
76
|
+
c.status === "waived" ? "text-muted-foreground line-through" : ""
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
{c.text}
|
|
80
|
+
</p>
|
|
81
|
+
{c.evidence && (
|
|
82
|
+
<p className="text-muted-foreground font-mono text-[11px] break-words">
|
|
83
|
+
{c.evidence.kind}: {c.evidence.ref}
|
|
84
|
+
{c.evidence.note && ` · ${c.evidence.note}`}
|
|
85
|
+
{c.status === "met" && !c.verified && " · unverified"}
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
{canEdit && c.status !== "met" && c.status !== "waived" && (
|
|
90
|
+
<Button
|
|
91
|
+
size="sm"
|
|
92
|
+
variant="ghost"
|
|
93
|
+
className="h-11 md:h-8"
|
|
94
|
+
onClick={onWaive}
|
|
95
|
+
>
|
|
96
|
+
Waive
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
</li>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function NextBar({ taskId, cycle }: { taskId: string; cycle: CycleRow }) {
|
|
104
|
+
const next = useNextInstruction(taskId);
|
|
105
|
+
const [now, setNow] = useState(() => Date.now());
|
|
106
|
+
const [text, setText] = useState(cycle.instruction ?? "");
|
|
107
|
+
const [editing, setEditing] = useState(false);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
110
|
+
return () => clearInterval(t);
|
|
111
|
+
}, []);
|
|
112
|
+
const left = cycle.sendAt
|
|
113
|
+
? Math.max(0, Math.ceil((cycle.sendAt - now) / 1000))
|
|
114
|
+
: null;
|
|
115
|
+
return (
|
|
116
|
+
<div className="bg-primary/10 space-y-2 rounded-xl p-3">
|
|
117
|
+
<div className="flex items-center justify-between gap-2">
|
|
118
|
+
<h3 className="text-sm font-medium">Next instruction</h3>
|
|
119
|
+
<span className="text-muted-foreground text-xs tabular-nums">
|
|
120
|
+
{left === null ? "held" : `sends in ${left}s`}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
{editing ? (
|
|
124
|
+
<textarea
|
|
125
|
+
value={text}
|
|
126
|
+
onChange={(e) => setText(e.target.value)}
|
|
127
|
+
rows={5}
|
|
128
|
+
aria-label="Next instruction"
|
|
129
|
+
className="bg-background/60 w-full rounded-lg p-2 text-sm outline-none"
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
<p className="text-sm whitespace-pre-wrap">{cycle.instruction}</p>
|
|
133
|
+
)}
|
|
134
|
+
{next.error && (
|
|
135
|
+
<p className="text-xs text-red-500">{next.error.message}</p>
|
|
136
|
+
)}
|
|
137
|
+
<div className="flex flex-wrap gap-2">
|
|
138
|
+
<Button
|
|
139
|
+
size="sm"
|
|
140
|
+
className="h-11 md:h-8"
|
|
141
|
+
disabled={next.isPending}
|
|
142
|
+
onClick={() =>
|
|
143
|
+
next.mutate({
|
|
144
|
+
action: "send",
|
|
145
|
+
instruction: editing ? text : undefined,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
<Send /> Send now
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
size="sm"
|
|
153
|
+
variant="outline"
|
|
154
|
+
className="h-11 md:h-8"
|
|
155
|
+
onClick={() => setEditing((e) => !e)}
|
|
156
|
+
>
|
|
157
|
+
{editing ? "Done editing" : "Edit"}
|
|
158
|
+
</Button>
|
|
159
|
+
<Button
|
|
160
|
+
size="sm"
|
|
161
|
+
variant="outline"
|
|
162
|
+
className="h-11 md:h-8"
|
|
163
|
+
onClick={() => next.mutate({ action: "hold" })}
|
|
164
|
+
>
|
|
165
|
+
<Pause /> Hold
|
|
166
|
+
</Button>
|
|
167
|
+
<Button
|
|
168
|
+
size="sm"
|
|
169
|
+
variant="outline"
|
|
170
|
+
className="h-11 md:h-8"
|
|
171
|
+
onClick={() => next.mutate({ action: "skip" })}
|
|
172
|
+
>
|
|
173
|
+
<SkipForward /> Skip
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function Cycle({ c }: { c: CycleRow }) {
|
|
181
|
+
const ev = c.evidence;
|
|
182
|
+
return (
|
|
183
|
+
<li className="bg-foreground/[0.03] space-y-1.5 rounded-xl p-3">
|
|
184
|
+
<div className="flex items-center justify-between gap-2">
|
|
185
|
+
<h4 className="text-sm font-medium">Cycle {c.n}</h4>
|
|
186
|
+
<StatusPill tone={c.state === "error" ? "amber" : "muted"}>
|
|
187
|
+
{CYCLE_LABEL[c.state]}
|
|
188
|
+
</StatusPill>
|
|
189
|
+
</div>
|
|
190
|
+
{ev && (
|
|
191
|
+
<div className="flex flex-wrap gap-1.5">
|
|
192
|
+
{ev.commands.map((cmd) => (
|
|
193
|
+
<StatusPill
|
|
194
|
+
key={cmd.name}
|
|
195
|
+
tone={cmd.exitCode === 0 ? "muted" : "amber"}
|
|
196
|
+
>
|
|
197
|
+
{cmd.name}{" "}
|
|
198
|
+
{cmd.skipped
|
|
199
|
+
? "skipped"
|
|
200
|
+
: cmd.exitCode === 0
|
|
201
|
+
? "passed"
|
|
202
|
+
: `${cmd.failed.length || cmd.errors.length} failing`}
|
|
203
|
+
</StatusPill>
|
|
204
|
+
))}
|
|
205
|
+
{!c.changed && <StatusPill tone="muted">no change</StatusPill>}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
{c.analysis?.summary && <p className="text-sm">{c.analysis.summary}</p>}
|
|
209
|
+
{c.analysis?.findings.map((f, i) => (
|
|
210
|
+
<p key={i} className="text-muted-foreground text-xs break-words">
|
|
211
|
+
{f.severity === "blocking" ? "Blocking" : "Minor"}: {f.text}
|
|
212
|
+
{f.file && (
|
|
213
|
+
<span className="font-mono">
|
|
214
|
+
{" "}
|
|
215
|
+
({f.file}
|
|
216
|
+
{f.line ? `:${f.line}` : ""})
|
|
217
|
+
</span>
|
|
218
|
+
)}
|
|
219
|
+
</p>
|
|
220
|
+
))}
|
|
221
|
+
{c.instructionSent && (
|
|
222
|
+
<p className="text-xs break-words">
|
|
223
|
+
<span className="text-muted-foreground">
|
|
224
|
+
{c.sender === "user" ? "You sent: " : "Sent: "}
|
|
225
|
+
</span>
|
|
226
|
+
{c.instructionSent}
|
|
227
|
+
</p>
|
|
228
|
+
)}
|
|
229
|
+
{!c.instructionSent && c.instruction && c.state !== "counting" && (
|
|
230
|
+
<p className="text-muted-foreground text-xs break-words">
|
|
231
|
+
Not sent: {c.instruction}
|
|
232
|
+
</p>
|
|
233
|
+
)}
|
|
234
|
+
{c.error && <p className="text-xs break-words text-red-500">{c.error}</p>}
|
|
235
|
+
</li>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function LoopView({ taskId, view }: { taskId: string; view: View }) {
|
|
240
|
+
const stop = useStopLoop(taskId);
|
|
241
|
+
const run = useRunLoop(taskId);
|
|
242
|
+
const edit = useEditLoop(taskId);
|
|
243
|
+
const { loop, criteria, cycles } = view;
|
|
244
|
+
const live = loop.state === "running";
|
|
245
|
+
const met = criteria.filter(
|
|
246
|
+
(c) => c.status === "met" || c.status === "waived"
|
|
247
|
+
).length;
|
|
248
|
+
const counting = cycles.find((c) => c.state === "counting" && c.instruction);
|
|
249
|
+
const limitStop = loop.stopReason?.startsWith("limit:");
|
|
250
|
+
const error =
|
|
251
|
+
stop.error?.message ?? run.error?.message ?? edit.error?.message;
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className="space-y-4">
|
|
255
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
256
|
+
<Stat label="Criteria" value={`${met}/${criteria.length}`} />
|
|
257
|
+
<Stat label="Cycles" value={`${cycles[0]?.n ?? 0}/${loop.maxCycles}`} />
|
|
258
|
+
<Stat
|
|
259
|
+
label="Spend"
|
|
260
|
+
value={`$${view.spentUsd.toFixed(2)} / $${loop.maxCostUsd}`}
|
|
261
|
+
/>
|
|
262
|
+
<Stat label="Elapsed" value={minutesText(view.elapsedMs)} />
|
|
263
|
+
</div>
|
|
264
|
+
{!live && loop.stopReason && (
|
|
265
|
+
<Notice
|
|
266
|
+
tone={
|
|
267
|
+
loop.stopReason === "done" || loop.stopReason === "stopped"
|
|
268
|
+
? "info"
|
|
269
|
+
: "warning"
|
|
270
|
+
}
|
|
271
|
+
action={
|
|
272
|
+
limitStop ? (
|
|
273
|
+
<Button
|
|
274
|
+
size="sm"
|
|
275
|
+
className="h-11 md:h-8"
|
|
276
|
+
disabled={edit.isPending}
|
|
277
|
+
onClick={() =>
|
|
278
|
+
edit.mutate({
|
|
279
|
+
extend: { cycles: 4, costUsd: 5, minutes: 60 },
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
>
|
|
283
|
+
Continue
|
|
284
|
+
</Button>
|
|
285
|
+
) : undefined
|
|
286
|
+
}
|
|
287
|
+
>
|
|
288
|
+
<p className="font-medium">{STOP_LABEL[loop.stopReason]}</p>
|
|
289
|
+
{loop.stopDetail && loop.stopReason !== "stopped" && (
|
|
290
|
+
<p className="text-muted-foreground">{loop.stopDetail}</p>
|
|
291
|
+
)}
|
|
292
|
+
</Notice>
|
|
293
|
+
)}
|
|
294
|
+
{live && view.holdReason && <Notice>{view.holdReason}</Notice>}
|
|
295
|
+
{live && counting && (
|
|
296
|
+
<NextBar key={counting.id} taskId={taskId} cycle={counting} />
|
|
297
|
+
)}
|
|
298
|
+
{error && <Notice tone="warning">{error}</Notice>}
|
|
299
|
+
<section className="space-y-2">
|
|
300
|
+
<h3 className="text-sm font-medium">Acceptance checklist</h3>
|
|
301
|
+
<ul className="space-y-2">
|
|
302
|
+
{criteria.map((c) => (
|
|
303
|
+
<CriterionRow
|
|
304
|
+
key={c.id}
|
|
305
|
+
c={c}
|
|
306
|
+
canEdit={live}
|
|
307
|
+
onWaive={() => edit.mutate({ waive: [c.id] })}
|
|
308
|
+
/>
|
|
309
|
+
))}
|
|
310
|
+
</ul>
|
|
311
|
+
</section>
|
|
312
|
+
{live && (
|
|
313
|
+
<div className="flex flex-wrap gap-2">
|
|
314
|
+
<Button
|
|
315
|
+
size="sm"
|
|
316
|
+
variant="outline"
|
|
317
|
+
className="h-11 md:h-8"
|
|
318
|
+
disabled={run.isPending}
|
|
319
|
+
onClick={() => run.mutate()}
|
|
320
|
+
>
|
|
321
|
+
<Play /> Run a cycle now
|
|
322
|
+
</Button>
|
|
323
|
+
<Button
|
|
324
|
+
size="sm"
|
|
325
|
+
variant="outline"
|
|
326
|
+
className="h-11 md:h-8"
|
|
327
|
+
disabled={stop.isPending}
|
|
328
|
+
onClick={() => stop.mutate()}
|
|
329
|
+
>
|
|
330
|
+
<Square /> Stop loop
|
|
331
|
+
</Button>
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
<section className="space-y-2">
|
|
335
|
+
<h3 className="text-sm font-medium">Cycles</h3>
|
|
336
|
+
{cycles.length === 0 ? (
|
|
337
|
+
<p className="text-muted-foreground text-sm">
|
|
338
|
+
The first cycle starts when the coding agent finishes a turn.
|
|
339
|
+
</p>
|
|
340
|
+
) : (
|
|
341
|
+
<ul className="space-y-2">
|
|
342
|
+
{cycles.map((c) => (
|
|
343
|
+
<Cycle key={c.id} c={c} />
|
|
344
|
+
))}
|
|
345
|
+
</ul>
|
|
346
|
+
)}
|
|
347
|
+
</section>
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StopReason } from "@/lib/loops/types";
|
|
2
|
+
|
|
3
|
+
export const STOP_LABEL: Record<StopReason, string> = {
|
|
4
|
+
stopped: "Stopped by you",
|
|
5
|
+
blocked: "Needs you",
|
|
6
|
+
done: "All criteria met",
|
|
7
|
+
repeat: "The same failure kept coming back",
|
|
8
|
+
no_progress: "No progress",
|
|
9
|
+
"limit:cycles": "Reached its cycle limit",
|
|
10
|
+
"limit:cost": "Reached its cost cap",
|
|
11
|
+
"limit:time": "Reached its time limit",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const minutesText = (ms: number) => {
|
|
15
|
+
const m = Math.max(0, Math.round(ms / 60000));
|
|
16
|
+
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
17
|
+
};
|