@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
@@ -0,0 +1,217 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { AlertTriangle, Info } from "lucide-react";
6
+ import { Switch } from "@/components/ui/switch";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { useExportEstimate, type ExportEstimate } from "@/data/exports";
14
+ import { exportUi, exportUiActions } from "@/stores/exportUi";
15
+ import {
16
+ COMPACT_RULES,
17
+ EXPORT_FORMATS,
18
+ formatBytes,
19
+ redactionStatus,
20
+ tokenLabel,
21
+ type ExportFormat,
22
+ type ExportMode,
23
+ type ExportRequest,
24
+ } from "@/lib/export/view";
25
+ import { cn } from "@/lib/utils";
26
+ import { ExportActions } from "./ExportActions";
27
+ import { RangePicker } from "./RangePicker";
28
+ import { Seg } from "./Seg";
29
+ import { TurnSelectBar } from "./TurnSelectBar";
30
+
31
+ // Opened from a menu or a message: what to export (format, detail, turns,
32
+ // masking), what it comes to, and where it goes (copy, download, share).
33
+ export function ExportSheet() {
34
+ const ui = useSnapshot(exportUi);
35
+ return (
36
+ <>
37
+ <Dialog
38
+ open={ui.open && !!ui.sessionId}
39
+ onOpenChange={(open) => !open && exportUiActions.close()}
40
+ >
41
+ {ui.sessionId && (
42
+ <SheetBody
43
+ key={ui.sessionId}
44
+ sessionId={ui.sessionId}
45
+ title={ui.title}
46
+ />
47
+ )}
48
+ </Dialog>
49
+ {ui.selecting && <TurnSelectBar />}
50
+ </>
51
+ );
52
+ }
53
+
54
+ function SheetBody({ sessionId, title }: { sessionId: string; title: string }) {
55
+ const { req, fromItemId } = useSnapshot(exportUi);
56
+ const [est, setEst] = useState<ExportEstimate | null>(null);
57
+ const [error, setError] = useState<string | null>(null);
58
+ const estimate = useExportEstimate(sessionId);
59
+ const seq = useRef(0);
60
+ const wire = JSON.stringify(req);
61
+
62
+ useEffect(() => {
63
+ const mine = ++seq.current;
64
+ const timer = setTimeout(async () => {
65
+ try {
66
+ const e = await estimate.mutateAsync(JSON.parse(wire) as ExportRequest);
67
+ if (mine !== seq.current) return;
68
+ setEst(e);
69
+ setError(null);
70
+ exportUi.turns = e.turns;
71
+ const from = exportUi.fromItemId;
72
+ if (from) {
73
+ exportUi.fromItemId = null;
74
+ const turn = e.turns.find((t) => t.id === from);
75
+ if (turn)
76
+ exportUiActions.setRequest({
77
+ range: { from: turn.n, to: e.selected.of },
78
+ });
79
+ }
80
+ } catch (err) {
81
+ if (mine === seq.current) setError((err as Error).message);
82
+ }
83
+ }, 250);
84
+ return () => clearTimeout(timer);
85
+ // The mutation object changes every render; the request is the input.
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, [wire, fromItemId]);
88
+
89
+ const name = est?.title ?? title;
90
+ const set = exportUiActions.setRequest;
91
+ const size = est?.estimate[req.mode];
92
+ const stale = estimate.isPending;
93
+ const token = (m: ExportMode) =>
94
+ est ? tokenLabel(est.estimate[m].tokens).replace("about ", "~") : " ";
95
+
96
+ return (
97
+ <DialogContent className="flex h-[100dvh] w-screen max-w-none flex-col gap-3 rounded-none p-4 sm:h-auto sm:max-h-[90vh] sm:max-w-[560px] sm:rounded-lg">
98
+ <div>
99
+ <DialogTitle>Export for AI</DialogTitle>
100
+ <DialogDescription className="truncate">{name}</DialogDescription>
101
+ </div>
102
+
103
+ <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto">
104
+ <Section label="Format">
105
+ <Seg<ExportFormat>
106
+ label="Format"
107
+ value={req.format}
108
+ options={EXPORT_FORMATS.map((f) => ({ id: f.id, label: f.label }))}
109
+ onChange={(format) => set({ format })}
110
+ />
111
+ <p className="text-muted-foreground text-xs">
112
+ {EXPORT_FORMATS.find((f) => f.id === req.format)?.hint}
113
+ </p>
114
+ </Section>
115
+
116
+ <Section label="Detail">
117
+ <Seg<ExportMode>
118
+ label="Detail"
119
+ value={req.mode}
120
+ options={[
121
+ { id: "compact", label: "Compact", sub: token("compact") },
122
+ { id: "full", label: "Full", sub: token("full") },
123
+ ]}
124
+ onChange={(mode) => set({ mode })}
125
+ />
126
+ <details className="text-muted-foreground text-xs">
127
+ <summary className="flex min-h-8 cursor-pointer items-center">
128
+ What Compact drops
129
+ </summary>
130
+ <ul className="list-disc space-y-0.5 pl-5">
131
+ {COMPACT_RULES.map((r) => (
132
+ <li key={r}>{r}</li>
133
+ ))}
134
+ </ul>
135
+ </details>
136
+ </Section>
137
+
138
+ <Section label="Range">
139
+ <RangePicker
140
+ range={req.range}
141
+ est={est}
142
+ onChange={(range) => set({ range })}
143
+ onSelectInChat={() => exportUiActions.startSelect()}
144
+ />
145
+ </Section>
146
+
147
+ <Section label="Masking">
148
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
149
+ <span>Mask paths, machine names and emails</span>
150
+ <Switch
151
+ checked={req.redact === "all"}
152
+ onCheckedChange={(on) => set({ redact: on ? "all" : "secrets" })}
153
+ />
154
+ </label>
155
+ <p className="text-muted-foreground text-xs">
156
+ Credentials are always masked.{" "}
157
+ {est ? redactionStatus(est.hits) + "." : ""}
158
+ </p>
159
+ </Section>
160
+
161
+ <div
162
+ className={cn(
163
+ "space-y-2 rounded-lg border p-3 text-sm",
164
+ stale && "opacity-60"
165
+ )}
166
+ aria-live="polite"
167
+ >
168
+ <p className="font-medium">
169
+ {size
170
+ ? `${formatBytes(size.bytes)} · ${tokenLabel(size.tokens)}`
171
+ : (error ?? "Working out the size…")}
172
+ </p>
173
+ {est?.warnings.map((w) => (
174
+ <p
175
+ key={w.code}
176
+ className={cn(
177
+ "flex gap-2 text-xs",
178
+ w.level === "danger" && "text-destructive",
179
+ w.level === "warn" && "text-amber-600 dark:text-amber-400",
180
+ w.level === "info" && "text-muted-foreground"
181
+ )}
182
+ >
183
+ {w.level === "info" ? (
184
+ <Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
185
+ ) : (
186
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
187
+ )}
188
+ <span>{w.text}</span>
189
+ </p>
190
+ ))}
191
+ </div>
192
+ </div>
193
+
194
+ <ExportActions
195
+ sessionId={sessionId}
196
+ title={name}
197
+ req={req as ExportRequest}
198
+ est={est}
199
+ />
200
+ </DialogContent>
201
+ );
202
+ }
203
+
204
+ function Section({
205
+ label,
206
+ children,
207
+ }: {
208
+ label: string;
209
+ children: React.ReactNode;
210
+ }) {
211
+ return (
212
+ <section className="space-y-2">
213
+ <h3 className="text-muted-foreground text-xs font-medium">{label}</h3>
214
+ {children}
215
+ </section>
216
+ );
217
+ }
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ import { MousePointerClick } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { cn } from "@/lib/utils";
6
+ import type { ExportEstimate } from "@/data/exports";
7
+ import type { ExportRange } from "@/lib/export/view";
8
+
9
+ type Chip = "all" | "last1" | "last5" | "compaction" | "custom";
10
+
11
+ const CHIPS: { id: Chip; label: string }[] = [
12
+ { id: "all", label: "Whole session" },
13
+ { id: "last1", label: "Last turn" },
14
+ { id: "last5", label: "Last 5" },
15
+ { id: "compaction", label: "Since compaction" },
16
+ { id: "custom", label: "Custom" },
17
+ ];
18
+
19
+ const chipOf = (r: ExportRange | undefined): Chip =>
20
+ !r
21
+ ? "all"
22
+ : "sinceCompaction" in r
23
+ ? "compaction"
24
+ : "last" in r
25
+ ? r.last === 1
26
+ ? "last1"
27
+ : r.last === 5
28
+ ? "last5"
29
+ : "custom"
30
+ : "custom";
31
+
32
+ const when = (at: string) => at.slice(5, 16).replace("T", " ");
33
+ const clock = (at: string) => at.slice(11, 16);
34
+
35
+ const SELECT =
36
+ "bg-background border-input min-h-11 w-full min-w-0 rounded-md border px-2 text-sm";
37
+
38
+ export function RangePicker({
39
+ range,
40
+ est,
41
+ onChange,
42
+ onSelectInChat,
43
+ }: {
44
+ range: ExportRange | undefined;
45
+ est: ExportEstimate | null;
46
+ onChange: (r: ExportRange | undefined) => void;
47
+ onSelectInChat: () => void;
48
+ }) {
49
+ const chip = chipOf(range);
50
+ const turns = (est?.turns ?? []).filter((t) => t.n > 0);
51
+ const sel = est?.selected;
52
+ const pick = (id: Chip) => {
53
+ if (id === "all") onChange(undefined);
54
+ else if (id === "last1") onChange({ last: 1 });
55
+ else if (id === "last5") onChange({ last: 5 });
56
+ else if (id === "compaction") onChange({ sinceCompaction: true });
57
+ else
58
+ onChange({
59
+ from: sel?.from ?? 1,
60
+ to: sel?.to ?? Math.max(1, turns.length),
61
+ });
62
+ };
63
+ const custom = range && "from" in range ? range : null;
64
+ const options = turns.map((t) => (
65
+ <option key={t.n} value={t.n}>
66
+ {`#${t.n} · ${when(t.at)} · ${t.excerpt.slice(0, 40)}`}
67
+ </option>
68
+ ));
69
+
70
+ return (
71
+ <div className="space-y-2">
72
+ <div className="flex flex-wrap gap-1.5" role="group" aria-label="Range">
73
+ {CHIPS.map((c) => (
74
+ <button
75
+ key={c.id}
76
+ type="button"
77
+ aria-pressed={chip === c.id}
78
+ onClick={() => pick(c.id)}
79
+ className={cn(
80
+ "min-h-11 rounded-full border px-3.5 text-sm transition-colors",
81
+ chip === c.id
82
+ ? "border-primary bg-primary/10 text-foreground"
83
+ : "text-muted-foreground hover:bg-foreground/[0.04]"
84
+ )}
85
+ >
86
+ {c.label}
87
+ </button>
88
+ ))}
89
+ </div>
90
+
91
+ {custom && (
92
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
93
+ <label className="text-muted-foreground space-y-1 text-xs">
94
+ From
95
+ <select
96
+ className={SELECT}
97
+ value={custom.from}
98
+ onChange={(e) =>
99
+ onChange({
100
+ from: Number(e.target.value),
101
+ to: Math.max(custom.to, Number(e.target.value)),
102
+ })
103
+ }
104
+ >
105
+ {options}
106
+ </select>
107
+ </label>
108
+ <label className="text-muted-foreground space-y-1 text-xs">
109
+ To
110
+ <select
111
+ className={SELECT}
112
+ value={custom.to}
113
+ onChange={(e) =>
114
+ onChange({
115
+ from: Math.min(custom.from, Number(e.target.value)),
116
+ to: Number(e.target.value),
117
+ })
118
+ }
119
+ >
120
+ {options}
121
+ </select>
122
+ </label>
123
+ </div>
124
+ )}
125
+
126
+ <div className="flex items-center justify-between gap-2">
127
+ <p className="text-muted-foreground min-w-0 text-xs">
128
+ {sel && sel.startedAt && sel.endedAt
129
+ ? `Turns ${sel.from}–${sel.to} · ${sel.turns} ${sel.turns === 1 ? "turn" : "turns"} · ${clock(sel.startedAt)}–${clock(sel.endedAt)}`
130
+ : "Counting turns…"}
131
+ </p>
132
+ <Button
133
+ variant="outline"
134
+ size="sm"
135
+ className="min-h-11 shrink-0"
136
+ onClick={onSelectInChat}
137
+ >
138
+ <MousePointerClick className="h-4 w-4" />
139
+ Select in chat
140
+ </Button>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ // A row of exclusive choices, each at least 44px tall.
6
+ export function Seg<T extends string>({
7
+ label,
8
+ value,
9
+ options,
10
+ onChange,
11
+ }: {
12
+ label: string;
13
+ value: T;
14
+ options: { id: T; label: string; sub?: string }[];
15
+ onChange: (id: T) => void;
16
+ }) {
17
+ return (
18
+ <div className="flex gap-1.5" role="group" aria-label={label}>
19
+ {options.map((o) => (
20
+ <button
21
+ key={o.id}
22
+ type="button"
23
+ aria-pressed={value === o.id}
24
+ onClick={() => onChange(o.id)}
25
+ className={cn(
26
+ "flex min-h-11 min-w-0 flex-1 flex-col items-center justify-center rounded-lg border px-2 text-sm transition-colors",
27
+ value === o.id
28
+ ? "border-primary bg-primary/10 text-foreground"
29
+ : "text-muted-foreground hover:bg-foreground/[0.04]"
30
+ )}
31
+ >
32
+ <span className="font-medium">{o.label}</span>
33
+ {o.sub && <span className="text-[11px] opacity-80">{o.sub}</span>}
34
+ </button>
35
+ ))}
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { useSnapshot } from "valtio";
4
+ import { Button } from "@/components/ui/button";
5
+ import { exportUi, exportUiActions, pickedRange } from "@/stores/exportUi";
6
+ import { tokenLabel } from "@/lib/export/view";
7
+
8
+ // The bar under the chat while turns are being picked for an export.
9
+ export function TurnSelectBar() {
10
+ const { pick, turns } = useSnapshot(exportUi);
11
+ const range = pickedRange(pick);
12
+ const picked = range
13
+ ? turns.filter((t) => t.n >= range.from && t.n <= range.to)
14
+ : [];
15
+ const tokens = picked.reduce((sum, t) => sum + t.tokens, 0);
16
+ const count = range ? range.to - range.from + 1 : 0;
17
+ const text = !range
18
+ ? "Tap the first turn to export"
19
+ : pick.to === null
20
+ ? `Turn ${range.from} · tap the last turn, or export just this one`
21
+ : `Turns ${range.from}–${range.to} · ${count} ${count === 1 ? "turn" : "turns"} · ${tokenLabel(tokens)}`;
22
+ return (
23
+ <div
24
+ role="region"
25
+ aria-label="Select turns to export"
26
+ className="bg-background fixed inset-x-0 bottom-0 z-40 flex flex-wrap items-center gap-2 border-t p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
27
+ >
28
+ <p className="min-w-[140px] flex-1 text-sm">{text}</p>
29
+ <Button
30
+ variant="outline"
31
+ className="min-h-11"
32
+ onClick={exportUiActions.cancelSelect}
33
+ >
34
+ Cancel
35
+ </Button>
36
+ <Button
37
+ className="min-h-11"
38
+ disabled={!range}
39
+ onClick={exportUiActions.finishSelect}
40
+ >
41
+ Export…
42
+ </Button>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { AlertTriangle } from "lucide-react";
4
+ import type { AgentRoleView } from "@/lib/models/agent-roles-view";
5
+ import { cn } from "@/lib/utils";
6
+ import { RoleModelSelect } from "./RoleModelSelect";
7
+
8
+ const SOURCE: Record<string, string> = {
9
+ task: "Set for the task",
10
+ project: "From this project",
11
+ workspace: "From the workspace",
12
+ settings: "Set in Settings",
13
+ env: "From the server's environment",
14
+ inherited: "Same as Gate review until set here",
15
+ default: "Default",
16
+ };
17
+
18
+ const initial = (label: string) => label.slice(0, 1);
19
+
20
+ // One role: what it does, the model it runs on, where that came from, and
21
+ // whether the model's server answers. A role whose local model is down says so.
22
+ export function AgentRoleCard({
23
+ role,
24
+ scope,
25
+ pending,
26
+ onChange,
27
+ onFallback,
28
+ }: {
29
+ role: AgentRoleView;
30
+ scope: "settings" | "workspace" | "project";
31
+ pending: boolean;
32
+ onChange: (ref: string | null) => void;
33
+ onFallback: (ref: string | null) => void;
34
+ }) {
35
+ const down = role.choice?.availability.state === "down";
36
+ const label = `Default (${role.default.split("/").slice(1).join("/")})`;
37
+ const inheritLabel =
38
+ scope === "settings"
39
+ ? role.modelRole === "advise"
40
+ ? "Same as Gate review"
41
+ : label
42
+ : "Inherit";
43
+ return (
44
+ <li
45
+ className={cn(
46
+ "bg-muted/40 space-y-2 rounded-xl px-3 py-3",
47
+ down && "ring-destructive/50 ring-1"
48
+ )}
49
+ >
50
+ <label htmlFor={`agent-role-${role.id}`} className="flex gap-2.5">
51
+ <span className="bg-accent text-primary grid h-8 w-8 shrink-0 place-items-center rounded-lg text-sm font-semibold">
52
+ {initial(role.label)}
53
+ </span>
54
+ <span className="min-w-0">
55
+ <span className="block text-sm font-medium">{role.label}</span>
56
+ <span className="text-muted-foreground block text-xs">
57
+ {role.purpose}
58
+ </span>
59
+ </span>
60
+ </label>
61
+ <RoleModelSelect
62
+ id={`agent-role-${role.id}`}
63
+ value={role.value}
64
+ inheritLabel={
65
+ role.value === null && role.binding
66
+ ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
67
+ : inheritLabel
68
+ }
69
+ disabled={pending}
70
+ onChange={onChange}
71
+ />
72
+ <p className="text-muted-foreground text-xs">
73
+ {role.error
74
+ ? role.error
75
+ : role.binding
76
+ ? `${SOURCE[role.binding.source] ?? role.binding.source}${role.choice?.endpoint ? ` · ${role.choice.endpoint}` : ""}`
77
+ : ""}
78
+ </p>
79
+ {down && (
80
+ <div
81
+ role="alert"
82
+ className="bg-destructive/10 text-foreground ring-destructive/35 flex gap-2 rounded-lg p-2.5 text-xs ring-1"
83
+ >
84
+ <AlertTriangle className="text-destructive mt-0.5 h-3.5 w-3.5 shrink-0" />
85
+ <span>
86
+ <b className="font-medium">
87
+ This model&apos;s server isn&apos;t answering.
88
+ </b>{" "}
89
+ {role.choice?.availability.error
90
+ ? `${role.choice.availability.error}. `
91
+ : ""}
92
+ New {role.label} sessions can&apos;t start until it is back
93
+ {role.fallback ? ", or you start on the fallback" : ""}.
94
+ </span>
95
+ </div>
96
+ )}
97
+ {scope === "settings" && (
98
+ <div className="flex flex-wrap items-center gap-2">
99
+ <span className="text-muted-foreground text-xs">Fallback</span>
100
+ <div className="min-w-44 flex-1">
101
+ <RoleModelSelect
102
+ id={`agent-role-fallback-${role.id}`}
103
+ value={role.fallback}
104
+ inheritLabel="None (don't fall back)"
105
+ claudeOnly
106
+ disabled={pending}
107
+ onChange={onFallback}
108
+ />
109
+ </div>
110
+ </div>
111
+ )}
112
+ </li>
113
+ );
114
+ }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useAgentRolesQuery, useSaveAgentRoles } from "@/data/models";
5
+ import { useProjectsQuery } from "@/data/projects/queries";
6
+ import { useWorkspacesQuery } from "@/data/workspaces";
7
+ import type { RoleMap } from "@/lib/models/config";
8
+ import type { RoleScope } from "@/lib/models/agent-roles-view";
9
+ import { cn } from "@/lib/utils";
10
+ import { AgentRoleCard } from "./AgentRoleCard";
11
+
12
+ const SCOPES: { id: RoleScope; label: string }[] = [
13
+ { id: "settings", label: "Default" },
14
+ { id: "workspace", label: "Workspace" },
15
+ { id: "project", label: "Project" },
16
+ ];
17
+
18
+ // Settings > Agent roles: the model each of Developer, Analyst, Reviewer and
19
+ // Supervisor runs on, for everything or for one workspace or project.
20
+ export function AgentRolesSection() {
21
+ const [scope, setScope] = useState<RoleScope>("settings");
22
+ const [target, setTarget] = useState<string | null>(null);
23
+ const workspaces = useWorkspacesQuery();
24
+ const projects = useProjectsQuery();
25
+ const options =
26
+ scope === "workspace"
27
+ ? (workspaces.data ?? []).map((w) => ({ id: w.id, name: w.name }))
28
+ : scope === "project"
29
+ ? (projects.data ?? [])
30
+ .filter((p) => !p.is_uncategorized)
31
+ .map((p) => ({ id: p.id, name: p.name }))
32
+ : [];
33
+ const id = scope === "settings" ? null : (target ?? options[0]?.id ?? null);
34
+ const { data, isPending, isError, error } = useAgentRolesQuery(scope, id);
35
+ const save = useSaveAgentRoles(scope, id);
36
+
37
+ const saved = (key: "value" | "fallback"): RoleMap =>
38
+ Object.fromEntries(
39
+ (data?.roles ?? [])
40
+ .filter((r) => r[key])
41
+ .map((r) => [r.modelRole, r[key] as string])
42
+ );
43
+ const setRole = (modelRole: string, ref: string | null) => {
44
+ const roles: RoleMap = { ...saved("value") };
45
+ if (ref) roles[modelRole as keyof RoleMap] = ref;
46
+ else delete roles[modelRole as keyof RoleMap];
47
+ save.mutate({ roles });
48
+ };
49
+ const setFallback = (modelRole: string, ref: string | null) => {
50
+ const fallbacks: RoleMap = { ...saved("fallback") };
51
+ if (ref) fallbacks[modelRole as keyof RoleMap] = ref;
52
+ else delete fallbacks[modelRole as keyof RoleMap];
53
+ save.mutate({ roles: saved("value"), fallbacks });
54
+ };
55
+
56
+ return (
57
+ <div className="space-y-4">
58
+ <div
59
+ role="tablist"
60
+ className="bg-muted inline-flex max-w-full gap-0.5 overflow-x-auto rounded-xl p-0.5"
61
+ >
62
+ {SCOPES.map((s) => (
63
+ <button
64
+ key={s.id}
65
+ role="tab"
66
+ aria-selected={scope === s.id}
67
+ type="button"
68
+ onClick={() => {
69
+ setScope(s.id);
70
+ setTarget(null);
71
+ }}
72
+ className={cn(
73
+ "h-11 rounded-lg px-3 text-sm whitespace-nowrap md:h-8",
74
+ scope === s.id
75
+ ? "bg-background text-foreground ring-border ring-1"
76
+ : "text-muted-foreground hover:text-foreground"
77
+ )}
78
+ >
79
+ {s.label}
80
+ </button>
81
+ ))}
82
+ </div>
83
+ {scope !== "settings" && (
84
+ <select
85
+ aria-label={scope === "workspace" ? "Workspace" : "Project"}
86
+ value={id ?? ""}
87
+ onChange={(e) => setTarget(e.target.value)}
88
+ className="border-input bg-background h-11 w-full rounded-md border px-3 text-sm md:h-9"
89
+ >
90
+ {options.length === 0 && <option value="">None yet</option>}
91
+ {options.map((o) => (
92
+ <option key={o.id} value={o.id}>
93
+ {o.name}
94
+ </option>
95
+ ))}
96
+ </select>
97
+ )}
98
+ <p className="text-muted-foreground text-xs">
99
+ The model each role runs on. A workspace or project can override these,
100
+ and a session can when it starts. A Pi model (a server you run) runs
101
+ sessions started in the role; tasks and the merge gate stay on Claude
102
+ Code models. Planning, gate review, scope check, helpers and watchers
103
+ are in Models.
104
+ </p>
105
+ {scope !== "settings" && !id ? (
106
+ <p className="text-muted-foreground text-sm">
107
+ There is no {scope} to set roles for yet.
108
+ </p>
109
+ ) : isPending ? (
110
+ <div className="bg-muted/40 h-64 animate-pulse rounded-lg" />
111
+ ) : isError ? (
112
+ <p className="text-destructive text-sm">{error.message}</p>
113
+ ) : data ? (
114
+ <ul className="space-y-2.5">
115
+ {data.roles.map((r) => (
116
+ <AgentRoleCard
117
+ key={r.id}
118
+ role={r}
119
+ scope={scope}
120
+ pending={save.isPending}
121
+ onChange={(ref) => setRole(r.modelRole, ref)}
122
+ onFallback={(ref) => setFallback(r.modelRole, ref)}
123
+ />
124
+ ))}
125
+ </ul>
126
+ ) : null}
127
+ {save.isError && (
128
+ <p className="text-destructive text-xs">{save.error.message}</p>
129
+ )}
130
+ </div>
131
+ );
132
+ }