@saadnvd1/agent-os 0.3.715 → 0.3.728

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/README.md +2 -0
  2. package/app/api/agent-roles/route.test.ts +67 -0
  3. package/app/api/agent-roles/route.ts +61 -0
  4. package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
  5. package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
  6. package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
  7. package/app/api/analyst/[id]/review/route.ts +22 -0
  8. package/app/api/analyst/[id]/route.ts +44 -0
  9. package/app/api/analyst/route.ts +10 -0
  10. package/app/api/bus/spawn/route.ts +22 -1
  11. package/app/api/loops/route.ts +17 -0
  12. package/app/api/models/availability/route.ts +9 -0
  13. package/app/api/models/choices/route.ts +9 -0
  14. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  15. package/app/api/sessions/[id]/crew/route.ts +38 -0
  16. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  17. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  18. package/app/api/sessions/[id]/export/route.ts +34 -0
  19. package/app/api/sessions/[id]/model/route.ts +49 -0
  20. package/app/api/sessions/route.ts +16 -0
  21. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  22. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  23. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  24. package/app/api/tasks/[id]/loop/route.ts +72 -0
  25. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  26. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  27. package/app/api/tasks/[id]/mode/route.ts +29 -0
  28. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  29. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  30. package/app/api/tasks/[id]/report/route.ts +39 -0
  31. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  32. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  33. package/app/page.tsx +13 -0
  34. package/components/Analyst/PairChip.tsx +131 -0
  35. package/components/Analyst/PairSwitch.tsx +76 -0
  36. package/components/Analyst/ReviewCard.tsx +272 -0
  37. package/components/Analyst/SignalChips.tsx +39 -0
  38. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  39. package/components/Analyst/cardState.test.ts +109 -0
  40. package/components/Analyst/cardState.ts +95 -0
  41. package/components/Analyst/pair.ts +81 -0
  42. package/components/Chat/Code.tsx +3 -0
  43. package/components/Chat/Messages.tsx +110 -11
  44. package/components/Chat/itemContext.ts +14 -0
  45. package/components/Draft/DraftChips.tsx +16 -1
  46. package/components/Draft/DraftPanel.tsx +58 -2
  47. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  48. package/components/Draft/RoleChips.tsx +110 -0
  49. package/components/Export/ExportActions.tsx +136 -0
  50. package/components/Export/ExportSheet.tsx +217 -0
  51. package/components/Export/RangePicker.tsx +144 -0
  52. package/components/Export/Seg.tsx +38 -0
  53. package/components/Export/TurnSelectBar.tsx +45 -0
  54. package/components/Models/AgentRoleCard.tsx +114 -0
  55. package/components/Models/AgentRolesSection.tsx +132 -0
  56. package/components/Models/ModelBadges.tsx +48 -0
  57. package/components/Models/ModelsSection.tsx +4 -2
  58. package/components/Models/RoleModelSelect.tsx +87 -0
  59. package/components/Pane/DesktopTabBar.tsx +8 -0
  60. package/components/Pane/MobileTabBar.tsx +29 -0
  61. package/components/Pane/MobileViewPicker.tsx +4 -0
  62. package/components/Pane/PaneMenu.test.ts +21 -0
  63. package/components/Pane/PaneMenu.tsx +26 -0
  64. package/components/Pane/PaneViewToggle.tsx +12 -0
  65. package/components/Pane/index.tsx +19 -0
  66. package/components/Report/DraftCard.tsx +89 -0
  67. package/components/Report/EstimateBar.tsx +51 -0
  68. package/components/Report/EvidenceSheet.tsx +98 -0
  69. package/components/Report/ModeChooser.tsx +43 -0
  70. package/components/Report/ReportView.tsx +289 -0
  71. package/components/SessionList/SessionRow.tsx +18 -7
  72. package/components/SessionList/SessionRowMenu.tsx +12 -0
  73. package/components/Sessions/RolePill.tsx +203 -0
  74. package/components/Settings/SettingsDialog.tsx +9 -0
  75. package/components/Share/ShareDialog.tsx +13 -0
  76. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  77. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  78. package/components/Tasks/Loop/LoopView.tsx +350 -0
  79. package/components/Tasks/Loop/labels.ts +17 -0
  80. package/components/Tasks/TaskRow.tsx +2 -0
  81. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  82. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  83. package/components/ui/segmented.tsx +55 -0
  84. package/components/ui/status-pill.tsx +3 -0
  85. package/contexts/PaneContext.tsx +21 -1
  86. package/data/analyst/index.ts +3 -0
  87. package/data/analyst/keys.ts +6 -0
  88. package/data/analyst/mutations.ts +116 -0
  89. package/data/analyst/queries.ts +35 -0
  90. package/data/exports/index.ts +2 -0
  91. package/data/exports/keys.ts +3 -0
  92. package/data/exports/queries.ts +51 -0
  93. package/data/loops/index.ts +3 -0
  94. package/data/loops/keys.ts +4 -0
  95. package/data/loops/mutations.ts +68 -0
  96. package/data/loops/queries.ts +40 -0
  97. package/data/models/index.ts +98 -0
  98. package/data/push/topics.ts +11 -0
  99. package/data/reports/index.ts +115 -0
  100. package/data/sessions/queries.ts +26 -0
  101. package/lib/agents/brief.ts +1 -0
  102. package/lib/analyst/__fixtures__/items.ts +57 -0
  103. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  104. package/lib/analyst/brief.ts +17 -0
  105. package/lib/analyst/buildDigest.ts +204 -0
  106. package/lib/analyst/createLink.ts +98 -0
  107. package/lib/analyst/endLink.ts +31 -0
  108. package/lib/analyst/forward.ts +109 -0
  109. package/lib/analyst/index.ts +16 -0
  110. package/lib/analyst/linkView.ts +38 -0
  111. package/lib/analyst/onTurnEnd.ts +119 -0
  112. package/lib/analyst/parseReview.ts +41 -0
  113. package/lib/analyst/pushDigest.ts +143 -0
  114. package/lib/analyst/resume.ts +35 -0
  115. package/lib/analyst/route.ts +50 -0
  116. package/lib/analyst/signals.ts +121 -0
  117. package/lib/analyst/store.ts +303 -0
  118. package/lib/analyst/types.ts +140 -0
  119. package/lib/bus/condense.ts +5 -1
  120. package/lib/bus/index.ts +5 -0
  121. package/lib/bus/read-scope.ts +12 -0
  122. package/lib/bus/role-alias.ts +70 -0
  123. package/lib/bus/targets.ts +3 -0
  124. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  125. package/lib/chat/drivers/pi-approvals.ts +7 -4
  126. package/lib/chat/drivers/pi-mapper.ts +38 -0
  127. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  128. package/lib/chat/drivers/pi.ts +8 -3
  129. package/lib/chat/drivers/turn-items.ts +36 -11
  130. package/lib/chat/drivers/turn-outcome.ts +108 -0
  131. package/lib/chat/events.ts +8 -0
  132. package/lib/chat/registry.ts +13 -0
  133. package/lib/chat/store.ts +19 -0
  134. package/lib/chat/turns.ts +1 -0
  135. package/lib/chat/worker/extras.ts +4 -2
  136. package/lib/claims/collectLocal.ts +3 -1
  137. package/lib/db/changes.ts +13 -0
  138. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  139. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  140. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  141. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  142. package/lib/db/session-parts/columns.ts +2 -0
  143. package/lib/db/types.ts +3 -0
  144. package/lib/done/archive.ts +2 -0
  145. package/lib/done/index.ts +2 -0
  146. package/lib/drafts.ts +6 -0
  147. package/lib/export/build.ts +470 -0
  148. package/lib/export/compact.ts +156 -0
  149. package/lib/export/estimate.ts +83 -0
  150. package/lib/export/fixtures.ts +45 -0
  151. package/lib/export/html.tsx +195 -0
  152. package/lib/export/json.ts +39 -0
  153. package/lib/export/load.ts +64 -0
  154. package/lib/export/markdown.ts +127 -0
  155. package/lib/export/request.ts +36 -0
  156. package/lib/export/service.ts +142 -0
  157. package/lib/export/summary.ts +86 -0
  158. package/lib/export/turns.ts +63 -0
  159. package/lib/export/view.ts +173 -0
  160. package/lib/loops/analyse.ts +46 -0
  161. package/lib/loops/analyst-prompt.ts +203 -0
  162. package/lib/loops/checkCriteria.ts +114 -0
  163. package/lib/loops/compactEvidence.ts +140 -0
  164. package/lib/loops/controls.ts +317 -0
  165. package/lib/loops/decideStop.ts +113 -0
  166. package/lib/loops/deps.ts +128 -0
  167. package/lib/loops/diffMap.ts +39 -0
  168. package/lib/loops/draftCriteria.ts +71 -0
  169. package/lib/loops/gatherEvidence.ts +265 -0
  170. package/lib/loops/hooks.ts +40 -0
  171. package/lib/loops/index.ts +7 -0
  172. package/lib/loops/route.ts +83 -0
  173. package/lib/loops/runCycle.ts +323 -0
  174. package/lib/loops/screenInstruction.ts +59 -0
  175. package/lib/loops/sendInstruction.ts +83 -0
  176. package/lib/loops/snapshot.ts +38 -0
  177. package/lib/loops/stopLoop.ts +86 -0
  178. package/lib/loops/store.ts +481 -0
  179. package/lib/loops/summary.ts +27 -0
  180. package/lib/loops/tickLoops.ts +141 -0
  181. package/lib/loops/types.ts +223 -0
  182. package/lib/loops/world.test-helpers.ts +223 -0
  183. package/lib/models/agent-binding.ts +102 -0
  184. package/lib/models/agent-roles-view.ts +157 -0
  185. package/lib/models/agent-roles.ts +87 -0
  186. package/lib/models/availability-refs.ts +17 -0
  187. package/lib/models/availability.ts +91 -0
  188. package/lib/models/catalog.ts +156 -0
  189. package/lib/models/config.ts +27 -0
  190. package/lib/models/locality.ts +33 -0
  191. package/lib/models/plan-step.ts +4 -1
  192. package/lib/models/providers.ts +11 -0
  193. package/lib/models/resolve.ts +50 -12
  194. package/lib/models/roles.ts +12 -0
  195. package/lib/needs-you.ts +1 -1
  196. package/lib/orchestrator/digest.ts +2 -1
  197. package/lib/orchestrator/facts.ts +7 -2
  198. package/lib/orchestrator/loop-guard.ts +5 -2
  199. package/lib/orchestrator/pr-wake.ts +4 -0
  200. package/lib/orchestrator/read.ts +5 -1
  201. package/lib/orchestrator/untrusted.ts +5 -0
  202. package/lib/orchestrator/watcher.ts +8 -1
  203. package/lib/panes.ts +52 -0
  204. package/lib/plans/acceptance.ts +23 -0
  205. package/lib/projects.ts +1 -0
  206. package/lib/reports/drafts.ts +87 -0
  207. package/lib/reports/end-hook.ts +17 -0
  208. package/lib/reports/estimate.ts +107 -0
  209. package/lib/reports/evidence/build.ts +193 -0
  210. package/lib/reports/evidence/gather.ts +229 -0
  211. package/lib/reports/fixtures.ts +17 -0
  212. package/lib/reports/generate.ts +159 -0
  213. package/lib/reports/hooks.ts +30 -0
  214. package/lib/reports/mode.ts +65 -0
  215. package/lib/reports/model.ts +115 -0
  216. package/lib/reports/read.ts +26 -0
  217. package/lib/reports/store.ts +180 -0
  218. package/lib/reports/template.ts +117 -0
  219. package/lib/reports/types.ts +135 -0
  220. package/lib/reports/validate.ts +142 -0
  221. package/lib/session-artifacts/agentos-files.ts +94 -0
  222. package/lib/session-artifacts/collect.ts +17 -0
  223. package/lib/sessions/crew.ts +72 -0
  224. package/lib/sessions/launch.ts +49 -9
  225. package/lib/settings-url.ts +1 -0
  226. package/lib/share/markdown.ts +2 -2
  227. package/lib/share/redact.ts +16 -10
  228. package/lib/share/render.tsx +1 -1
  229. package/lib/share/snapshot.ts +2 -2
  230. package/lib/sidebar/row-menu.ts +15 -1
  231. package/lib/tasks/contract/routes.ts +5 -0
  232. package/lib/tasks/contract/types.ts +3 -0
  233. package/lib/tasks/finish.ts +2 -0
  234. package/lib/tasks/index.ts +6 -0
  235. package/lib/tasks/move-carry.ts +1 -0
  236. package/lib/worktree-deps/caches.ts +3 -2
  237. package/lib/worktree-deps/clone.ts +3 -1
  238. package/lib/worktree-deps/paths.ts +26 -0
  239. package/package.json +2 -2
  240. package/server.ts +9 -1
  241. package/stores/analystUi.ts +19 -0
  242. package/stores/exportUi.ts +87 -0
