@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,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { Eye, Loader2 } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuSeparator,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from "@/components/ui/dropdown-menu";
|
|
13
|
+
import { usePanes } from "@/contexts/PaneContext";
|
|
14
|
+
import {
|
|
15
|
+
useAskAgain,
|
|
16
|
+
useEndAnalyst,
|
|
17
|
+
useReviewNow,
|
|
18
|
+
useSessionAnalyst,
|
|
19
|
+
useSetAnalystPaused,
|
|
20
|
+
} from "@/data/analyst";
|
|
21
|
+
import type { Session } from "@/lib/db";
|
|
22
|
+
import { cn } from "@/lib/utils";
|
|
23
|
+
import {
|
|
24
|
+
PAIR_STATUS_LABEL,
|
|
25
|
+
pairStatus,
|
|
26
|
+
partnerOf,
|
|
27
|
+
paneTmux,
|
|
28
|
+
type PairStatus,
|
|
29
|
+
} from "./pair";
|
|
30
|
+
|
|
31
|
+
const DOT: Record<PairStatus, string> = {
|
|
32
|
+
paused: "bg-muted-foreground/50",
|
|
33
|
+
reviewing: "bg-primary animate-pulse",
|
|
34
|
+
recommendation: "bg-primary",
|
|
35
|
+
watching: "bg-green-500",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const fail = (e: Error) => toast.error(e.message);
|
|
39
|
+
|
|
40
|
+
// A paired chat's link to the other side, with the pairing's controls.
|
|
41
|
+
// Nothing for a session that isn't paired.
|
|
42
|
+
export function PairChip({
|
|
43
|
+
session,
|
|
44
|
+
paneId,
|
|
45
|
+
}: {
|
|
46
|
+
session: Session | null | undefined;
|
|
47
|
+
paneId: string;
|
|
48
|
+
}) {
|
|
49
|
+
const { data: link } = useSessionAnalyst(
|
|
50
|
+
session?.view === "chat" ? session.id : undefined
|
|
51
|
+
);
|
|
52
|
+
const { openBeside } = usePanes();
|
|
53
|
+
const id = link?.id ?? "";
|
|
54
|
+
const review = useReviewNow(id);
|
|
55
|
+
const pause = useSetAnalystPaused(id);
|
|
56
|
+
const end = useEndAnalyst(id);
|
|
57
|
+
const again = useAskAgain(id);
|
|
58
|
+
if (!session || !link || link.endedAt) return null;
|
|
59
|
+
|
|
60
|
+
const status = pairStatus(link);
|
|
61
|
+
const isCoder = link.coder.id === session.id;
|
|
62
|
+
const partner = partnerOf(link, session.id);
|
|
63
|
+
const latest = link.recommendations[0];
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<DropdownMenu>
|
|
67
|
+
<DropdownMenuTrigger asChild>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={(e) => e.stopPropagation()}
|
|
71
|
+
title={PAIR_STATUS_LABEL[status]}
|
|
72
|
+
aria-label={`${isCoder ? "Analyst" : "Reviewing"} ${partner.name}: ${PAIR_STATUS_LABEL[status]}`}
|
|
73
|
+
className="ring-border/70 bg-muted/60 hover:bg-accent flex h-7 max-w-56 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs ring-1 ring-inset"
|
|
74
|
+
>
|
|
75
|
+
<Eye className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
76
|
+
<span className="truncate">
|
|
77
|
+
{isCoder ? "Analyst" : "Coding agent"}
|
|
78
|
+
</span>
|
|
79
|
+
<span
|
|
80
|
+
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[status])}
|
|
81
|
+
/>
|
|
82
|
+
</button>
|
|
83
|
+
</DropdownMenuTrigger>
|
|
84
|
+
<DropdownMenuContent align="start" className="w-60">
|
|
85
|
+
<DropdownMenuLabel className="space-y-0.5">
|
|
86
|
+
<div className="text-muted-foreground text-[11px] font-normal">
|
|
87
|
+
{isCoder ? "Reviewed by" : "Reviewing"} ·{" "}
|
|
88
|
+
{PAIR_STATUS_LABEL[status]}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="truncate text-sm">{partner.name}</div>
|
|
91
|
+
</DropdownMenuLabel>
|
|
92
|
+
<DropdownMenuItem
|
|
93
|
+
onSelect={() =>
|
|
94
|
+
openBeside(paneId, {
|
|
95
|
+
sessionId: partner.id,
|
|
96
|
+
tmux: paneTmux(partner),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
>
|
|
100
|
+
Open {isCoder ? "analyst" : "coding agent"} beside
|
|
101
|
+
</DropdownMenuItem>
|
|
102
|
+
<DropdownMenuItem
|
|
103
|
+
disabled={review.isPending || !!link.pendingDigestId}
|
|
104
|
+
onSelect={() => review.mutate(undefined, { onError: fail })}
|
|
105
|
+
>
|
|
106
|
+
{review.isPending && <Loader2 className="animate-spin" />}
|
|
107
|
+
{link.pendingDigestId ? "Reviewing…" : "Review now"}
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
{latest?.state === "invalid" && (
|
|
110
|
+
<DropdownMenuItem
|
|
111
|
+
onSelect={() => again.mutate(latest.id, { onError: fail })}
|
|
112
|
+
>
|
|
113
|
+
Ask for the review again
|
|
114
|
+
</DropdownMenuItem>
|
|
115
|
+
)}
|
|
116
|
+
<DropdownMenuItem
|
|
117
|
+
onSelect={() => pause.mutate(!link.paused, { onError: fail })}
|
|
118
|
+
>
|
|
119
|
+
{link.paused ? "Resume reviews" : "Pause reviews"}
|
|
120
|
+
</DropdownMenuItem>
|
|
121
|
+
<DropdownMenuSeparator />
|
|
122
|
+
<DropdownMenuItem
|
|
123
|
+
variant="destructive"
|
|
124
|
+
onSelect={() => end.mutate(true, { onError: fail })}
|
|
125
|
+
>
|
|
126
|
+
Unlink and finish the analyst
|
|
127
|
+
</DropdownMenuItem>
|
|
128
|
+
</DropdownMenuContent>
|
|
129
|
+
</DropdownMenu>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePanes } from "@/contexts/PaneContext";
|
|
4
|
+
import { useSessionAnalyst } from "@/data/analyst";
|
|
5
|
+
import type { Session } from "@/lib/db";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { PAIR_STATUS_LABEL, pairStatus, paneTmux } from "./pair";
|
|
8
|
+
|
|
9
|
+
// A phone shows one pane: a paired chat switches between its two sides
|
|
10
|
+
// here, with a dot on the analyst while a recommendation waits.
|
|
11
|
+
export function PairSwitch({
|
|
12
|
+
session,
|
|
13
|
+
paneId,
|
|
14
|
+
}: {
|
|
15
|
+
session: Session | null | undefined;
|
|
16
|
+
paneId: string;
|
|
17
|
+
}) {
|
|
18
|
+
const { data: link } = useSessionAnalyst(
|
|
19
|
+
session?.view === "chat" ? session.id : undefined
|
|
20
|
+
);
|
|
21
|
+
const { attachSession } = usePanes();
|
|
22
|
+
if (!session || !link || link.endedAt) return null;
|
|
23
|
+
|
|
24
|
+
const status = pairStatus(link);
|
|
25
|
+
const sides = [
|
|
26
|
+
{ side: link.coder, label: "Coding agent", note: null },
|
|
27
|
+
{
|
|
28
|
+
side: link.analyst,
|
|
29
|
+
label: "Analyst",
|
|
30
|
+
note: PAIR_STATUS_LABEL[status],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
role="tablist"
|
|
37
|
+
aria-label="Paired sessions"
|
|
38
|
+
className="bg-muted grid grid-cols-2 gap-1 px-2 pb-1.5"
|
|
39
|
+
onClick={(e) => e.stopPropagation()}
|
|
40
|
+
>
|
|
41
|
+
{sides.map(({ side, label, note }) => {
|
|
42
|
+
const active = side.id === session.id;
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
key={side.id}
|
|
46
|
+
type="button"
|
|
47
|
+
role="tab"
|
|
48
|
+
aria-selected={active}
|
|
49
|
+
onClick={() =>
|
|
50
|
+
!active && attachSession(paneId, side.id, paneTmux(side))
|
|
51
|
+
}
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg text-sm",
|
|
54
|
+
active
|
|
55
|
+
? "bg-background text-foreground font-medium shadow-sm"
|
|
56
|
+
: "text-muted-foreground"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<span className="truncate">{label}</span>
|
|
60
|
+
{note && status === "recommendation" && !active && (
|
|
61
|
+
<span
|
|
62
|
+
aria-label="Recommendation waiting"
|
|
63
|
+
className="bg-primary h-2 w-2 shrink-0 rounded-full"
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
{note && (
|
|
67
|
+
<span className="text-muted-foreground shrink-0 text-xs font-normal">
|
|
68
|
+
{note}
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { AlertTriangle, Check, Loader2, RotateCcw, Send } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
8
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
9
|
+
import { useBookmarkChat } from "@/components/Bookmarks/context";
|
|
10
|
+
import { useChatItem } from "@/components/Chat/itemContext";
|
|
11
|
+
import {
|
|
12
|
+
useAskAgain,
|
|
13
|
+
useDismissRecommendation,
|
|
14
|
+
useSendRecommendation,
|
|
15
|
+
useSessionAnalyst,
|
|
16
|
+
} from "@/data/analyst";
|
|
17
|
+
import { parseReviewJson } from "@/lib/analyst/parseReview";
|
|
18
|
+
import type { Issue, Recommendation } from "@/lib/analyst/types";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
20
|
+
import { cardView } from "./cardState";
|
|
21
|
+
import { SignalChips } from "./SignalChips";
|
|
22
|
+
|
|
23
|
+
const SEVERITY_DOT: Record<Issue["severity"], string> = {
|
|
24
|
+
high: "bg-red-500",
|
|
25
|
+
medium: "bg-amber-500",
|
|
26
|
+
low: "bg-muted-foreground/50",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const fail = (e: Error) => toast.error(e.message);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An analyst's `agentos-review` block, drawn as a card: its summary, the
|
|
33
|
+
* issues it flagged and the prompt it recommends, with Send to Coding Agent,
|
|
34
|
+
* Edit and Dismiss while the stored card is open.
|
|
35
|
+
*/
|
|
36
|
+
export function ReviewCard({ code }: { code: string }) {
|
|
37
|
+
const item = useChatItem();
|
|
38
|
+
const sessionId = useBookmarkChat()?.sessionId;
|
|
39
|
+
const { data: link } = useSessionAnalyst(sessionId);
|
|
40
|
+
const parsed = parseReviewJson(code);
|
|
41
|
+
const rec =
|
|
42
|
+
(item &&
|
|
43
|
+
link &&
|
|
44
|
+
link.analyst.id === sessionId &&
|
|
45
|
+
link.recommendations.find((r) => r.analystItemId === item.itemId)) ||
|
|
46
|
+
null;
|
|
47
|
+
|
|
48
|
+
if (!parsed.ok)
|
|
49
|
+
return (
|
|
50
|
+
<div className="bg-foreground/[0.04] text-muted-foreground not-prose my-3 space-y-2 rounded-xl px-3 py-2.5 text-xs">
|
|
51
|
+
<div className="flex items-center gap-2">
|
|
52
|
+
{item?.streaming ? (
|
|
53
|
+
<>
|
|
54
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Writing the
|
|
55
|
+
review…
|
|
56
|
+
</>
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
60
|
+
{parsed.error}
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
{rec && link && !item?.streaming && (
|
|
65
|
+
<Actions
|
|
66
|
+
linkId={link.id}
|
|
67
|
+
coderName={link.coder.name}
|
|
68
|
+
rec={rec}
|
|
69
|
+
prompt={null}
|
|
70
|
+
view={cardView(rec, null, link.coderLastTurn)}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const review = parsed.review;
|
|
77
|
+
const view = cardView(
|
|
78
|
+
rec,
|
|
79
|
+
rec?.verdict ?? review.verdict,
|
|
80
|
+
link?.coderLastTurn ?? null
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="bg-card hairline not-prose my-3 space-y-3 rounded-xl p-3 text-sm">
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<StatusPill tone={view.tone}>{view.label}</StatusPill>
|
|
87
|
+
<span className="text-muted-foreground text-xs">Analyst review</span>
|
|
88
|
+
</div>
|
|
89
|
+
<p className="leading-relaxed">{review.summary}</p>
|
|
90
|
+
{rec?.signals && <SignalChips signals={rec.signals} />}
|
|
91
|
+
{review.issues.length > 0 && (
|
|
92
|
+
<ul className="space-y-2">
|
|
93
|
+
{review.issues.map((issue, i) => (
|
|
94
|
+
<li key={i} className="flex gap-2">
|
|
95
|
+
<span
|
|
96
|
+
aria-label={`${issue.severity} severity`}
|
|
97
|
+
className={cn(
|
|
98
|
+
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
|
|
99
|
+
SEVERITY_DOT[issue.severity]
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
<div className="min-w-0">
|
|
103
|
+
<div className="font-medium">{issue.title}</div>
|
|
104
|
+
{issue.where && (
|
|
105
|
+
<div className="text-muted-foreground truncate font-mono text-[11px]">
|
|
106
|
+
{issue.where}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{issue.detail && (
|
|
110
|
+
<div className="text-muted-foreground text-xs leading-relaxed">
|
|
111
|
+
{issue.detail}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
)}
|
|
119
|
+
{view.notes.map((note) => (
|
|
120
|
+
<div
|
|
121
|
+
key={note}
|
|
122
|
+
className="flex items-start gap-2 rounded-lg bg-amber-500/10 px-2.5 py-2 text-xs text-amber-700 dark:text-amber-400"
|
|
123
|
+
>
|
|
124
|
+
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
|
125
|
+
{note}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
{rec && link ? (
|
|
129
|
+
<Actions
|
|
130
|
+
linkId={link.id}
|
|
131
|
+
coderName={link.coder.name}
|
|
132
|
+
rec={rec}
|
|
133
|
+
prompt={review.prompt ?? null}
|
|
134
|
+
view={view}
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
review.prompt && <PromptBox text={review.prompt} />
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function PromptBox({ text, muted }: { text: string; muted?: boolean }) {
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
className={cn(
|
|
147
|
+
"bg-foreground/[0.04] max-h-60 overflow-y-auto rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap",
|
|
148
|
+
muted && "text-muted-foreground"
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{text}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function Actions({
|
|
157
|
+
linkId,
|
|
158
|
+
coderName,
|
|
159
|
+
rec,
|
|
160
|
+
prompt,
|
|
161
|
+
view,
|
|
162
|
+
}: {
|
|
163
|
+
linkId: string;
|
|
164
|
+
coderName: string;
|
|
165
|
+
rec: Recommendation;
|
|
166
|
+
prompt: string | null;
|
|
167
|
+
view: ReturnType<typeof cardView>;
|
|
168
|
+
}) {
|
|
169
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
170
|
+
const send = useSendRecommendation(linkId);
|
|
171
|
+
const dismiss = useDismissRecommendation(linkId);
|
|
172
|
+
const again = useAskAgain(linkId);
|
|
173
|
+
const busy = send.isPending || dismiss.isPending || again.isPending;
|
|
174
|
+
|
|
175
|
+
if (rec.state === "sent")
|
|
176
|
+
return (
|
|
177
|
+
<div className="space-y-1.5">
|
|
178
|
+
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
179
|
+
<Check className="h-3.5 w-3.5 text-green-500" /> Sent to {coderName}
|
|
180
|
+
</div>
|
|
181
|
+
{rec.sentText && <PromptBox text={rec.sentText} muted />}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (draft !== null)
|
|
186
|
+
return (
|
|
187
|
+
<div className="space-y-2">
|
|
188
|
+
<Textarea
|
|
189
|
+
autoFocus
|
|
190
|
+
value={draft}
|
|
191
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
192
|
+
aria-label="Prompt for the coding agent"
|
|
193
|
+
className="min-h-[160px]"
|
|
194
|
+
/>
|
|
195
|
+
<div className="flex flex-wrap gap-2">
|
|
196
|
+
<Button
|
|
197
|
+
size="sm"
|
|
198
|
+
className="h-11 md:h-8"
|
|
199
|
+
disabled={busy || !draft.trim()}
|
|
200
|
+
onClick={() =>
|
|
201
|
+
send.mutate(
|
|
202
|
+
{ recId: rec.id, text: draft.trim() },
|
|
203
|
+
{ onError: fail, onSuccess: () => setDraft(null) }
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
<Send /> Send to Coding Agent
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
size="sm"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
className="h-11 md:h-8"
|
|
213
|
+
onClick={() => setDraft(null)}
|
|
214
|
+
>
|
|
215
|
+
Cancel
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
{prompt && <PromptBox text={prompt} muted={rec.state !== "open"} />}
|
|
224
|
+
<div className="flex flex-wrap gap-2">
|
|
225
|
+
{view.canSend && (
|
|
226
|
+
<Button
|
|
227
|
+
size="sm"
|
|
228
|
+
className="h-11 md:h-8"
|
|
229
|
+
disabled={busy}
|
|
230
|
+
onClick={() => send.mutate({ recId: rec.id }, { onError: fail })}
|
|
231
|
+
>
|
|
232
|
+
{send.isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
|
233
|
+
Send to Coding Agent
|
|
234
|
+
</Button>
|
|
235
|
+
)}
|
|
236
|
+
{view.canWrite && (
|
|
237
|
+
<Button
|
|
238
|
+
size="sm"
|
|
239
|
+
variant="outline"
|
|
240
|
+
className="h-11 md:h-8"
|
|
241
|
+
disabled={busy}
|
|
242
|
+
onClick={() => setDraft(prompt ?? "")}
|
|
243
|
+
>
|
|
244
|
+
{view.canSend ? "Edit" : "Ask coding agent anyway"}
|
|
245
|
+
</Button>
|
|
246
|
+
)}
|
|
247
|
+
{view.canAskAgain && (
|
|
248
|
+
<Button
|
|
249
|
+
size="sm"
|
|
250
|
+
variant="outline"
|
|
251
|
+
className="h-11 md:h-8"
|
|
252
|
+
disabled={busy}
|
|
253
|
+
onClick={() => again.mutate(rec.id, { onError: fail })}
|
|
254
|
+
>
|
|
255
|
+
<RotateCcw /> Ask again
|
|
256
|
+
</Button>
|
|
257
|
+
)}
|
|
258
|
+
{view.canDismiss && (
|
|
259
|
+
<Button
|
|
260
|
+
size="sm"
|
|
261
|
+
variant="ghost"
|
|
262
|
+
className="h-11 md:h-8"
|
|
263
|
+
disabled={busy}
|
|
264
|
+
onClick={() => dismiss.mutate(rec.id, { onError: fail })}
|
|
265
|
+
>
|
|
266
|
+
Dismiss
|
|
267
|
+
</Button>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DigestSignals } from "@/lib/analyst/types";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
5
|
+
|
|
6
|
+
const shortCommand = (c: string) => (c.length > 40 ? `${c.slice(0, 39)}…` : c);
|
|
7
|
+
|
|
8
|
+
/** What the reviewed turns did, read without a model: the facts under the card. */
|
|
9
|
+
export function SignalChips({ signals: s }: { signals: DigestSignals }) {
|
|
10
|
+
const chips: { text: string; tone?: "good" | "bad" }[] = [];
|
|
11
|
+
chips.push({ text: plural(s.toolCalls, "tool call") });
|
|
12
|
+
if (s.filesChanged.length)
|
|
13
|
+
chips.push({ text: plural(s.filesChanged.length, "file") + " changed" });
|
|
14
|
+
for (const c of s.checks)
|
|
15
|
+
chips.push({
|
|
16
|
+
text: `${shortCommand(c.command)} ${c.passed ? "passed" : "failed"}`,
|
|
17
|
+
tone: c.passed ? "good" : "bad",
|
|
18
|
+
});
|
|
19
|
+
if (s.failedTools)
|
|
20
|
+
chips.push({ text: plural(s.failedTools, "failed tool"), tone: "bad" });
|
|
21
|
+
if (s.errors) chips.push({ text: plural(s.errors, "error"), tone: "bad" });
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex flex-wrap gap-1.5">
|
|
24
|
+
{chips.map((c) => (
|
|
25
|
+
<span
|
|
26
|
+
key={c.text}
|
|
27
|
+
className={cn(
|
|
28
|
+
"bg-foreground/[0.05] text-muted-foreground max-w-full truncate rounded-md px-1.5 py-0.5 font-mono text-[11px]",
|
|
29
|
+
c.tone === "good" &&
|
|
30
|
+
"bg-green-500/10 text-green-700 dark:text-green-400",
|
|
31
|
+
c.tone === "bad" && "bg-red-500/10 text-red-700 dark:text-red-400"
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{c.text}
|
|
35
|
+
</span>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { Loader2 } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { Switch } from "@/components/ui/switch";
|
|
15
|
+
import { RoleModelSelect } from "@/components/Models/RoleModelSelect";
|
|
16
|
+
import { analystUi, analystUiActions } from "@/stores/analystUi";
|
|
17
|
+
import { useStartAnalystBeside } from "./pair";
|
|
18
|
+
|
|
19
|
+
// Pairs an analyst with a chat: it reviews each turn the coding agent
|
|
20
|
+
// finishes and recommends the next prompt, which the user sends or not.
|
|
21
|
+
export function StartAnalystSheet() {
|
|
22
|
+
const ui = useSnapshot(analystUi);
|
|
23
|
+
return (
|
|
24
|
+
<Dialog open={ui.open} onOpenChange={(o) => !o && analystUiActions.close()}>
|
|
25
|
+
{ui.open && ui.sessionId && (
|
|
26
|
+
<StartAnalystForm
|
|
27
|
+
key={ui.sessionId}
|
|
28
|
+
sessionId={ui.sessionId}
|
|
29
|
+
name={ui.name}
|
|
30
|
+
paneId={ui.paneId}
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
</Dialog>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function StartAnalystForm({
|
|
38
|
+
sessionId,
|
|
39
|
+
name,
|
|
40
|
+
paneId,
|
|
41
|
+
}: {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
name: string;
|
|
44
|
+
paneId: string | null;
|
|
45
|
+
}) {
|
|
46
|
+
const [model, setModel] = useState<string | null>(null);
|
|
47
|
+
const [reviewLast, setReviewLast] = useState(false);
|
|
48
|
+
const start = useStartAnalystBeside();
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<DialogContent className="sm:max-w-[460px]">
|
|
52
|
+
<DialogHeader>
|
|
53
|
+
<DialogTitle>Add an analyst</DialogTitle>
|
|
54
|
+
<DialogDescription>
|
|
55
|
+
A second chat that reviews each turn of{" "}
|
|
56
|
+
<span className="text-foreground font-medium">{name}</span>, explains
|
|
57
|
+
it in plain English, flags issues and drafts the next prompt. Nothing
|
|
58
|
+
reaches the coding agent until you press Send.
|
|
59
|
+
</DialogDescription>
|
|
60
|
+
</DialogHeader>
|
|
61
|
+
<div className="space-y-1.5">
|
|
62
|
+
<label
|
|
63
|
+
htmlFor="analyst-model"
|
|
64
|
+
className="text-muted-foreground text-xs font-medium uppercase"
|
|
65
|
+
>
|
|
66
|
+
Analyst model
|
|
67
|
+
</label>
|
|
68
|
+
<RoleModelSelect
|
|
69
|
+
id="analyst-model"
|
|
70
|
+
value={model}
|
|
71
|
+
inheritLabel="The analyst role's model"
|
|
72
|
+
claudeOnly
|
|
73
|
+
onChange={setModel}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
77
|
+
<span>Review the last turn now</span>
|
|
78
|
+
<Switch checked={reviewLast} onCheckedChange={setReviewLast} />
|
|
79
|
+
</label>
|
|
80
|
+
<div className="flex justify-end gap-2">
|
|
81
|
+
<Button
|
|
82
|
+
variant="ghost"
|
|
83
|
+
className="h-11 md:h-9"
|
|
84
|
+
onClick={() => analystUiActions.close()}
|
|
85
|
+
>
|
|
86
|
+
Cancel
|
|
87
|
+
</Button>
|
|
88
|
+
<Button
|
|
89
|
+
className="h-11 md:h-9"
|
|
90
|
+
disabled={start.isPending}
|
|
91
|
+
onClick={async () => {
|
|
92
|
+
const link = await start.run(
|
|
93
|
+
{
|
|
94
|
+
coderSessionId: sessionId,
|
|
95
|
+
analystModel: model ?? undefined,
|
|
96
|
+
reviewLast,
|
|
97
|
+
},
|
|
98
|
+
paneId ?? undefined
|
|
99
|
+
);
|
|
100
|
+
if (link) analystUiActions.close();
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{start.isPending && <Loader2 className="animate-spin" />}
|
|
104
|
+
Add analyst
|
|
105
|
+
</Button>
|
|
106
|
+
</div>
|
|
107
|
+
</DialogContent>
|
|
108
|
+
);
|
|
109
|
+
}
|