@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,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
+ }
@@ -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
- <Markdown text={item.text} streaming={item.streaming} />
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
  });