@saadnvd1/agent-os 0.3.715 → 0.3.723

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 (142) hide show
  1. package/README.md +1 -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/models/availability/route.ts +9 -0
  12. package/app/api/models/choices/route.ts +9 -0
  13. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  14. package/app/api/sessions/[id]/crew/route.ts +38 -0
  15. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  16. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  17. package/app/api/sessions/[id]/export/route.ts +34 -0
  18. package/app/api/sessions/[id]/model/route.ts +49 -0
  19. package/app/api/sessions/route.ts +16 -0
  20. package/app/page.tsx +5 -0
  21. package/components/Chat/Messages.tsx +104 -10
  22. package/components/Draft/DraftChips.tsx +6 -1
  23. package/components/Draft/DraftPanel.tsx +51 -2
  24. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  25. package/components/Draft/RoleChips.tsx +110 -0
  26. package/components/Export/ExportActions.tsx +136 -0
  27. package/components/Export/ExportSheet.tsx +217 -0
  28. package/components/Export/RangePicker.tsx +144 -0
  29. package/components/Export/Seg.tsx +38 -0
  30. package/components/Export/TurnSelectBar.tsx +45 -0
  31. package/components/Models/AgentRoleCard.tsx +114 -0
  32. package/components/Models/AgentRolesSection.tsx +132 -0
  33. package/components/Models/ModelBadges.tsx +48 -0
  34. package/components/Models/ModelsSection.tsx +4 -2
  35. package/components/Models/RoleModelSelect.tsx +87 -0
  36. package/components/Pane/DesktopTabBar.tsx +2 -0
  37. package/components/Pane/MobileTabBar.tsx +12 -0
  38. package/components/Pane/PaneMenu.tsx +10 -0
  39. package/components/SessionList/SessionRow.tsx +18 -7
  40. package/components/SessionList/SessionRowMenu.tsx +6 -0
  41. package/components/Sessions/RolePill.tsx +196 -0
  42. package/components/Settings/SettingsDialog.tsx +9 -0
  43. package/components/Share/ShareDialog.tsx +13 -0
  44. package/data/analyst/index.ts +3 -0
  45. package/data/analyst/keys.ts +6 -0
  46. package/data/analyst/mutations.ts +116 -0
  47. package/data/analyst/queries.ts +35 -0
  48. package/data/exports/index.ts +2 -0
  49. package/data/exports/keys.ts +3 -0
  50. package/data/exports/queries.ts +51 -0
  51. package/data/models/index.ts +98 -0
  52. package/data/push/topics.ts +5 -0
  53. package/data/sessions/queries.ts +26 -0
  54. package/lib/agents/brief.ts +1 -0
  55. package/lib/analyst/__fixtures__/items.ts +57 -0
  56. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  57. package/lib/analyst/brief.ts +17 -0
  58. package/lib/analyst/buildDigest.ts +204 -0
  59. package/lib/analyst/createLink.ts +98 -0
  60. package/lib/analyst/endLink.ts +31 -0
  61. package/lib/analyst/forward.ts +109 -0
  62. package/lib/analyst/index.ts +16 -0
  63. package/lib/analyst/linkView.ts +38 -0
  64. package/lib/analyst/onTurnEnd.ts +119 -0
  65. package/lib/analyst/parseReview.ts +41 -0
  66. package/lib/analyst/pushDigest.ts +143 -0
  67. package/lib/analyst/resume.ts +35 -0
  68. package/lib/analyst/route.ts +50 -0
  69. package/lib/analyst/signals.ts +121 -0
  70. package/lib/analyst/store.ts +303 -0
  71. package/lib/analyst/types.ts +140 -0
  72. package/lib/bus/condense.ts +5 -1
  73. package/lib/bus/index.ts +5 -0
  74. package/lib/bus/read-scope.ts +12 -0
  75. package/lib/bus/role-alias.ts +70 -0
  76. package/lib/bus/targets.ts +3 -0
  77. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  78. package/lib/chat/drivers/pi-approvals.ts +7 -4
  79. package/lib/chat/drivers/pi-mapper.ts +38 -0
  80. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  81. package/lib/chat/drivers/pi.ts +8 -3
  82. package/lib/chat/drivers/turn-items.ts +36 -11
  83. package/lib/chat/drivers/turn-outcome.ts +108 -0
  84. package/lib/chat/events.ts +8 -0
  85. package/lib/chat/registry.ts +13 -0
  86. package/lib/chat/store.ts +19 -0
  87. package/lib/chat/turns.ts +1 -0
  88. package/lib/chat/worker/extras.ts +4 -2
  89. package/lib/claims/collectLocal.ts +3 -1
  90. package/lib/db/changes.ts +6 -0
  91. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  92. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  93. package/lib/db/session-parts/columns.ts +2 -0
  94. package/lib/db/types.ts +3 -0
  95. package/lib/done/archive.ts +2 -0
  96. package/lib/drafts.ts +4 -0
  97. package/lib/export/build.ts +470 -0
  98. package/lib/export/compact.ts +156 -0
  99. package/lib/export/estimate.ts +83 -0
  100. package/lib/export/fixtures.ts +45 -0
  101. package/lib/export/html.tsx +195 -0
  102. package/lib/export/json.ts +39 -0
  103. package/lib/export/load.ts +64 -0
  104. package/lib/export/markdown.ts +127 -0
  105. package/lib/export/request.ts +36 -0
  106. package/lib/export/service.ts +142 -0
  107. package/lib/export/summary.ts +86 -0
  108. package/lib/export/turns.ts +63 -0
  109. package/lib/export/view.ts +173 -0
  110. package/lib/models/agent-binding.ts +102 -0
  111. package/lib/models/agent-roles-view.ts +157 -0
  112. package/lib/models/agent-roles.ts +87 -0
  113. package/lib/models/availability-refs.ts +17 -0
  114. package/lib/models/availability.ts +91 -0
  115. package/lib/models/catalog.ts +156 -0
  116. package/lib/models/config.ts +27 -0
  117. package/lib/models/locality.ts +33 -0
  118. package/lib/models/providers.ts +11 -0
  119. package/lib/models/resolve.ts +50 -12
  120. package/lib/models/roles.ts +12 -0
  121. package/lib/needs-you.ts +1 -1
  122. package/lib/orchestrator/facts.ts +7 -2
  123. package/lib/orchestrator/read.ts +5 -1
  124. package/lib/orchestrator/untrusted.ts +5 -0
  125. package/lib/projects.ts +1 -0
  126. package/lib/session-artifacts/agentos-files.ts +94 -0
  127. package/lib/session-artifacts/collect.ts +17 -0
  128. package/lib/sessions/crew.ts +72 -0
  129. package/lib/sessions/launch.ts +49 -9
  130. package/lib/settings-url.ts +1 -0
  131. package/lib/share/markdown.ts +2 -2
  132. package/lib/share/redact.ts +16 -10
  133. package/lib/share/render.tsx +1 -1
  134. package/lib/share/snapshot.ts +2 -2
  135. package/lib/sidebar/row-menu.ts +6 -1
  136. package/lib/tasks/index.ts +6 -0
  137. package/lib/worktree-deps/caches.ts +3 -2
  138. package/lib/worktree-deps/clone.ts +3 -1
  139. package/lib/worktree-deps/paths.ts +26 -0
  140. package/package.json +1 -1
  141. package/server.ts +9 -1
  142. package/stores/exportUi.ts +87 -0
