@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,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
|
+
};
|
|
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
|
|
|
21
21
|
import { SimulatorIndicator } from "@/components/Simulator";
|
|
22
22
|
import { PlanReview } from "./PlanReview";
|
|
23
23
|
import { MoveTaskButton } from "./MoveTaskButton";
|
|
24
|
+
import { LoopButton } from "./Loop/LoopButton";
|
|
24
25
|
import { ResumeOnModel } from "./ResumeOnModel";
|
|
25
26
|
import { TASK_STATE as STATE } from "./task-state-labels";
|
|
26
27
|
import { formatTokens } from "@/components/Usage/format";
|
|
@@ -119,6 +120,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
119
120
|
)}
|
|
120
121
|
{!task.hostId && <SimulatorIndicator sessionId={task.id} />}
|
|
121
122
|
<MoveTaskButton task={task} onError={setMoveError} />
|
|
123
|
+
{!task.hostId && <LoopButton taskId={task.id} name={task.name} />}
|
|
122
124
|
{!task.hostId && <ResumeOnModel task={task} />}
|
|
123
125
|
<span className="flex-1" />
|
|
124
126
|
{confirmDrop ? (
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ModeChooser } from "@/components/Report/ModeChooser";
|
|
4
|
+
import { useSetWorkspaceMode, useWorkspaceMode } from "@/data/reports";
|
|
5
|
+
|
|
6
|
+
// A workspace's default operating mode; a task can override it.
|
|
7
|
+
export function WorkspaceModeForm({ workspaceId }: { workspaceId: string }) {
|
|
8
|
+
const { data: mode } = useWorkspaceMode(workspaceId);
|
|
9
|
+
const set = useSetWorkspaceMode(workspaceId);
|
|
10
|
+
if (!mode) return null;
|
|
11
|
+
return (
|
|
12
|
+
<div className="space-y-1.5">
|
|
13
|
+
<h4 className="text-sm font-medium">Operating mode</h4>
|
|
14
|
+
<ModeChooser
|
|
15
|
+
mode={mode}
|
|
16
|
+
onChange={(m) => set.mutate(m)}
|
|
17
|
+
disabled={set.isPending}
|
|
18
|
+
note="New tasks start in this mode; any task can change its own."
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -9,6 +9,7 @@ import { McpServersForm } from "./McpServersForm";
|
|
|
9
9
|
import { HqFolderForm } from "./HqFolderForm";
|
|
10
10
|
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
11
11
|
import { StallForm } from "./StallForm";
|
|
12
|
+
import { WorkspaceModeForm } from "./WorkspaceModeForm";
|
|
12
13
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
13
14
|
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
14
15
|
|
|
@@ -46,6 +47,7 @@ export function WorkspaceSettings() {
|
|
|
46
47
|
key={`${w.id}:${w.max_running_tasks ?? "off"}`}
|
|
47
48
|
workspace={w}
|
|
48
49
|
/>
|
|
50
|
+
<WorkspaceModeForm workspaceId={w.id} />
|
|
49
51
|
<RulesForm workspaceId={w.id} />
|
|
50
52
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
51
53
|
<McpServersForm workspace={w} />
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface SegmentedOption<T extends string> {
|
|
6
|
+
value: T;
|
|
7
|
+
label: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One choice out of a few, side by side. Radio semantics, 44px targets. */
|
|
12
|
+
export function Segmented<T extends string>({
|
|
13
|
+
value,
|
|
14
|
+
options,
|
|
15
|
+
onChange,
|
|
16
|
+
label,
|
|
17
|
+
disabled,
|
|
18
|
+
}: {
|
|
19
|
+
value: T;
|
|
20
|
+
options: SegmentedOption<T>[];
|
|
21
|
+
onChange: (v: T) => void;
|
|
22
|
+
label: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
role="radiogroup"
|
|
28
|
+
aria-label={label}
|
|
29
|
+
className="bg-muted/60 grid auto-cols-fr grid-flow-col gap-1 rounded-lg p-1"
|
|
30
|
+
>
|
|
31
|
+
{options.map((o) => {
|
|
32
|
+
const on = o.value === value;
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
key={o.value}
|
|
36
|
+
type="button"
|
|
37
|
+
role="radio"
|
|
38
|
+
aria-checked={on}
|
|
39
|
+
title={o.hint}
|
|
40
|
+
disabled={disabled}
|
|
41
|
+
onClick={() => !on && onChange(o.value)}
|
|
42
|
+
className={cn(
|
|
43
|
+
"min-h-11 rounded-md px-2 text-sm font-medium transition-colors disabled:opacity-50",
|
|
44
|
+
on
|
|
45
|
+
? "bg-card text-foreground shadow-sm"
|
|
46
|
+
: "text-muted-foreground hover:text-foreground"
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{o.label}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -4,6 +4,9 @@ import { cn } from "@/lib/utils";
|
|
|
4
4
|
const TONES = {
|
|
5
5
|
amber: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
|
6
6
|
muted: "bg-muted text-muted-foreground",
|
|
7
|
+
green: "bg-green-500/15 text-green-700 dark:text-green-400",
|
|
8
|
+
primary: "bg-primary/15 text-primary",
|
|
9
|
+
red: "bg-red-500/15 text-red-700 dark:text-red-400",
|
|
7
10
|
} as const;
|
|
8
11
|
|
|
9
12
|
/** A small coloured pill for a status. A button when it has an onClick. */
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
loadPaneState,
|
|
24
24
|
showDraft,
|
|
25
25
|
MAX_PANES,
|
|
26
|
+
openBeside as openBesideIn,
|
|
27
|
+
type OpenSession,
|
|
26
28
|
} from "@/lib/panes";
|
|
27
29
|
import { useViewport } from "@/hooks/useViewport";
|
|
28
30
|
|
|
29
31
|
export type ViewMode =
|
|
30
|
-
"terminal" | "files" | "git" | "workers" | "log" | "artifacts";
|
|
32
|
+
"terminal" | "files" | "git" | "workers" | "log" | "artifacts" | "report";
|
|
31
33
|
|
|
32
34
|
export interface PaneViewState {
|
|
33
35
|
viewMode: ViewMode;
|
|
@@ -59,6 +61,8 @@ interface PaneContextValue {
|
|
|
59
61
|
hostId?: string
|
|
60
62
|
) => void;
|
|
61
63
|
detachSession: (paneId: string) => void;
|
|
64
|
+
// Shows a session beside a pane: its pane if open, else a split or a tab.
|
|
65
|
+
openBeside: (paneId: string, open: OpenSession) => void;
|
|
62
66
|
openDraft: (paneId: string, draftId: string) => void;
|
|
63
67
|
getPaneData: (paneId: string) => PaneData;
|
|
64
68
|
getActiveTab: (paneId: string) => TabData | null;
|
|
@@ -270,6 +274,21 @@ export function PaneProvider({ children }: { children: ReactNode }) {
|
|
|
270
274
|
[]
|
|
271
275
|
);
|
|
272
276
|
|
|
277
|
+
// A phone shows one pane: the session takes this one's place.
|
|
278
|
+
const openBeside = useCallback(
|
|
279
|
+
(paneId: string, open: OpenSession) => {
|
|
280
|
+
if (isMobile)
|
|
281
|
+
attachSession(
|
|
282
|
+
paneId,
|
|
283
|
+
open.sessionId,
|
|
284
|
+
open.tmux,
|
|
285
|
+
open.hostId ?? undefined
|
|
286
|
+
);
|
|
287
|
+
else setState((prev) => openBesideIn(prev, paneId, open));
|
|
288
|
+
},
|
|
289
|
+
[isMobile, attachSession]
|
|
290
|
+
);
|
|
291
|
+
|
|
273
292
|
// Detach session from active tab
|
|
274
293
|
const detachSession = useCallback((paneId: string) => {
|
|
275
294
|
setState((prev) => {
|
|
@@ -393,6 +412,7 @@ export function PaneProvider({ children }: { children: ReactNode }) {
|
|
|
393
412
|
switchTab,
|
|
394
413
|
attachSession,
|
|
395
414
|
detachSession,
|
|
415
|
+
openBeside,
|
|
396
416
|
openDraft,
|
|
397
417
|
getPaneData,
|
|
398
418
|
getActiveTab,
|