@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,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { DigestSignals, Recommendation } from "@/lib/analyst/types";
|
|
3
|
+
import { cardView } from "./cardState";
|
|
4
|
+
|
|
5
|
+
const signals = (over: Partial<DigestSignals> = {}): DigestSignals => ({
|
|
6
|
+
turns: 1,
|
|
7
|
+
toolCalls: 3,
|
|
8
|
+
failedTools: 0,
|
|
9
|
+
checks: [],
|
|
10
|
+
filesChanged: [],
|
|
11
|
+
durationMs: 1000,
|
|
12
|
+
costUsd: 0,
|
|
13
|
+
interrupted: false,
|
|
14
|
+
errors: 0,
|
|
15
|
+
noToolTurns: 0,
|
|
16
|
+
readWeb: false,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const rec = (over: Partial<Recommendation> = {}): Recommendation => ({
|
|
21
|
+
id: "r1",
|
|
22
|
+
linkId: "l1",
|
|
23
|
+
analystItemId: "a1",
|
|
24
|
+
coderThrough: "t1",
|
|
25
|
+
verdict: "continue",
|
|
26
|
+
summary: "Did the thing",
|
|
27
|
+
issues: [],
|
|
28
|
+
signals: signals(),
|
|
29
|
+
prompt: "Run the tests",
|
|
30
|
+
state: "open",
|
|
31
|
+
error: null,
|
|
32
|
+
sentText: null,
|
|
33
|
+
sentItemId: null,
|
|
34
|
+
edited: false,
|
|
35
|
+
createdAt: 1,
|
|
36
|
+
decidedAt: null,
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("cardView", () => {
|
|
41
|
+
it("offers Send, Edit and Dismiss on an open card with a prompt", () => {
|
|
42
|
+
const v = cardView(rec(), "continue", "t1");
|
|
43
|
+
expect(v).toMatchObject({
|
|
44
|
+
label: "Next prompt",
|
|
45
|
+
canSend: true,
|
|
46
|
+
canWrite: true,
|
|
47
|
+
canDismiss: true,
|
|
48
|
+
canAskAgain: false,
|
|
49
|
+
notes: [],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("is read-only without a stored card", () => {
|
|
54
|
+
const v = cardView(null, "continue", "t1");
|
|
55
|
+
expect(v.canSend || v.canWrite || v.canDismiss).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("has no Send on a blocked or needs-you card, but lets the user write one", () => {
|
|
59
|
+
for (const verdict of ["blocked", "needs_you"] as const) {
|
|
60
|
+
const v = cardView(rec({ verdict }), verdict, "t1");
|
|
61
|
+
expect(v.canSend).toBe(false);
|
|
62
|
+
expect(v.canWrite).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("warns when the coding agent has moved on", () => {
|
|
67
|
+
const v = cardView(rec(), "continue", "t2");
|
|
68
|
+
expect(v.canSend).toBe(true);
|
|
69
|
+
expect(v.notes).toContain(
|
|
70
|
+
"The coding agent has moved on since this review"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("flags a turn with no tool calls and web reads", () => {
|
|
75
|
+
const v = cardView(
|
|
76
|
+
rec({ signals: signals({ noToolTurns: 1, readWeb: true }) }),
|
|
77
|
+
"continue",
|
|
78
|
+
"t1"
|
|
79
|
+
);
|
|
80
|
+
expect(v.notes).toEqual([
|
|
81
|
+
"The coding agent's turn ended without a single tool call",
|
|
82
|
+
"It read web content: check the prompt before sending",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("offers Ask again on an invalid card, with its error", () => {
|
|
87
|
+
const v = cardView(
|
|
88
|
+
rec({ state: "invalid", error: "No block", verdict: null }),
|
|
89
|
+
null,
|
|
90
|
+
"t1"
|
|
91
|
+
);
|
|
92
|
+
expect(v).toMatchObject({
|
|
93
|
+
canAskAgain: true,
|
|
94
|
+
canSend: false,
|
|
95
|
+
notes: ["No block"],
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("is settled once sent, dismissed or superseded", () => {
|
|
100
|
+
for (const state of ["sent", "dismissed", "superseded"] as const) {
|
|
101
|
+
const v = cardView(rec({ state }), "continue", "t2");
|
|
102
|
+
expect(v.canSend || v.canWrite || v.canDismiss).toBe(false);
|
|
103
|
+
expect(v.notes).toEqual([]);
|
|
104
|
+
}
|
|
105
|
+
expect(
|
|
106
|
+
cardView(rec({ state: "sent", edited: true }), "continue", "t1").label
|
|
107
|
+
).toBe("Sent, edited");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Recommendation, Verdict } from "@/lib/analyst/types";
|
|
2
|
+
|
|
3
|
+
export type CardTone = "primary" | "green" | "amber" | "red" | "muted";
|
|
4
|
+
|
|
5
|
+
export interface CardView {
|
|
6
|
+
label: string;
|
|
7
|
+
tone: CardTone;
|
|
8
|
+
// Send the prompt as written (or edited).
|
|
9
|
+
canSend: boolean;
|
|
10
|
+
// A blocked or needs-you card has no Send; the user can still write one.
|
|
11
|
+
canWrite: boolean;
|
|
12
|
+
canDismiss: boolean;
|
|
13
|
+
canAskAgain: boolean;
|
|
14
|
+
// Why to look twice before sending, or what happened to it.
|
|
15
|
+
notes: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VERDICT_LABEL: Record<Verdict, [string, CardTone]> = {
|
|
19
|
+
continue: ["Next prompt", "primary"],
|
|
20
|
+
done: ["Looks done", "green"],
|
|
21
|
+
blocked: ["Blocked", "red"],
|
|
22
|
+
needs_you: ["Needs you", "amber"],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a review card shows and offers. `rec` is the stored card for the
|
|
29
|
+
* block, or null when there is none (an old review, an ended pairing): then
|
|
30
|
+
* it's read-only.
|
|
31
|
+
*/
|
|
32
|
+
export function cardView(
|
|
33
|
+
rec: Recommendation | null,
|
|
34
|
+
verdict: Verdict | null,
|
|
35
|
+
coderLastTurn: string | null
|
|
36
|
+
): CardView {
|
|
37
|
+
const [vLabel, vTone] = verdict
|
|
38
|
+
? VERDICT_LABEL[verdict]
|
|
39
|
+
: (["Review", "muted"] as const);
|
|
40
|
+
const view: CardView = {
|
|
41
|
+
label: vLabel,
|
|
42
|
+
tone: vTone,
|
|
43
|
+
canSend: false,
|
|
44
|
+
canWrite: false,
|
|
45
|
+
canDismiss: false,
|
|
46
|
+
canAskAgain: false,
|
|
47
|
+
notes: [],
|
|
48
|
+
};
|
|
49
|
+
if (!rec) return view;
|
|
50
|
+
|
|
51
|
+
const s = rec.signals;
|
|
52
|
+
if (s?.noToolTurns && s.noToolTurns === s.turns)
|
|
53
|
+
view.notes.push(
|
|
54
|
+
s.turns === 1
|
|
55
|
+
? "The coding agent's turn ended without a single tool call"
|
|
56
|
+
: `None of the ${plural(s.turns, "turn")} made a tool call`
|
|
57
|
+
);
|
|
58
|
+
if (s?.interrupted) view.notes.push("The coding agent was interrupted");
|
|
59
|
+
|
|
60
|
+
switch (rec.state) {
|
|
61
|
+
case "sent":
|
|
62
|
+
return {
|
|
63
|
+
...view,
|
|
64
|
+
label: rec.edited ? "Sent, edited" : "Sent",
|
|
65
|
+
tone: "muted",
|
|
66
|
+
notes: [],
|
|
67
|
+
};
|
|
68
|
+
case "dismissed":
|
|
69
|
+
return { ...view, label: "Dismissed", tone: "muted", notes: [] };
|
|
70
|
+
case "superseded":
|
|
71
|
+
return { ...view, label: "Superseded", tone: "muted", notes: [] };
|
|
72
|
+
case "invalid":
|
|
73
|
+
return {
|
|
74
|
+
...view,
|
|
75
|
+
label: "Couldn't read",
|
|
76
|
+
tone: "red",
|
|
77
|
+
canAskAgain: true,
|
|
78
|
+
canDismiss: true,
|
|
79
|
+
notes: [rec.error || "The review block was invalid"],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Open.
|
|
84
|
+
if (rec.coderThrough && coderLastTurn && rec.coderThrough !== coderLastTurn)
|
|
85
|
+
view.notes.push("The coding agent has moved on since this review");
|
|
86
|
+
if (s?.readWeb)
|
|
87
|
+
view.notes.push("It read web content: check the prompt before sending");
|
|
88
|
+
const sendable = verdict === "continue" || verdict === "done";
|
|
89
|
+
return {
|
|
90
|
+
...view,
|
|
91
|
+
canSend: sendable && !!rec.prompt,
|
|
92
|
+
canWrite: true,
|
|
93
|
+
canDismiss: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { usePanes } from "@/contexts/PaneContext";
|
|
6
|
+
import {
|
|
7
|
+
useSessionAnalyst,
|
|
8
|
+
useStartAnalyst,
|
|
9
|
+
type StartAnalystInput,
|
|
10
|
+
} from "@/data/analyst";
|
|
11
|
+
import type { Session } from "@/lib/db";
|
|
12
|
+
import type { AnalystLinkView, PaneSession } from "@/lib/analyst/types";
|
|
13
|
+
|
|
14
|
+
// A chat's tmux name: `<agent>-<id>`, as every chat tab attaches.
|
|
15
|
+
export const paneTmux = (s: PaneSession) => `${s.agentType}-${s.id}`;
|
|
16
|
+
|
|
17
|
+
export type PairStatus = "paused" | "reviewing" | "recommendation" | "watching";
|
|
18
|
+
|
|
19
|
+
export function pairStatus(link: AnalystLinkView): PairStatus {
|
|
20
|
+
if (link.paused) return "paused";
|
|
21
|
+
if (link.pendingDigestId) return "reviewing";
|
|
22
|
+
if (link.recommendations.some((r) => r.state === "open"))
|
|
23
|
+
return "recommendation";
|
|
24
|
+
return "watching";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PAIR_STATUS_LABEL: Record<PairStatus, string> = {
|
|
28
|
+
paused: "Paused",
|
|
29
|
+
reviewing: "Reviewing",
|
|
30
|
+
recommendation: "Prompt ready",
|
|
31
|
+
watching: "Watching",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The side of a pairing a session isn't.
|
|
35
|
+
export function partnerOf(link: AnalystLinkView, sessionId: string) {
|
|
36
|
+
return link.coder.id === sessionId ? link.analyst : link.coder;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Starts an analyst for a chat and shows it beside the coder's pane: the
|
|
40
|
+
// one given, else one showing the coder, else the focused one.
|
|
41
|
+
export function useStartAnalystBeside() {
|
|
42
|
+
const start = useStartAnalyst();
|
|
43
|
+
const { openBeside, state, focusedPaneId } = usePanes();
|
|
44
|
+
const run = useCallback(
|
|
45
|
+
async (input: StartAnalystInput, paneId?: string) => {
|
|
46
|
+
try {
|
|
47
|
+
const link = await start.mutateAsync(input);
|
|
48
|
+
const showing = Object.entries(state.panes).find(([, p]) =>
|
|
49
|
+
p.tabs.some(
|
|
50
|
+
(t) => t.id === p.activeTabId && t.sessionId === link.coder.id
|
|
51
|
+
)
|
|
52
|
+
)?.[0];
|
|
53
|
+
openBeside(paneId ?? showing ?? focusedPaneId, {
|
|
54
|
+
sessionId: link.analyst.id,
|
|
55
|
+
tmux: paneTmux(link.analyst),
|
|
56
|
+
});
|
|
57
|
+
return link;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
toast.error(error instanceof Error ? error.message : String(error));
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[start, openBeside, state.panes, focusedPaneId]
|
|
64
|
+
);
|
|
65
|
+
return { run, isPending: start.isPending };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Whether a session can be given an analyst here: a local chat that isn't
|
|
69
|
+
// an orchestrator, an analyst or already paired. The server checks again.
|
|
70
|
+
export function useCanAddAnalyst(session: Session | null | undefined) {
|
|
71
|
+
const chat = session?.view === "chat" ? session : null;
|
|
72
|
+
const paired = useSessionAnalyst(chat?.id).data;
|
|
73
|
+
return (
|
|
74
|
+
!!chat &&
|
|
75
|
+
!chat.role &&
|
|
76
|
+
!chat.archived_at &&
|
|
77
|
+
(chat.host_id || "local") === "local" &&
|
|
78
|
+
chat.agent_role !== "analyst" &&
|
|
79
|
+
!paired
|
|
80
|
+
);
|
|
81
|
+
}
|
package/components/Chat/Code.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import oneDark from "react-syntax-highlighter/dist/esm/styles/prism/one-dark";
|
|
|
12
12
|
import oneLight from "react-syntax-highlighter/dist/esm/styles/prism/one-light";
|
|
13
13
|
import { CopyButton } from "./CopyButton";
|
|
14
14
|
import { Mermaid } from "./Mermaid";
|
|
15
|
+
import { ReviewCard } from "@/components/Analyst/ReviewCard";
|
|
16
|
+
import { REVIEW_FENCE } from "@/lib/analyst/parseReview";
|
|
15
17
|
|
|
16
18
|
const ALIASES: Record<string, string> = {
|
|
17
19
|
sh: "bash",
|
|
@@ -117,6 +119,7 @@ export function CodeBlock({
|
|
|
117
119
|
language?: string;
|
|
118
120
|
}) {
|
|
119
121
|
if (language === "mermaid") return <Mermaid code={code} />;
|
|
122
|
+
if (language === REVIEW_FENCE) return <ReviewCard code={code} />;
|
|
120
123
|
return (
|
|
121
124
|
<div className="bg-foreground/[0.04] not-prose my-3 overflow-hidden rounded-xl">
|
|
122
125
|
<div
|
|
@@ -24,9 +24,11 @@ import { FileChip } from "./FileChip";
|
|
|
24
24
|
import { ImageThumb } from "./ImageThumb";
|
|
25
25
|
import { Markdown } from "./Markdown";
|
|
26
26
|
import { openSessionLink } from "./session-link";
|
|
27
|
+
import { TurnCopy } from "./TurnCopy";
|
|
27
28
|
import { UndoButton } from "./Undo";
|
|
28
29
|
import { useRowState } from "./rowState";
|
|
29
30
|
import { useBookmarkChat } from "@/components/Bookmarks/context";
|
|
31
|
+
import { ChatItemContext } from "./itemContext";
|
|
30
32
|
import { exportUi, exportUiActions, pickedRange } from "@/stores/exportUi";
|
|
31
33
|
import {
|
|
32
34
|
BookmarkButton,
|
|
@@ -299,7 +301,11 @@ export function AssistantMessage({
|
|
|
299
301
|
<BookmarkPress itemId={item.id}>
|
|
300
302
|
<div className="group">
|
|
301
303
|
<div data-quotable className={cn(item.streaming && "streaming-cursor")}>
|
|
302
|
-
<
|
|
304
|
+
<ChatItemContext.Provider
|
|
305
|
+
value={{ itemId: item.id, streaming: !!item.streaming }}
|
|
306
|
+
>
|
|
307
|
+
<Markdown text={item.text} streaming={item.streaming} />
|
|
308
|
+
</ChatItemContext.Provider>
|
|
303
309
|
</div>
|
|
304
310
|
{!item.streaming && (
|
|
305
311
|
<div className="-ml-1.5 flex items-center">
|
|
@@ -402,6 +408,7 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
|
|
|
402
408
|
<div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
403
409
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
404
410
|
{label}
|
|
411
|
+
<TurnCopy turnEndId={item.id} />
|
|
405
412
|
<TurnBookmark turnEndId={item.id} />
|
|
406
413
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
407
414
|
</div>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Check, Copy } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { fetchTurnCopy } from "@/data/exports";
|
|
7
|
+
import { useBookmarkChat, useTurnStart } from "@/components/Bookmarks/context";
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "@/components/ui/dropdown-menu";
|
|
14
|
+
|
|
15
|
+
type Part = "response" | "full";
|
|
16
|
+
|
|
17
|
+
// "Copy" on a finished turn's end line: the answer alone, or the whole turn
|
|
18
|
+
// (reasoning, tools, output, errors) as Markdown. One menu, so a phone's
|
|
19
|
+
// turn row gets one button, not two.
|
|
20
|
+
export function TurnCopy({ turnEndId }: { turnEndId: string }) {
|
|
21
|
+
const chat = useBookmarkChat();
|
|
22
|
+
const start = useTurnStart(turnEndId);
|
|
23
|
+
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
|
24
|
+
if (!chat || !start) return null;
|
|
25
|
+
|
|
26
|
+
const copy = async (part: Part) => {
|
|
27
|
+
try {
|
|
28
|
+
const turn = await fetchTurnCopy(chat.sessionId, start);
|
|
29
|
+
const text = part === "full" ? turn.full : turn.response;
|
|
30
|
+
if (!text) throw new Error("nothing to copy");
|
|
31
|
+
await navigator.clipboard.writeText(text);
|
|
32
|
+
setState("copied");
|
|
33
|
+
} catch {
|
|
34
|
+
setState("failed");
|
|
35
|
+
}
|
|
36
|
+
setTimeout(() => setState("idle"), 1500);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<DropdownMenu>
|
|
41
|
+
<DropdownMenuTrigger asChild>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-label="Copy turn"
|
|
45
|
+
className={cn(
|
|
46
|
+
"hover:text-foreground hover:bg-foreground/[0.06] -my-2 flex min-h-11 shrink-0 items-center gap-1 rounded-md px-2 md:-my-1 md:min-h-7",
|
|
47
|
+
state === "idle"
|
|
48
|
+
? "opacity-60 group-hover:opacity-100 focus-visible:opacity-100"
|
|
49
|
+
: state === "failed"
|
|
50
|
+
? "text-destructive"
|
|
51
|
+
: "text-foreground"
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{state === "copied" ? (
|
|
55
|
+
<Check className="h-3 w-3" />
|
|
56
|
+
) : (
|
|
57
|
+
<Copy className="h-3 w-3" />
|
|
58
|
+
)}
|
|
59
|
+
<span className="md:hidden">
|
|
60
|
+
{state === "failed"
|
|
61
|
+
? "Copy failed"
|
|
62
|
+
: state === "copied"
|
|
63
|
+
? "Copied"
|
|
64
|
+
: "Copy"}
|
|
65
|
+
</span>
|
|
66
|
+
</button>
|
|
67
|
+
</DropdownMenuTrigger>
|
|
68
|
+
<DropdownMenuContent align="end" className="font-sans text-sm">
|
|
69
|
+
<DropdownMenuItem
|
|
70
|
+
className="min-h-11 md:min-h-8"
|
|
71
|
+
onSelect={() => void copy("response")}
|
|
72
|
+
>
|
|
73
|
+
Copy Response
|
|
74
|
+
</DropdownMenuItem>
|
|
75
|
+
<DropdownMenuItem
|
|
76
|
+
className="min-h-11 md:min-h-8"
|
|
77
|
+
onSelect={() => void copy("full")}
|
|
78
|
+
>
|
|
79
|
+
Copy Full Turn
|
|
80
|
+
</DropdownMenuItem>
|
|
81
|
+
</DropdownMenuContent>
|
|
82
|
+
</DropdownMenu>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
// The message a rendered block belongs to, for blocks that act on it (a
|
|
6
|
+
// review card finds its stored recommendation by the item's id).
|
|
7
|
+
export interface ChatItemRef {
|
|
8
|
+
itemId: string;
|
|
9
|
+
streaming: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ChatItemContext = createContext<ChatItemRef | null>(null);
|
|
13
|
+
|
|
14
|
+
export const useChatItem = () => useContext(ChatItemContext);
|
|
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
|
|
4
4
|
import {
|
|
5
5
|
Bot,
|
|
6
6
|
Cpu,
|
|
7
|
+
Eye,
|
|
7
8
|
FolderGit2,
|
|
8
9
|
GitBranch,
|
|
9
10
|
Flag,
|
|
@@ -252,6 +253,15 @@ export function DraftChips({
|
|
|
252
253
|
onChange={(openPr) => onChange({ openPr })}
|
|
253
254
|
/>
|
|
254
255
|
)}
|
|
256
|
+
{project && !draft.openPr && local && draft.agentRole !== "analyst" && (
|
|
257
|
+
<ChipToggle
|
|
258
|
+
icon={Eye}
|
|
259
|
+
label="With analyst"
|
|
260
|
+
on={!!draft.withAnalyst}
|
|
261
|
+
title="A second chat reviews each turn and drafts the next prompt for you to send"
|
|
262
|
+
onChange={(withAnalyst) => onChange({ withAnalyst })}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
255
265
|
{project && draft.openPr && local && (
|
|
256
266
|
<ChipMenu
|
|
257
267
|
icon={Cpu}
|
|
@@ -23,6 +23,7 @@ import type { StartWarning } from "@/lib/claims/startWarning";
|
|
|
23
23
|
import { usePanes } from "@/contexts/PaneContext";
|
|
24
24
|
import { setPendingPrompt } from "@/stores/initialPrompt";
|
|
25
25
|
import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
|
|
26
|
+
import { useStartAnalystBeside } from "@/components/Analyst/pair";
|
|
26
27
|
import { DraftChips } from "./DraftChips";
|
|
27
28
|
import { DraftHeading } from "./DraftHeading";
|
|
28
29
|
import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
|
|
@@ -57,6 +58,7 @@ export function DraftPanel({
|
|
|
57
58
|
discovers
|
|
58
59
|
);
|
|
59
60
|
const launch = useLaunchSession();
|
|
61
|
+
const startAnalyst = useStartAnalystBeside();
|
|
60
62
|
const { workspace } = useSelectedWorkspace();
|
|
61
63
|
const createTask = useCreateTask();
|
|
62
64
|
const checkStart = useCheckStart();
|
|
@@ -172,6 +174,11 @@ export function DraftPanel({
|
|
|
172
174
|
session.tmux_name ?? "",
|
|
173
175
|
session.host_id
|
|
174
176
|
);
|
|
177
|
+
if (draft.withAnalyst && project && draft.hostId === "local") {
|
|
178
|
+
if (session.view === "chat")
|
|
179
|
+
void startAnalyst.run({ coderSessionId: session.id }, paneId);
|
|
180
|
+
else toast("An analyst reviews chats: this session runs in a terminal");
|
|
181
|
+
}
|
|
175
182
|
} catch (error) {
|
|
176
183
|
if (error instanceof ModelUnavailableError) {
|
|
177
184
|
setDown({ error, text, images, files });
|
|
@@ -16,6 +16,7 @@ import { PaneViewToggle } from "./PaneViewToggle";
|
|
|
16
16
|
import { PaneMenu } from "./PaneMenu";
|
|
17
17
|
import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
18
18
|
import { RolePill } from "@/components/Sessions/RolePill";
|
|
19
|
+
import { PairChip } from "@/components/Analyst/PairChip";
|
|
19
20
|
import { usePaneBarSlots } from "./PaneBarSlots";
|
|
20
21
|
import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
|
|
21
22
|
import type { ViewMode } from "@/contexts/PaneContext";
|
|
@@ -37,6 +38,7 @@ interface DesktopTabBarProps {
|
|
|
37
38
|
isFocused: boolean;
|
|
38
39
|
isConductor: boolean;
|
|
39
40
|
hasLog?: boolean;
|
|
41
|
+
hasReport?: boolean;
|
|
40
42
|
workerCount: number;
|
|
41
43
|
canSplit: boolean;
|
|
42
44
|
canClose: boolean;
|
|
@@ -68,6 +70,7 @@ export function DesktopTabBar({
|
|
|
68
70
|
isFocused,
|
|
69
71
|
isConductor,
|
|
70
72
|
hasLog,
|
|
73
|
+
hasReport,
|
|
71
74
|
workerCount,
|
|
72
75
|
canSplit,
|
|
73
76
|
canClose,
|
|
@@ -163,6 +166,7 @@ export function DesktopTabBar({
|
|
|
163
166
|
|
|
164
167
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
165
168
|
<RolePill session={session} paneId={paneId} />
|
|
169
|
+
<PairChip session={session} paneId={paneId} />
|
|
166
170
|
<HostBadge hostId={session?.host_id} />
|
|
167
171
|
<ContextMeter sessionId={session?.id} />
|
|
168
172
|
<SessionPrStage sessionId={session?.id} />
|
|
@@ -182,6 +186,7 @@ export function DesktopTabBar({
|
|
|
182
186
|
isLocalSession={isLocalSession}
|
|
183
187
|
isConductor={isConductor}
|
|
184
188
|
hasLog={hasLog}
|
|
189
|
+
hasReport={hasReport}
|
|
185
190
|
workerCount={workerCount}
|
|
186
191
|
showGit={showGit}
|
|
187
192
|
gitDrawerOpen={gitDrawerOpen}
|
|
@@ -194,6 +199,7 @@ export function DesktopTabBar({
|
|
|
194
199
|
<div className="flex items-center">
|
|
195
200
|
<PaneMenu
|
|
196
201
|
session={session}
|
|
202
|
+
paneId={paneId}
|
|
197
203
|
bookmarksSessionId={bookmarksSessionId}
|
|
198
204
|
canSplit={canSplit}
|
|
199
205
|
canClose={canClose}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
Share2,
|
|
27
27
|
Download,
|
|
28
28
|
TextSearch,
|
|
29
|
+
Eye,
|
|
29
30
|
} from "lucide-react";
|
|
30
31
|
import { cn } from "@/lib/utils";
|
|
31
32
|
import { useAwayQuery } from "@/data/away";
|
|
@@ -36,6 +37,8 @@ import { RolePill } from "@/components/Sessions/RolePill";
|
|
|
36
37
|
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
37
38
|
import { shareUiActions } from "@/stores/shareUi";
|
|
38
39
|
import { exportUiActions } from "@/stores/exportUi";
|
|
40
|
+
import { analystUiActions } from "@/stores/analystUi";
|
|
41
|
+
import { useCanAddAnalyst } from "@/components/Analyst/pair";
|
|
39
42
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
40
43
|
import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
|
|
41
44
|
import type { ViewMode } from "@/contexts/PaneContext";
|
|
@@ -50,6 +53,7 @@ interface MobileTabBarProps {
|
|
|
50
53
|
viewMode: ViewMode;
|
|
51
54
|
isConductor: boolean;
|
|
52
55
|
hasLog?: boolean;
|
|
56
|
+
hasReport?: boolean;
|
|
53
57
|
workerCount: number;
|
|
54
58
|
// Local, and the folder is not known to be outside a git repository.
|
|
55
59
|
showGit: boolean;
|
|
@@ -70,6 +74,7 @@ export function MobileTabBar({
|
|
|
70
74
|
viewMode,
|
|
71
75
|
isConductor,
|
|
72
76
|
hasLog,
|
|
77
|
+
hasReport,
|
|
73
78
|
workerCount,
|
|
74
79
|
showGit,
|
|
75
80
|
onMenuClick,
|
|
@@ -128,6 +133,7 @@ export function MobileTabBar({
|
|
|
128
133
|
};
|
|
129
134
|
|
|
130
135
|
const isLocalSession = !session?.host_id || session.host_id === "local";
|
|
136
|
+
const canAddAnalyst = useCanAddAnalyst(session);
|
|
131
137
|
|
|
132
138
|
return (
|
|
133
139
|
<div
|
|
@@ -230,6 +236,16 @@ export function MobileTabBar({
|
|
|
230
236
|
<Download className="h-4 w-4" />
|
|
231
237
|
Export for AI…
|
|
232
238
|
</DropdownMenuItem>
|
|
239
|
+
{canAddAnalyst && (
|
|
240
|
+
<DropdownMenuItem
|
|
241
|
+
onSelect={() =>
|
|
242
|
+
analystUiActions.open(session.id, session.name)
|
|
243
|
+
}
|
|
244
|
+
>
|
|
245
|
+
<Eye className="h-4 w-4" />
|
|
246
|
+
Add analyst…
|
|
247
|
+
</DropdownMenuItem>
|
|
248
|
+
)}
|
|
233
249
|
<DropdownMenuSeparator />
|
|
234
250
|
</>
|
|
235
251
|
)}
|
|
@@ -375,6 +391,7 @@ export function MobileTabBar({
|
|
|
375
391
|
isLocalSession={isLocalSession}
|
|
376
392
|
showGit={showGit}
|
|
377
393
|
hasLog={hasLog}
|
|
394
|
+
hasReport={hasReport}
|
|
378
395
|
isConductor={isConductor}
|
|
379
396
|
workerCount={workerCount}
|
|
380
397
|
/>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
GitBranch,
|
|
8
8
|
Package,
|
|
9
9
|
ScrollText,
|
|
10
|
+
ClipboardList,
|
|
10
11
|
Terminal as TerminalIcon,
|
|
11
12
|
Users,
|
|
12
13
|
type LucideIcon,
|
|
@@ -31,6 +32,7 @@ export interface MobileViewAvailability {
|
|
|
31
32
|
// Local, and the folder is not known to be outside a git repository.
|
|
32
33
|
showGit: boolean;
|
|
33
34
|
hasLog?: boolean;
|
|
35
|
+
hasReport?: boolean;
|
|
34
36
|
isConductor: boolean;
|
|
35
37
|
workerCount: number;
|
|
36
38
|
}
|
|
@@ -40,6 +42,7 @@ export function mobileViews({
|
|
|
40
42
|
isLocalSession,
|
|
41
43
|
showGit,
|
|
42
44
|
hasLog,
|
|
45
|
+
hasReport,
|
|
43
46
|
isConductor,
|
|
44
47
|
workerCount,
|
|
45
48
|
}: MobileViewAvailability): MobileView[] {
|
|
@@ -48,6 +51,7 @@ export function mobileViews({
|
|
|
48
51
|
isLocalSession && { mode: "files", label: "Files", icon: FolderOpen },
|
|
49
52
|
showGit && { mode: "git", label: "Git", icon: GitBranch },
|
|
50
53
|
{ mode: "artifacts", label: "Artifacts", icon: Package },
|
|
54
|
+
!!hasReport && { mode: "report", label: "Report", icon: ClipboardList },
|
|
51
55
|
!!hasLog && { mode: "log", label: "Log", icon: ScrollText },
|
|
52
56
|
isConductor && {
|
|
53
57
|
mode: "workers",
|
|
@@ -6,10 +6,12 @@ import type { Session } from "@/lib/db/types";
|
|
|
6
6
|
import { bookmarksUi } from "@/stores/bookmarksUi";
|
|
7
7
|
import { docsUi } from "@/stores/docsUi";
|
|
8
8
|
import { paletteUi } from "@/stores/palette";
|
|
9
|
+
import { analystUi } from "@/stores/analystUi";
|
|
9
10
|
|
|
10
11
|
const data = vi.hoisted(() => ({
|
|
11
12
|
bookmarks: [] as unknown[],
|
|
12
13
|
docs: null as { id: string } | null,
|
|
14
|
+
canAddAnalyst: false,
|
|
13
15
|
}));
|
|
14
16
|
vi.mock("@/data/bookmarks", () => ({
|
|
15
17
|
useSessionBookmarks: () => ({ data: data.bookmarks }),
|
|
@@ -17,6 +19,9 @@ vi.mock("@/data/bookmarks", () => ({
|
|
|
17
19
|
vi.mock("@/data/lumifyhub/docs", () => ({
|
|
18
20
|
useDocsWorkspace: () => data.docs,
|
|
19
21
|
}));
|
|
22
|
+
vi.mock("@/components/Analyst/pair", () => ({
|
|
23
|
+
useCanAddAnalyst: () => data.canAddAnalyst,
|
|
24
|
+
}));
|
|
20
25
|
vi.mock("@/components/Tasks/MoveMenuItems", () => ({
|
|
21
26
|
MoveMenuItems: () => null,
|
|
22
27
|
}));
|
|
@@ -60,6 +65,8 @@ describe("PaneMenu", () => {
|
|
|
60
65
|
root = createRoot(host);
|
|
61
66
|
data.bookmarks = [];
|
|
62
67
|
data.docs = null;
|
|
68
|
+
data.canAddAnalyst = false;
|
|
69
|
+
analystUi.open = false;
|
|
63
70
|
paletteUi.open = false;
|
|
64
71
|
bookmarksUi.chatSessionId = null;
|
|
65
72
|
docsUi.workspaceId = null;
|
|
@@ -73,6 +80,7 @@ describe("PaneMenu", () => {
|
|
|
73
80
|
root.render(
|
|
74
81
|
createElement(PaneMenu, {
|
|
75
82
|
session,
|
|
83
|
+
paneId: "pane-1",
|
|
76
84
|
bookmarksSessionId,
|
|
77
85
|
canSplit: true,
|
|
78
86
|
canClose: true,
|
|
@@ -115,4 +123,17 @@ describe("PaneMenu", () => {
|
|
|
115
123
|
act(() => item("Docs")?.click());
|
|
116
124
|
expect(docsUi.workspaceId).toBe("w1");
|
|
117
125
|
});
|
|
126
|
+
|
|
127
|
+
it("offers Add analyst only when the chat can have one, beside this pane", () => {
|
|
128
|
+
render();
|
|
129
|
+
expect(item("Add analyst")).toBeUndefined();
|
|
130
|
+
data.canAddAnalyst = true;
|
|
131
|
+
render();
|
|
132
|
+
act(() => item("Add analyst")?.click());
|
|
133
|
+
expect(analystUi).toMatchObject({
|
|
134
|
+
open: true,
|
|
135
|
+
sessionId: "s1",
|
|
136
|
+
paneId: "pane-1",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
118
139
|
});
|