package/app/page.tsx CHANGED
@@ -138,6 +138,10 @@ const ShareDialog = dynamic(
138
138
  () => import("@/components/Share/ShareDialog").then((m) => m.ShareDialog),
139
139
  { ssr: false }
140
140
  );
141
+ const ExportSheet = dynamic(
142
+ () => import("@/components/Export/ExportSheet").then((m) => m.ExportSheet),
143
+ { ssr: false }
144
+ );
141
145
  const CommandPalette = dynamic(
142
146
  () =>
143
147
  import("@/components/CommandPalette/CommandPalette").then(
@@ -166,6 +170,7 @@ const AppDialogs = memo(function AppDialogs({
166
170
  <ClaimsSheet />
167
171
  <MoveDialog />
168
172
  <ShareDialog />
173
+ <ExportSheet />
169
174
  <AwayReport />
170
175
  <IncomingLinks />
171
176
  <CommandPalette />
@@ -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,8 @@ 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 { exportUi, exportUiActions, pickedRange } from "@/stores/exportUi";
27
31
  import {
28
32
  BookmarkButton,
29
33
  TurnBookmark,
@@ -153,6 +157,71 @@ function SendStatus({ pending }: { pending: PendingStatus }) {
153
157
  );
154
158
  }
155
159
 
160
+ // The turn this message begins, while turns are being picked for an export.
161
+ function useExportTurn(itemId: string): number | null {
162
+ const ui = useSnapshot(exportUi);
163
+ if (!ui.selecting) return null;
164
+ return ui.turns.find((t) => t.id === itemId)?.n ?? null;
165
+ }
166
+
167
+ // A tick in the gutter and a tinted row: tap anywhere on it to pick the turn.
168
+ function TurnTick({
169
+ turn,
170
+ children,
171
+ }: {
172
+ turn: number;
173
+ children: React.ReactNode;
174
+ }) {
175
+ const { pick } = useSnapshot(exportUi);
176
+ const range = pickedRange(pick);
177
+ const on = !!range && turn >= range.from && turn <= range.to;
178
+ return (
179
+ <div
180
+ className={cn(
181
+ "-mx-2 flex items-start gap-2 rounded-xl px-2",
182
+ on && "bg-primary/10"
183
+ )}
184
+ >
185
+ <button
186
+ type="button"
187
+ role="checkbox"
188
+ aria-checked={on}
189
+ aria-label={`Turn ${turn}`}
190
+ onClick={() => exportUiActions.tap(turn)}
191
+ className={cn(
192
+ "mt-1 flex h-11 min-w-11 shrink-0 flex-col items-center justify-center rounded-md border text-[11px]",
193
+ on
194
+ ? "border-primary bg-primary text-primary-foreground"
195
+ : "text-muted-foreground"
196
+ )}
197
+ >
198
+ {on ? <Check className="h-4 w-4" /> : turn}
199
+ </button>
200
+ <div className="min-w-0 flex-1">{children}</div>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ // Export this message's turn and everything after it.
206
+ function ExportFromHere({ itemId }: { itemId: string }) {
207
+ const chat = useBookmarkChat();
208
+ if (!chat) return null;
209
+ return (
210
+ <button
211
+ type="button"
212
+ aria-label="Export from here"
213
+ title="Export from here"
214
+ onClick={(e) => {
215
+ e.stopPropagation();
216
+ exportUiActions.open(chat.sessionId, "", itemId);
217
+ }}
218
+ 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"
219
+ >
220
+ <Download className="h-3.5 w-3.5" />
221
+ </button>
222
+ );
223
+ }
224
+
156
225
  export function UserMessage({
157
226
  item,
158
227
  onUndo,
@@ -162,6 +231,7 @@ export function UserMessage({
162
231
  onUndo?: () => void;
163
232
  pending?: PendingStatus;
164
233
  }) {
234
+ const turn = useExportTurn(item.id);
165
235
  const message = (
166
236
  <div className="group flex flex-col items-end gap-1">
167
237
  {item.from && (
@@ -204,6 +274,7 @@ export function UserMessage({
204
274
  ) : (
205
275
  <div className="-mr-1.5 flex items-center">
206
276
  {onUndo && <UndoButton onClick={onUndo} />}
277
+ <ExportFromHere itemId={item.id} />
207
278
  <BookmarkButton itemId={item.id} />
208
279
  </div>
209
280
  )}
@@ -211,7 +282,8 @@ export function UserMessage({
211
282
  );
212
283
  // Not in the chat yet: nothing to star.
213
284
  if (pending) return message;
214
- return <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
285
+ const row = <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
286
+ return turn ? <TurnTick turn={turn}>{row}</TurnTick> : row;
215
287
  }
216
288
 
217
289
  export function AssistantMessage({
@@ -221,6 +293,8 @@ export function AssistantMessage({
221
293
  item: Of<"assistant">;
222
294
  onQuote?: (text: string) => void;
223
295
  }) {
296
+ // A reply whose only content was a tool call AgentOS ran for it.
297
+ if (!item.streaming && !item.text.trim()) return null;
224
298
  return (
225
299
  <BookmarkPress itemId={item.id}>
226
300
  <div className="group">
@@ -300,18 +374,38 @@ export function Todos({ item }: { item: Of<"todos"> }) {
300
374
 
301
375
  export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
302
376
  const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
377
+ const early = !item.interrupted && item.ended;
303
378
  const label = item.interrupted
304
379
  ? "Stopped"
305
- : secs !== null
306
- ? `Done in ${secs}s`
307
- : "Done";
380
+ : early
381
+ ? secs !== null
382
+ ? `Ended early in ${secs}s`
383
+ : "Ended early"
384
+ : secs !== null
385
+ ? `Done in ${secs}s`
386
+ : "Done";
308
387
  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>
388
+ <>
389
+ {early && (
390
+ <div
391
+ role="status"
392
+ className="rounded-xl bg-amber-500/10 px-3.5 py-2.5 text-sm text-amber-700 dark:text-amber-300"
393
+ >
394
+ {early.notice}
395
+ {item.finishReason && (
396
+ <span className="mt-1 block font-mono text-[11px] opacity-80">
397
+ finish reason: {item.finishReason}
398
+ </span>
399
+ )}
400
+ </div>
401
+ )}
402
+ <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
403
+ <span className="bg-foreground/[0.06] h-px flex-1" />
404
+ {label}
405
+ <TurnBookmark turnEndId={item.id} />
406
+ <span className="bg-foreground/[0.06] h-px flex-1" />
407
+ </div>
408
+ </>
315
409
  );
316
410
  }
317
411
 
@@ -21,6 +21,7 @@ import { useAgentStatusQuery } from "@/data/agents";
21
21
  import { useProjectMachinesQuery } from "@/data/projects";
22
22
  import { modelChoices, useModelsQuery } from "@/data/models";
23
23
  import type { AgentProbe } from "@/lib/agents/probe";
24
+ import { RoleChips } from "./RoleChips";
24
25
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
25
26
  import {
26
27
  DEFAULT_PRIORITY,
@@ -168,7 +169,7 @@ export function DraftChips({
168
169
  </ChipMenu>
169
170
  )}
170
171
 
171
- {!draft.openPr && (
172
+ {!draft.openPr && !draft.agentRole && (
172
173
  <ChipMenu icon={Bot} name="Agent" label={agentLabel(draft.agentType)}>
173
174
  {AGENT_OPTIONS.map((o) => (
174
175
  <ChipItem
@@ -189,6 +190,10 @@ export function DraftChips({
189
190
  </ChipMenu>
190
191
  )}
191
192
 
193
+ {!draft.openPr && local && (
194
+ <RoleChips draft={draft} onChange={onChange} />
195
+ )}
196
+
192
197
  {project && local && git?.isGitRepo && (
193
198
  <ChipMenu
194
199
  icon={GitBranch}
@@ -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";
@@ -23,6 +25,7 @@ import { setPendingPrompt } from "@/stores/initialPrompt";
23
25
  import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
24
26
  import { DraftChips } from "./DraftChips";
25
27
  import { DraftHeading } from "./DraftHeading";
28
+ import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
26
29
  import { draftModels } from "./draftModels";
27
30
 
28
31
  // A new session before its first send: choices on top, a composer below.
@@ -63,6 +66,13 @@ export function DraftPanel({
63
66
  text: string;
64
67
  warnings: StartWarning[];
65
68
  } | null>(null);
69
+ // A role's model server that didn't answer, with what was being sent.
70
+ const [down, setDown] = useState<{
71
+ error: ModelUnavailableError;
72
+ text: string;
73
+ images: ChatImage[];
74
+ files: ChatFile[];
75
+ } | null>(null);
66
76
  const pending =
67
77
  launch.isPending || createTask.isPending || checkStart.isPending;
68
78
 
@@ -81,7 +91,7 @@ export function DraftPanel({
81
91
  text: string,
82
92
  images: ChatImage[],
83
93
  files: ChatFile[],
84
- claim?: { after?: string }
94
+ claim?: { after?: string; onUnavailable?: "fallback" }
85
95
  ) => {
86
96
  try {
87
97
  if (draft.openPr) {
@@ -129,7 +139,17 @@ export function DraftPanel({
129
139
  ? draft.hostId
130
140
  : undefined,
131
141
  agentType: draft.agentType,
132
- model: draft.model,
142
+ model: isAgentRole(draft.agentRole)
143
+ ? (draft.roleModel ?? "")
144
+ : draft.model,
145
+ ...(isAgentRole(draft.agentRole)
146
+ ? {
147
+ agentRole: draft.agentRole,
148
+ ...(claim?.onUnavailable && {
149
+ onUnavailable: claim.onUnavailable,
150
+ }),
151
+ }
152
+ : {}),
133
153
  access: draft.access,
134
154
  useWorktree: draft.useWorktree && !!git?.isGitRepo,
135
155
  baseBranch: draft.baseBranch,
@@ -153,6 +173,10 @@ export function DraftPanel({
153
173
  session.host_id
154
174
  );
155
175
  } catch (error) {
176
+ if (error instanceof ModelUnavailableError) {
177
+ setDown({ error, text, images, files });
178
+ return;
179
+ }
156
180
  setPrefill({ text, at: Date.now() });
157
181
  toast.error(error instanceof Error ? error.message : String(error));
158
182
  }
@@ -172,6 +196,31 @@ export function DraftPanel({
172
196
  git={git}
173
197
  onChange={(patch) => draftsActions.update(draft.id, patch)}
174
198
  />
199
+ <ModelUnavailableDialog
200
+ error={down?.error ?? null}
201
+ busy={pending}
202
+ onClose={() => {
203
+ if (down) setPrefill({ text: down.text, at: Date.now() });
204
+ setDown(null);
205
+ }}
206
+ onRetry={() => {
207
+ const d = down;
208
+ setDown(null);
209
+ if (d) void send(d.text, d.images, d.files);
210
+ }}
211
+ onFallback={() => {
212
+ const d = down;
213
+ setDown(null);
214
+ if (d)
215
+ void send(d.text, d.images, d.files, {
216
+ onUnavailable: "fallback",
217
+ });
218
+ }}
219
+ onPick={() => {
220
+ if (down) setPrefill({ text: down.text, at: Date.now() });
221
+ setDown(null);
222
+ }}
223
+ />
175
224
  {held && (
176
225
  <StartClaimsNotice
177
226
  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
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Check, Copy, Download, Loader2, Share2 } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { writeClipboard } from "@/hooks/useCopyToClipboard";
8
+ import { fetchExport, type ExportEstimate } from "@/data/exports";
9
+ import type { ExportRequest } from "@/lib/export/view";
10
+
11
+ type Busy = "copy" | "download" | "share" | null;
12
+
13
+ function save(blob: Blob, name: string) {
14
+ const url = URL.createObjectURL(blob);
15
+ const a = document.createElement("a");
16
+ a.href = url;
17
+ a.download = name;
18
+ a.click();
19
+ URL.revokeObjectURL(url);
20
+ }
21
+
22
+ // Copy for agent, Download and (where the browser can) Share. Never blocked
23
+ // by a sensitive-data warning, only by the size limit.
24
+ export function ExportActions({
25
+ sessionId,
26
+ title,
27
+ req,
28
+ est,
29
+ }: {
30
+ sessionId: string;
31
+ title: string;
32
+ req: ExportRequest;
33
+ est: ExportEstimate | null;
34
+ }) {
35
+ const [busy, setBusy] = useState<Busy>(null);
36
+ const [copied, setCopied] = useState(false);
37
+ // Only rendered on the client (the sheet loads with ssr off).
38
+ const [canShare] = useState(() => {
39
+ try {
40
+ const probe = new File([""], "x.md", { type: "text/markdown" });
41
+ return !!navigator.canShare?.({ files: [probe] });
42
+ } catch {
43
+ return false;
44
+ }
45
+ });
46
+
47
+ const wire = JSON.parse(JSON.stringify(req)) as ExportRequest;
48
+ const file = () => fetchExport(sessionId, wire, title, est?.selected ?? null);
49
+ const disabled = !est || est.tooBig || busy !== null;
50
+
51
+ const run =
52
+ (kind: Exclude<Busy, null>, work: () => Promise<void>) => async () => {
53
+ setBusy(kind);
54
+ try {
55
+ await work();
56
+ } catch (err) {
57
+ if ((err as Error).name !== "AbortError")
58
+ toast.error((err as Error).message || "Export failed");
59
+ } finally {
60
+ setBusy(null);
61
+ }
62
+ };
63
+
64
+ const copy = run("copy", async () => {
65
+ const pending = file();
66
+ // Safari only allows a copy started inside the tap: hand it the promise.
67
+ if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
68
+ await navigator.clipboard.write([
69
+ new ClipboardItem({
70
+ "text/plain": pending.then(
71
+ (f) => new Blob([f.blob], { type: "text/plain" })
72
+ ),
73
+ }),
74
+ ]);
75
+ } else {
76
+ await writeClipboard(await (await pending).blob.text());
77
+ }
78
+ setCopied(true);
79
+ setTimeout(() => setCopied(false), 1500);
80
+ toast.success("Copied. Paste it into your AI chat.");
81
+ });
82
+
83
+ const download = run("download", async () => {
84
+ const f = await file();
85
+ save(f.blob, f.name);
86
+ });
87
+
88
+ const share = run("share", async () => {
89
+ const f = await file();
90
+ const shared = new File([f.blob], f.name, { type: f.blob.type });
91
+ if (!navigator.canShare?.({ files: [shared] })) return save(f.blob, f.name);
92
+ await navigator.share({ files: [shared], title });
93
+ });
94
+
95
+ const Spin = <Loader2 className="h-4 w-4 animate-spin" />;
96
+ return (
97
+ <div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
98
+ <Button
99
+ className="min-h-11 sm:order-last"
100
+ disabled={disabled}
101
+ onClick={copy}
102
+ >
103
+ {busy === "copy" ? (
104
+ Spin
105
+ ) : copied ? (
106
+ <Check className="h-4 w-4" />
107
+ ) : (
108
+ <Copy className="h-4 w-4" />
109
+ )}
110
+ {req.format === "md" ? "Copy for agent" : "Copy"}
111
+ </Button>
112
+ <div className="flex gap-2">
113
+ <Button
114
+ variant="outline"
115
+ className="min-h-11 flex-1 sm:flex-none"
116
+ disabled={disabled}
117
+ onClick={download}
118
+ >
119
+ {busy === "download" ? Spin : <Download className="h-4 w-4" />}
120
+ Download
121
+ </Button>
122
+ {canShare && (
123
+ <Button
124
+ variant="outline"
125
+ className="min-h-11 flex-1 sm:flex-none"
126
+ disabled={disabled}
127
+ onClick={share}
128
+ >
129
+ {busy === "share" ? Spin : <Share2 className="h-4 w-4" />}
130
+ Share…
131
+ </Button>
132
+ )}
133
+ </div>
134
+ </div>
135
+ );
136
+ }