@@ -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
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { memo } from "react";
4
+ import { useSnapshot } from "valtio";
4
5
  import {
5
6
  Check,
6
7
  ChevronRight,
8
+ Download,
7
9
  Circle,
8
10
  CircleDot,
9
11
  Quote,
@@ -24,6 +26,9 @@ import { Markdown } from "./Markdown";
24
26
  import { openSessionLink } from "./session-link";
25
27
  import { UndoButton } from "./Undo";
26
28
  import { useRowState } from "./rowState";
29
+ import { useBookmarkChat } from "@/components/Bookmarks/context";
30
+ import { ChatItemContext } from "./itemContext";
31
+ import { exportUi, exportUiActions, pickedRange } from "@/stores/exportUi";
27
32
  import {
28
33
  BookmarkButton,
29
34
  TurnBookmark,
@@ -153,6 +158,71 @@ function SendStatus({ pending }: { pending: PendingStatus }) {
153
158
  );
154
159
  }
155
160
 
161
+ // The turn this message begins, while turns are being picked for an export.
162
+ function useExportTurn(itemId: string): number | null {
163
+ const ui = useSnapshot(exportUi);
164
+ if (!ui.selecting) return null;
165
+ return ui.turns.find((t) => t.id === itemId)?.n ?? null;
166
+ }
167
+
168
+ // A tick in the gutter and a tinted row: tap anywhere on it to pick the turn.
169
+ function TurnTick({
170
+ turn,
171
+ children,
172
+ }: {
173
+ turn: number;
174
+ children: React.ReactNode;
175
+ }) {
176
+ const { pick } = useSnapshot(exportUi);
177
+ const range = pickedRange(pick);
178
+ const on = !!range && turn >= range.from && turn <= range.to;
179
+ return (
180
+ <div
181
+ className={cn(
182
+ "-mx-2 flex items-start gap-2 rounded-xl px-2",
183
+ on && "bg-primary/10"
184
+ )}
185
+ >
186
+ <button
187
+ type="button"
188
+ role="checkbox"
189
+ aria-checked={on}
190
+ aria-label={`Turn ${turn}`}
191
+ onClick={() => exportUiActions.tap(turn)}
192
+ className={cn(
193
+ "mt-1 flex h-11 min-w-11 shrink-0 flex-col items-center justify-center rounded-md border text-[11px]",
194
+ on
195
+ ? "border-primary bg-primary text-primary-foreground"
196
+ : "text-muted-foreground"
197
+ )}
198
+ >
199
+ {on ? <Check className="h-4 w-4" /> : turn}
200
+ </button>
201
+ <div className="min-w-0 flex-1">{children}</div>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ // Export this message's turn and everything after it.
207
+ function ExportFromHere({ itemId }: { itemId: string }) {
208
+ const chat = useBookmarkChat();
209
+ if (!chat) return null;
210
+ return (
211
+ <button
212
+ type="button"
213
+ aria-label="Export from here"
214
+ title="Export from here"
215
+ onClick={(e) => {
216
+ e.stopPropagation();
217
+ exportUiActions.open(chat.sessionId, "", itemId);
218
+ }}
219
+ className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-11 w-11 shrink-0 items-center justify-center rounded-md opacity-40 group-hover:opacity-100 focus-visible:opacity-100 md:h-7 md:w-7 md:opacity-0"
220
+ >
221
+ <Download className="h-3.5 w-3.5" />
222
+ </button>
223
+ );
224
+ }
225
+
156
226
  export function UserMessage({
157
227
  item,
158
228
  onUndo,
@@ -162,6 +232,7 @@ export function UserMessage({
162
232
  onUndo?: () => void;
163
233
  pending?: PendingStatus;
164
234
  }) {
235
+ const turn = useExportTurn(item.id);
165
236
  const message = (
166
237
  <div className="group flex flex-col items-end gap-1">
167
238
  {item.from && (
@@ -204,6 +275,7 @@ export function UserMessage({
204
275
  ) : (
205
276
  <div className="-mr-1.5 flex items-center">
206
277
  {onUndo && <UndoButton onClick={onUndo} />}
278
+ <ExportFromHere itemId={item.id} />
207
279
  <BookmarkButton itemId={item.id} />
208
280
  </div>
209
281
  )}
@@ -211,7 +283,8 @@ export function UserMessage({
211
283
  );
212
284
  // Not in the chat yet: nothing to star.
213
285
  if (pending) return message;
214
- return <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
286
+ const row = <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
287
+ return turn ? <TurnTick turn={turn}>{row}</TurnTick> : row;
215
288
  }
216
289
 
217
290
  export function AssistantMessage({
@@ -221,11 +294,17 @@ export function AssistantMessage({
221
294
  item: Of<"assistant">;
222
295
  onQuote?: (text: string) => void;
223
296
  }) {
297
+ // A reply whose only content was a tool call AgentOS ran for it.
298
+ if (!item.streaming && !item.text.trim()) return null;
224
299
  return (
225
300
  <BookmarkPress itemId={item.id}>
226
301
  <div className="group">
227
302
  <div data-quotable className={cn(item.streaming && "streaming-cursor")}>
228
- <Markdown text={item.text} streaming={item.streaming} />
303
+ <ChatItemContext.Provider
304
+ value={{ itemId: item.id, streaming: !!item.streaming }}
305
+ >
306
+ <Markdown text={item.text} streaming={item.streaming} />
307
+ </ChatItemContext.Provider>
229
308
  </div>
230
309
  {!item.streaming && (
231
310
  <div className="-ml-1.5 flex items-center">
@@ -300,18 +379,38 @@ export function Todos({ item }: { item: Of<"todos"> }) {
300
379
 
301
380
  export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
302
381
  const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
382
+ const early = !item.interrupted && item.ended;
303
383
  const label = item.interrupted
304
384
  ? "Stopped"
305
- : secs !== null
306
- ? `Done in ${secs}s`
307
- : "Done";
385
+ : early
386
+ ? secs !== null
387
+ ? `Ended early in ${secs}s`
388
+ : "Ended early"
389
+ : secs !== null
390
+ ? `Done in ${secs}s`
391
+ : "Done";
308
392
  return (
309
- <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
310
- <span className="bg-foreground/[0.06] h-px flex-1" />
311
- {label}
312
- <TurnBookmark turnEndId={item.id} />
313
- <span className="bg-foreground/[0.06] h-px flex-1" />
314
- </div>
393
+ <>
394
+ {early && (
395
+ <div
396
+ role="status"
397
+ className="rounded-xl bg-amber-500/10 px-3.5 py-2.5 text-sm text-amber-700 dark:text-amber-300"
398
+ >
399
+ {early.notice}
400
+ {item.finishReason && (
401
+ <span className="mt-1 block font-mono text-[11px] opacity-80">
402
+ finish reason: {item.finishReason}
403
+ </span>
404
+ )}
405
+ </div>
406
+ )}
407
+ <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
408
+ <span className="bg-foreground/[0.06] h-px flex-1" />
409
+ {label}
410
+ <TurnBookmark turnEndId={item.id} />
411
+ <span className="bg-foreground/[0.06] h-px flex-1" />
412
+ </div>
413
+ </>
315
414
  );
316
415
  }
317
416
 
@@ -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,
@@ -21,6 +22,7 @@ import { useAgentStatusQuery } from "@/data/agents";
21
22
  import { useProjectMachinesQuery } from "@/data/projects";
22
23
  import { modelChoices, useModelsQuery } from "@/data/models";
23
24
  import type { AgentProbe } from "@/lib/agents/probe";
25
+ import { RoleChips } from "./RoleChips";
24
26
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
25
27
  import {
26
28
  DEFAULT_PRIORITY,
@@ -168,7 +170,7 @@ export function DraftChips({
168
170
  </ChipMenu>
169
171
  )}
170
172
 
171
- {!draft.openPr && (
173
+ {!draft.openPr && !draft.agentRole && (
172
174
  <ChipMenu icon={Bot} name="Agent" label={agentLabel(draft.agentType)}>
173
175
  {AGENT_OPTIONS.map((o) => (
174
176
  <ChipItem
@@ -189,6 +191,10 @@ export function DraftChips({
189
191
  </ChipMenu>
190
192
  )}
191
193
 
194
+ {!draft.openPr && local && (
195
+ <RoleChips draft={draft} onChange={onChange} />
196
+ )}
197
+
192
198
  {project && local && git?.isGitRepo && (
193
199
  <ChipMenu
194
200
  icon={GitBranch}
@@ -247,6 +253,15 @@ export function DraftChips({
247
253
  onChange={(openPr) => onChange({ openPr })}
248
254
  />
249
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
+ )}
250
265
  {project && draft.openPr && local && (
251
266
  <ChipMenu
252
267
  icon={Cpu}
@@ -14,6 +14,8 @@ import { useHostsQuery } from "@/data/hosts";
14
14
  import { useGitCheck } from "@/data/git/queries";
15
15
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
16
16
  import { useLaunchSession } from "@/data/sessions";
17
+ import { ModelUnavailableError } from "@/data/sessions/queries";
18
+ import { isAgentRole } from "@/lib/models/agent-roles";
17
19
  import { useCreateTask } from "@/data/tasks";
18
20
  import { useCheckStart } from "@/data/claims";
19
21
  import { StartClaimsNotice } from "@/components/Claims";
@@ -21,8 +23,10 @@ import type { StartWarning } from "@/lib/claims/startWarning";
21
23
  import { usePanes } from "@/contexts/PaneContext";
22
24
  import { setPendingPrompt } from "@/stores/initialPrompt";
23
25
  import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
26
+ import { useStartAnalystBeside } from "@/components/Analyst/pair";
24
27
  import { DraftChips } from "./DraftChips";
25
28
  import { DraftHeading } from "./DraftHeading";
29
+ import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
26
30
  import { draftModels } from "./draftModels";
27
31
 
28
32
  // A new session before its first send: choices on top, a composer below.
@@ -54,6 +58,7 @@ export function DraftPanel({
54
58
  discovers
55
59
  );
56
60
  const launch = useLaunchSession();
61
+ const startAnalyst = useStartAnalystBeside();
57
62
  const { workspace } = useSelectedWorkspace();
58
63
  const createTask = useCreateTask();
59
64
  const checkStart = useCheckStart();
@@ -63,6 +68,13 @@ export function DraftPanel({
63
68
  text: string;
64
69
  warnings: StartWarning[];
65
70
  } | null>(null);
71
+ // A role's model server that didn't answer, with what was being sent.
72
+ const [down, setDown] = useState<{
73
+ error: ModelUnavailableError;
74
+ text: string;
75
+ images: ChatImage[];
76
+ files: ChatFile[];
77
+ } | null>(null);
66
78
  const pending =
67
79
  launch.isPending || createTask.isPending || checkStart.isPending;
68
80
 
@@ -81,7 +93,7 @@ export function DraftPanel({
81
93
  text: string,
82
94
  images: ChatImage[],
83
95
  files: ChatFile[],
84
- claim?: { after?: string }
96
+ claim?: { after?: string; onUnavailable?: "fallback" }
85
97
  ) => {
86
98
  try {
87
99
  if (draft.openPr) {
@@ -129,7 +141,17 @@ export function DraftPanel({
129
141
  ? draft.hostId
130
142
  : undefined,
131
143
  agentType: draft.agentType,
132
- model: draft.model,
144
+ model: isAgentRole(draft.agentRole)
145
+ ? (draft.roleModel ?? "")
146
+ : draft.model,
147
+ ...(isAgentRole(draft.agentRole)
148
+ ? {
149
+ agentRole: draft.agentRole,
150
+ ...(claim?.onUnavailable && {
151
+ onUnavailable: claim.onUnavailable,
152
+ }),
153
+ }
154
+ : {}),
133
155
  access: draft.access,
134
156
  useWorktree: draft.useWorktree && !!git?.isGitRepo,
135
157
  baseBranch: draft.baseBranch,
@@ -152,7 +174,16 @@ export function DraftPanel({
152
174
  session.tmux_name ?? "",
153
175
  session.host_id
154
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
+ }
155
182
  } catch (error) {
183
+ if (error instanceof ModelUnavailableError) {
184
+ setDown({ error, text, images, files });
185
+ return;
186
+ }
156
187
  setPrefill({ text, at: Date.now() });
157
188
  toast.error(error instanceof Error ? error.message : String(error));
158
189
  }
@@ -172,6 +203,31 @@ export function DraftPanel({
172
203
  git={git}
173
204
  onChange={(patch) => draftsActions.update(draft.id, patch)}
174
205
  />
206
+ <ModelUnavailableDialog
207
+ error={down?.error ?? null}
208
+ busy={pending}
209
+ onClose={() => {
210
+ if (down) setPrefill({ text: down.text, at: Date.now() });
211
+ setDown(null);
212
+ }}
213
+ onRetry={() => {
214
+ const d = down;
215
+ setDown(null);
216
+ if (d) void send(d.text, d.images, d.files);
217
+ }}
218
+ onFallback={() => {
219
+ const d = down;
220
+ setDown(null);
221
+ if (d)
222
+ void send(d.text, d.images, d.files, {
223
+ onUnavailable: "fallback",
224
+ });
225
+ }}
226
+ onPick={() => {
227
+ if (down) setPrefill({ text: down.text, at: Date.now() });
228
+ setDown(null);
229
+ }}
230
+ />
175
231
  {held && (
176
232
  <StartClaimsNotice
177
233
  warnings={held.warnings}
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { AGENT_ROLE_DEFS, isAgentRole } from "@/lib/models/agent-roles";
12
+ import type { ModelUnavailableError } from "@/data/sessions/queries";
13
+
14
+ // A session in a role couldn't start: its model's server isn't answering.
15
+ // A local model never turns into a cloud one on its own: starting on the
16
+ // role's fallback is a choice made here.
17
+ export function ModelUnavailableDialog({
18
+ error,
19
+ busy,
20
+ onRetry,
21
+ onFallback,
22
+ onPick,
23
+ onClose,
24
+ }: {
25
+ error: ModelUnavailableError | null;
26
+ busy: boolean;
27
+ onRetry: () => void;
28
+ onFallback: () => void;
29
+ onPick: () => void;
30
+ onClose: () => void;
31
+ }) {
32
+ const role =
33
+ error && isAgentRole(error.role)
34
+ ? AGENT_ROLE_DEFS[error.role].label
35
+ : "role";
36
+ const name = (ref: string) => ref.split("/").slice(1).join("/");
37
+ return (
38
+ <Dialog open={!!error} onOpenChange={(o) => !o && onClose()}>
39
+ <DialogContent>
40
+ <DialogHeader>
41
+ <DialogTitle>Can&apos;t start the {role}</DialogTitle>
42
+ <DialogDescription>
43
+ {error ? `${name(error.ref)}'s server isn't answering.` : ""}
44
+ </DialogDescription>
45
+ </DialogHeader>
46
+ {error && (
47
+ <p className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 font-mono text-xs break-words">
48
+ {error.message}
49
+ </p>
50
+ )}
51
+ <div className="flex flex-col gap-2">
52
+ <Button className="h-11" disabled={busy} onClick={onRetry}>
53
+ Retry
54
+ </Button>
55
+ {error?.fallback && (
56
+ <Button
57
+ variant="outline"
58
+ className="h-11"
59
+ disabled={busy}
60
+ onClick={onFallback}
61
+ >
62
+ Start on {name(error.fallback)}
63
+ </Button>
64
+ )}
65
+ <Button variant="outline" className="h-11" onClick={onPick}>
66
+ Pick another model
67
+ </Button>
68
+ </div>
69
+ {error?.fallback && (
70
+ <p className="text-muted-foreground text-xs">
71
+ Starting on the fallback sends your code to wherever that model
72
+ runs.
73
+ </p>
74
+ )}
75
+ </DialogContent>
76
+ </Dialog>
77
+ );
78
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { Cpu, Users } from "lucide-react";
4
+ import {
5
+ AvailabilityDot,
6
+ LocalityBadge,
7
+ } from "@/components/Models/ModelBadges";
8
+ import { useAgentRolesQuery, useModelChoices } from "@/data/models";
9
+ import type { Draft } from "@/lib/drafts";
10
+ import {
11
+ AGENT_ROLES,
12
+ AGENT_ROLE_DEFS,
13
+ isAgentRole,
14
+ } from "@/lib/models/agent-roles";
15
+ import { ChipItem, ChipMenu } from "./Chip";
16
+
17
+ // A draft's role and the model it runs on. The role's binding gives the
18
+ // model (Settings > Agent roles); the model chip overrides it for this
19
+ // session. Both lists come from the server.
20
+ export function RoleChips({
21
+ draft,
22
+ onChange,
23
+ }: {
24
+ draft: Draft;
25
+ onChange: (patch: Partial<Draft>) => void;
26
+ }) {
27
+ const roles = useAgentRolesQuery("settings");
28
+ const { data: choices = [] } = useModelChoices(!!draft.agentRole);
29
+ const role = isAgentRole(draft.agentRole) ? draft.agentRole : null;
30
+ const bound = roles.data?.roles.find((r) => r.id === role);
31
+ const override = draft.roleModel
32
+ ? choices.find((c) => c.ref === draft.roleModel)
33
+ : undefined;
34
+ const current = override ?? bound?.choice ?? null;
35
+ const modelName = (ref: string | undefined | null) =>
36
+ ref ? ref.split("/").slice(1).join("/") : "…";
37
+ return (
38
+ <>
39
+ <ChipMenu
40
+ icon={Users}
41
+ name="Role"
42
+ label={role ? AGENT_ROLE_DEFS[role].label : "No role"}
43
+ >
44
+ <ChipItem
45
+ checked={!role}
46
+ onSelect={() => onChange({ agentRole: null, roleModel: null })}
47
+ hint="A plain session on the interactive model"
48
+ >
49
+ No role
50
+ </ChipItem>
51
+ {AGENT_ROLES.map((r) => {
52
+ const view = roles.data?.roles.find((x) => x.id === r);
53
+ return (
54
+ <ChipItem
55
+ key={r}
56
+ checked={r === role}
57
+ onSelect={() =>
58
+ onChange({ agentRole: r, roleModel: null, openPr: false })
59
+ }
60
+ hint={`${AGENT_ROLE_DEFS[r].purpose}${view?.binding ? ` · ${modelName(view.binding.ref)}` : ""}`}
61
+ >
62
+ {AGENT_ROLE_DEFS[r].label}
63
+ </ChipItem>
64
+ );
65
+ })}
66
+ </ChipMenu>
67
+ {role && (
68
+ <ChipMenu
69
+ icon={Cpu}
70
+ name="Model"
71
+ label={
72
+ draft.roleModel
73
+ ? modelName(draft.roleModel)
74
+ : `Role model: ${modelName(bound?.binding?.ref)}`
75
+ }
76
+ >
77
+ <ChipItem
78
+ checked={!draft.roleModel}
79
+ onSelect={() => onChange({ roleModel: null })}
80
+ hint={bound?.binding ? modelName(bound.binding.ref) : undefined}
81
+ >
82
+ Role model
83
+ </ChipItem>
84
+ {choices.map((c) => (
85
+ <ChipItem
86
+ key={c.ref}
87
+ checked={c.ref === draft.roleModel}
88
+ onSelect={() => onChange({ roleModel: c.ref })}
89
+ hint={c.group}
90
+ >
91
+ <span className="flex items-center gap-1.5">
92
+ <AvailabilityDot availability={c.availability} />
93
+ {c.label}
94
+ <LocalityBadge locality={c.locality} />
95
+ </span>
96
+ </ChipItem>
97
+ ))}
98
+ </ChipMenu>
99
+ )}
100
+ {role && current?.availability.state === "down" && (
101
+ <span
102
+ role="status"
103
+ className="text-destructive flex h-11 items-center text-xs md:h-8"
104
+ >
105
+ {modelName(current.ref)} isn&apos;t answering
106
+ </span>
107
+ )}
108
+ </>
109
+ );
110
+ }