@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.
Files changed (150) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/ports/route.test.ts +43 -0
  3. package/app/api/bus/ports/route.ts +35 -0
  4. package/app/api/loops/route.ts +17 -0
  5. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  6. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  7. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  8. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  9. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  10. package/app/api/tasks/[id]/loop/route.ts +72 -0
  11. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  12. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  13. package/app/api/tasks/[id]/mode/route.ts +29 -0
  14. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  15. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  16. package/app/api/tasks/[id]/report/route.ts +39 -0
  17. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  18. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  19. package/app/page.tsx +8 -0
  20. package/components/Analyst/PairChip.tsx +131 -0
  21. package/components/Analyst/PairSwitch.tsx +76 -0
  22. package/components/Analyst/ReviewCard.tsx +272 -0
  23. package/components/Analyst/SignalChips.tsx +39 -0
  24. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  25. package/components/Analyst/cardState.test.ts +109 -0
  26. package/components/Analyst/cardState.ts +95 -0
  27. package/components/Analyst/pair.ts +81 -0
  28. package/components/Chat/Code.tsx +3 -0
  29. package/components/Chat/Messages.tsx +8 -1
  30. package/components/Chat/TurnCopy.tsx +84 -0
  31. package/components/Chat/itemContext.ts +14 -0
  32. package/components/Draft/DraftChips.tsx +10 -0
  33. package/components/Draft/DraftPanel.tsx +7 -0
  34. package/components/Pane/DesktopTabBar.tsx +6 -0
  35. package/components/Pane/MobileTabBar.tsx +17 -0
  36. package/components/Pane/MobileViewPicker.tsx +4 -0
  37. package/components/Pane/PaneMenu.test.ts +21 -0
  38. package/components/Pane/PaneMenu.tsx +16 -0
  39. package/components/Pane/PaneViewToggle.tsx +12 -0
  40. package/components/Pane/index.tsx +19 -0
  41. package/components/Report/DraftCard.tsx +89 -0
  42. package/components/Report/EstimateBar.tsx +51 -0
  43. package/components/Report/EvidenceSheet.tsx +98 -0
  44. package/components/Report/ModeChooser.tsx +43 -0
  45. package/components/Report/ReportView.tsx +289 -0
  46. package/components/SessionList/GroupShelves.tsx +57 -22
  47. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  48. package/components/SessionList/SessionRow.tsx +12 -1
  49. package/components/SessionList/SessionRowMenu.tsx +6 -0
  50. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  51. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  52. package/components/SessionList/index.tsx +11 -2
  53. package/components/SessionList/useGroupDrag.ts +83 -0
  54. package/components/Sessions/RolePill.tsx +7 -0
  55. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  56. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  57. package/components/Tasks/Loop/LoopView.tsx +350 -0
  58. package/components/Tasks/Loop/labels.ts +17 -0
  59. package/components/Tasks/TaskRow.tsx +2 -0
  60. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  61. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  62. package/components/ui/segmented.tsx +55 -0
  63. package/components/ui/status-pill.tsx +3 -0
  64. package/contexts/PaneContext.tsx +21 -1
  65. package/data/exports/queries.ts +11 -0
  66. package/data/loops/index.ts +3 -0
  67. package/data/loops/keys.ts +4 -0
  68. package/data/loops/mutations.ts +68 -0
  69. package/data/loops/queries.ts +40 -0
  70. package/data/push/topics.ts +6 -0
  71. package/data/reports/index.ts +115 -0
  72. package/lib/agents/brief.ts +1 -1
  73. package/lib/analyst/forward.ts +1 -1
  74. package/lib/db/changes.ts +7 -0
  75. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  76. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  77. package/lib/done/index.ts +2 -0
  78. package/lib/drafts.ts +2 -0
  79. package/lib/export/markdown.ts +7 -1
  80. package/lib/export/turn.ts +48 -0
  81. package/lib/health/config.ts +2 -0
  82. package/lib/health/env/classes.ts +14 -1
  83. package/lib/health/port-wait-tick.ts +222 -0
  84. package/lib/health/port-wait.ts +160 -0
  85. package/lib/health/tick.ts +9 -2
  86. package/lib/health/types.ts +2 -0
  87. package/lib/loops/analyse.ts +46 -0
  88. package/lib/loops/analyst-prompt.ts +203 -0
  89. package/lib/loops/checkCriteria.ts +114 -0
  90. package/lib/loops/compactEvidence.ts +140 -0
  91. package/lib/loops/controls.ts +317 -0
  92. package/lib/loops/decideStop.ts +113 -0
  93. package/lib/loops/deps.ts +128 -0
  94. package/lib/loops/diffMap.ts +39 -0
  95. package/lib/loops/draftCriteria.ts +71 -0
  96. package/lib/loops/gatherEvidence.ts +265 -0
  97. package/lib/loops/hooks.ts +40 -0
  98. package/lib/loops/index.ts +7 -0
  99. package/lib/loops/route.ts +83 -0
  100. package/lib/loops/runCycle.ts +323 -0
  101. package/lib/loops/screenInstruction.ts +59 -0
  102. package/lib/loops/sendInstruction.ts +83 -0
  103. package/lib/loops/snapshot.ts +38 -0
  104. package/lib/loops/stopLoop.ts +86 -0
  105. package/lib/loops/store.ts +481 -0
  106. package/lib/loops/summary.ts +27 -0
  107. package/lib/loops/tickLoops.ts +141 -0
  108. package/lib/loops/types.ts +223 -0
  109. package/lib/loops/world.test-helpers.ts +223 -0
  110. package/lib/models/plan-step.ts +4 -1
  111. package/lib/orchestrator/digest.ts +2 -1
  112. package/lib/orchestrator/loop-guard.ts +5 -2
  113. package/lib/orchestrator/pr-wake.ts +4 -0
  114. package/lib/orchestrator/watcher.ts +8 -1
  115. package/lib/panes.ts +52 -0
  116. package/lib/plans/acceptance.ts +23 -0
  117. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  118. package/lib/ports/ensurePorts.ts +138 -0
  119. package/lib/ports/findHardcodedPorts.ts +116 -0
  120. package/lib/ports/index.ts +15 -0
  121. package/lib/ports/listListeners.ts +134 -0
  122. package/lib/ports/reassignPort.ts +45 -0
  123. package/lib/ports/types.ts +62 -0
  124. package/lib/ports/whoHolds.ts +155 -0
  125. package/lib/project-config/brief.ts +4 -2
  126. package/lib/project-config/session.ts +19 -3
  127. package/lib/reports/drafts.ts +87 -0
  128. package/lib/reports/end-hook.ts +17 -0
  129. package/lib/reports/estimate.ts +107 -0
  130. package/lib/reports/evidence/build.ts +193 -0
  131. package/lib/reports/evidence/gather.ts +229 -0
  132. package/lib/reports/fixtures.ts +17 -0
  133. package/lib/reports/generate.ts +159 -0
  134. package/lib/reports/hooks.ts +30 -0
  135. package/lib/reports/mode.ts +65 -0
  136. package/lib/reports/model.ts +115 -0
  137. package/lib/reports/read.ts +26 -0
  138. package/lib/reports/store.ts +180 -0
  139. package/lib/reports/template.ts +117 -0
  140. package/lib/reports/types.ts +135 -0
  141. package/lib/reports/validate.ts +142 -0
  142. package/lib/sidebar/groupDrop.ts +36 -0
  143. package/lib/sidebar/row-menu.ts +9 -0
  144. package/lib/tasks/contract/routes.ts +5 -0
  145. package/lib/tasks/contract/types.ts +3 -0
  146. package/lib/tasks/finish.ts +2 -0
  147. package/lib/tasks/move-carry.ts +1 -0
  148. package/lib/windows-ports.ts +36 -18
  149. package/package.json +2 -2
  150. 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
+ };