@saadnvd1/agent-os 0.3.471 → 0.3.473

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 (75) hide show
  1. package/README.md +100 -0
  2. package/app/api/models/route.ts +34 -0
  3. package/app/api/models/test/route.ts +17 -0
  4. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  5. package/app/api/tasks/[id]/model/route.ts +23 -0
  6. package/app/api/tasks/route.ts +5 -1
  7. package/app/api/usage/compare/route.ts +21 -0
  8. package/app/api/usage/route.ts +7 -4
  9. package/app/api/workspaces/[id]/models/route.ts +27 -0
  10. package/components/CommandPalette/useAppCommands.ts +19 -1
  11. package/components/Draft/DraftChips.tsx +28 -0
  12. package/components/Draft/DraftPanel.tsx +1 -1
  13. package/components/Models/ModelSelect.tsx +67 -0
  14. package/components/Models/ModelsSection.tsx +199 -0
  15. package/components/Models/ProviderCard.tsx +271 -0
  16. package/components/SessionList/SidebarHeader.tsx +5 -1
  17. package/components/Settings/SettingsDialog.test.ts +3 -0
  18. package/components/Settings/SettingsDialog.tsx +9 -0
  19. package/components/Settings/commands.ts +19 -0
  20. package/components/Settings/entry-points.test.ts +1 -0
  21. package/components/Tasks/ResumeOnModel.tsx +72 -0
  22. package/components/Tasks/TaskRow.tsx +4 -0
  23. package/components/Usage/CompareView.tsx +211 -0
  24. package/components/Usage/ModelSpend.tsx +133 -0
  25. package/components/Usage/UsageDialog.tsx +38 -3
  26. package/components/Usage/format.ts +9 -0
  27. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  28. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  29. package/data/models/index.ts +125 -0
  30. package/data/usage/index.ts +3 -0
  31. package/lib/agents/launch.ts +11 -2
  32. package/lib/chat/context.ts +1 -1
  33. package/lib/chat/runner.ts +5 -0
  34. package/lib/chat/worker/extras.ts +12 -0
  35. package/lib/chat/worker/host.ts +13 -2
  36. package/lib/chat/worker/main.ts +5 -2
  37. package/lib/db/migrations.ts +28 -0
  38. package/lib/db/types.ts +6 -0
  39. package/lib/drafts.ts +3 -0
  40. package/lib/models/config.ts +218 -0
  41. package/lib/models/launch.ts +93 -0
  42. package/lib/models/plan-step.ts +94 -0
  43. package/lib/models/providers.ts +163 -0
  44. package/lib/models/resolve.ts +246 -0
  45. package/lib/models/resume.ts +99 -0
  46. package/lib/models/roles.ts +98 -0
  47. package/lib/models/secrets.ts +86 -0
  48. package/lib/models/session-model.ts +40 -0
  49. package/lib/models/test-provider.ts +116 -0
  50. package/lib/orchestrator/act.ts +13 -1
  51. package/lib/orchestrator/brief.ts +2 -1
  52. package/lib/orchestrator/claude-cli.ts +44 -15
  53. package/lib/orchestrator/home.ts +2 -2
  54. package/lib/orchestrator/review.ts +2 -0
  55. package/lib/orchestrator/scope.ts +2 -0
  56. package/lib/orchestrator/serve.ts +13 -0
  57. package/lib/orchestrator/tool-names.ts +1 -0
  58. package/lib/orchestrator/tool-schemas.ts +43 -0
  59. package/lib/orchestrator/tools.ts +3 -1
  60. package/lib/pr-generation.ts +5 -2
  61. package/lib/providers/registry.ts +1 -1
  62. package/lib/session-titles.ts +1 -1
  63. package/lib/sessions/launch.ts +19 -4
  64. package/lib/settings-url.ts +1 -0
  65. package/lib/tasks/index.ts +59 -8
  66. package/lib/tasks/queue.ts +14 -5
  67. package/lib/tasks/start.ts +7 -2
  68. package/lib/usage/by-model.ts +105 -0
  69. package/lib/usage/check-runs.ts +5 -2
  70. package/lib/usage/compare.ts +280 -0
  71. package/lib/usage/cost.ts +71 -0
  72. package/lib/usage/report.ts +26 -0
  73. package/lib/usage/turns.ts +79 -18
  74. package/package.json +1 -1
  75. package/stores/usageUi.ts +11 -2
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
6
+ import { useModelsQuery, useUpdateModels } from "@/data/models";
7
+ import type { ModelSettings } from "@/lib/models/config";
8
+ import {
9
+ PROVIDER_PRESETS,
10
+ type ModelPrice,
11
+ type ModelProvider,
12
+ } from "@/lib/models/providers";
13
+ import { MODEL_ROLES, ROLE_LABELS } from "@/lib/models/roles";
14
+ import { ModelSelect } from "./ModelSelect";
15
+ import { ProviderCard } from "./ProviderCard";
16
+
17
+ const PRICE_FIELDS: { key: keyof ModelPrice; label: string }[] = [
18
+ { key: "input", label: "Input" },
19
+ { key: "cacheWrite", label: "Cache write" },
20
+ { key: "cacheRead", label: "Cache read" },
21
+ { key: "output", label: "Output" },
22
+ ];
23
+
24
+ // Settings > Models: the model each kind of work runs on, the providers
25
+ // they can come from, and the prices usage is estimated at.
26
+ export function ModelsSection() {
27
+ const { data, isPending, isError, error } = useModelsQuery();
28
+ const update = useUpdateModels();
29
+ if (isPending)
30
+ return <div className="bg-muted/40 h-64 animate-pulse rounded-lg" />;
31
+ if (isError)
32
+ return <p className="text-destructive text-sm">{error.message}</p>;
33
+ const { saved, effective, defaults } = data;
34
+ const save = (patch: Partial<ModelSettings>) =>
35
+ update.mutate({ ...saved, ...patch });
36
+
37
+ const presetIds = new Set(PROVIDER_PRESETS.map((p) => p.id));
38
+ const saveProvider = (p: ModelProvider) =>
39
+ save({
40
+ providers: [...(saved.providers ?? []).filter((x) => x.id !== p.id), p],
41
+ });
42
+ const addProvider = () => {
43
+ const taken = new Set(effective.providers.map((p) => p.id));
44
+ let n = 1;
45
+ while (taken.has(`custom-${n}`)) n++;
46
+ saveProvider({
47
+ id: `custom-${n}`,
48
+ label: `Custom ${n}`,
49
+ api: "anthropic",
50
+ authHeader: "bearer",
51
+ models: [],
52
+ enabled: false,
53
+ });
54
+ };
55
+
56
+ return (
57
+ <div className="space-y-6">
58
+ <section className="space-y-2">
59
+ <p className="text-muted-foreground text-sm">
60
+ Which model each kind of work runs on. A workspace can set its own
61
+ (Settings › Workspaces), and a task its own when it starts or resumes.
62
+ </p>
63
+ <ul className="space-y-2">
64
+ {MODEL_ROLES.map((role) => (
65
+ <li
66
+ key={role}
67
+ className="bg-muted/40 space-y-1.5 rounded-lg px-3 py-2.5"
68
+ >
69
+ <label htmlFor={`role-${role}`} className="block">
70
+ <span className="text-sm font-medium">
71
+ {ROLE_LABELS[role].label}
72
+ </span>
73
+ <span className="text-muted-foreground block text-xs">
74
+ {ROLE_LABELS[role].hint}
75
+ </span>
76
+ </label>
77
+ <ModelSelect
78
+ id={`role-${role}`}
79
+ value={saved.roles?.[role] ?? null}
80
+ effective={effective}
81
+ inheritLabel={`Default (${defaults[role].split("/").slice(1).join("/")})`}
82
+ disabled={update.isPending}
83
+ onChange={(ref) => {
84
+ const roles = { ...saved.roles };
85
+ if (ref) roles[role] = ref;
86
+ else delete roles[role];
87
+ save({ roles });
88
+ }}
89
+ />
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ <SettingToggle
94
+ title="Tasks plan first"
95
+ detail="A chat task's first turn writes its plan to .agentos/plan.md on the planning model; the implementation model then follows it in the same conversation."
96
+ checked={effective.planStep}
97
+ onChange={(planStep) => save({ planStep })}
98
+ />
99
+ </section>
100
+
101
+ <section className="space-y-2">
102
+ <h3 className="text-sm font-medium">Providers</h3>
103
+ <p className="text-muted-foreground text-xs">
104
+ Claude stays the default; another provider runs only where a role or a
105
+ task picks it. Keys are never stored here: only where to read them
106
+ from.
107
+ </p>
108
+ <ul className="space-y-2">
109
+ {effective.providers.map((p) => (
110
+ <ProviderCard
111
+ key={`${p.id}-${JSON.stringify(p)}`}
112
+ provider={p}
113
+ saving={update.isPending}
114
+ onSave={saveProvider}
115
+ onRemove={
116
+ presetIds.has(p.id)
117
+ ? undefined
118
+ : () =>
119
+ save({
120
+ providers: (saved.providers ?? []).filter(
121
+ (x) => x.id !== p.id
122
+ ),
123
+ })
124
+ }
125
+ />
126
+ ))}
127
+ </ul>
128
+ <Button
129
+ variant="outline"
130
+ size="sm"
131
+ className="h-11 md:h-8"
132
+ onClick={addProvider}
133
+ disabled={update.isPending}
134
+ >
135
+ Add provider
136
+ </Button>
137
+ </section>
138
+
139
+ <section className="space-y-2">
140
+ <h3 className="text-sm font-medium">Prices</h3>
141
+ <p className="text-muted-foreground text-xs">
142
+ Dollars per million tokens, for Usage&apos;s estimates. Claude&apos;s
143
+ are API list prices: the plan itself is measured by its window.
144
+ </p>
145
+ <div className="overflow-x-auto">
146
+ <table className="w-full text-xs">
147
+ <thead className="text-muted-foreground text-left">
148
+ <tr>
149
+ <th className="py-1 pr-2 font-normal">Model</th>
150
+ {PRICE_FIELDS.map((f) => (
151
+ <th key={f.key} className="py-1 pr-2 font-normal">
152
+ {f.label}
153
+ </th>
154
+ ))}
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {Object.entries(effective.prices).map(([ref, price]) => (
159
+ <tr key={ref} className="border-border border-t">
160
+ <td className="py-1 pr-2 font-mono">{ref}</td>
161
+ {PRICE_FIELDS.map((f) => (
162
+ <td key={f.key} className="py-1 pr-2">
163
+ <Input
164
+ type="number"
165
+ min={0}
166
+ step="0.001"
167
+ defaultValue={price[f.key]}
168
+ aria-label={`${ref} ${f.label}`}
169
+ className="h-11 w-20 tabular-nums md:h-8"
170
+ onBlur={(e) => {
171
+ const v = Number(e.target.value);
172
+ if (
173
+ !Number.isFinite(v) ||
174
+ v < 0 ||
175
+ v === price[f.key]
176
+ )
177
+ return;
178
+ save({
179
+ prices: {
180
+ ...saved.prices,
181
+ [ref]: { ...price, [f.key]: v },
182
+ },
183
+ });
184
+ }}
185
+ />
186
+ </td>
187
+ ))}
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ </section>
194
+ {update.isError && (
195
+ <p className="text-destructive text-sm">{update.error.message}</p>
196
+ )}
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,271 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Switch } from "@/components/ui/switch";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import { useTestProvider } from "@/data/models";
15
+ import {
16
+ describeSecretRef,
17
+ type ModelProvider,
18
+ type SecretRef,
19
+ } from "@/lib/models/providers";
20
+ import { ANTHROPIC } from "@/lib/models/roles";
21
+ import { cn } from "@/lib/utils";
22
+
23
+ const KINDS: {
24
+ value: SecretRef["kind"];
25
+ label: string;
26
+ a: string;
27
+ b?: string;
28
+ }[] = [
29
+ { value: "tokenvault", label: "tokenvault", a: "Project", b: "Description" },
30
+ { value: "keychain", label: "macOS Keychain", a: "Service", b: "Account" },
31
+ { value: "env", label: "Environment variable", a: "Variable (…_API_KEY)" },
32
+ ];
33
+
34
+ function refFields(ref: SecretRef | undefined): [string, string] {
35
+ if (!ref) return ["", ""];
36
+ if (ref.kind === "tokenvault") return [ref.project, ref.desc ?? ""];
37
+ if (ref.kind === "keychain") return [ref.service, ref.account ?? ""];
38
+ return [ref.name, ""];
39
+ }
40
+
41
+ function toRef(
42
+ kind: SecretRef["kind"],
43
+ a: string,
44
+ b: string
45
+ ): SecretRef | undefined {
46
+ const x = a.trim();
47
+ const y = b.trim() || undefined;
48
+ if (!x) return undefined;
49
+ if (kind === "tokenvault") return { kind, project: x, desc: y };
50
+ if (kind === "keychain") return { kind, service: x, account: y };
51
+ return { kind, name: x };
52
+ }
53
+
54
+ const list = (s: string) =>
55
+ s
56
+ .split(/[\s,]+/)
57
+ .map((x) => x.trim())
58
+ .filter(Boolean);
59
+
60
+ // One provider: on or off, its endpoint, where its key lives (never the
61
+ // key), its models and Claude Code's tiers mapped onto them, and a test.
62
+ export function ProviderCard({
63
+ provider,
64
+ saving,
65
+ onSave,
66
+ onRemove,
67
+ }: {
68
+ provider: ModelProvider;
69
+ saving: boolean;
70
+ onSave: (p: ModelProvider) => void;
71
+ onRemove?: () => void;
72
+ }) {
73
+ const builtIn = provider.id === ANTHROPIC;
74
+ const [open, setOpen] = useState(false);
75
+ const [kind, setKind] = useState<SecretRef["kind"]>(
76
+ provider.auth?.kind ?? "tokenvault"
77
+ );
78
+ const [a0, b0] = refFields(provider.auth);
79
+ const [a, setA] = useState(a0);
80
+ const [b, setB] = useState(b0);
81
+ const [baseUrl, setBaseUrl] = useState(provider.baseUrl ?? "");
82
+ const [models, setModels] = useState(provider.models.join(", "));
83
+ const [tiers, setTiers] = useState(provider.tiers ?? {});
84
+ const test = useTestProvider();
85
+ const kindInfo = KINDS.find((k) => k.value === kind)!;
86
+ const ready = builtIn || (!!provider.baseUrl && !!provider.auth);
87
+
88
+ const draft = (): ModelProvider => ({
89
+ ...provider,
90
+ baseUrl: baseUrl.trim() || undefined,
91
+ auth: toRef(kind, a, b),
92
+ models: list(models),
93
+ tiers: Object.fromEntries(
94
+ Object.entries(tiers).filter(([, v]) => v)
95
+ ) as ModelProvider["tiers"],
96
+ });
97
+
98
+ return (
99
+ <li className="bg-muted/40 space-y-3 rounded-lg px-3 py-2.5">
100
+ <div className="flex items-center gap-3">
101
+ <div className="min-w-0 flex-1">
102
+ <p className="text-sm font-medium">{provider.label}</p>
103
+ <p className="text-muted-foreground truncate text-xs">
104
+ {builtIn
105
+ ? "Your Claude login; no key"
106
+ : `${provider.baseUrl ?? "no base URL"} · key: ${describeSecretRef(provider.auth)}`}
107
+ </p>
108
+ </div>
109
+ {!builtIn && (
110
+ <Switch
111
+ checked={provider.enabled}
112
+ disabled={saving || (!ready && !provider.enabled)}
113
+ title={ready ? undefined : "Set its base URL and key first"}
114
+ onCheckedChange={(enabled) => onSave({ ...provider, enabled })}
115
+ aria-label={`Use ${provider.label}`}
116
+ />
117
+ )}
118
+ </div>
119
+ <div className="flex flex-wrap gap-2">
120
+ <Button
121
+ variant="outline"
122
+ size="sm"
123
+ className="h-11 md:h-8"
124
+ disabled={test.isPending}
125
+ onClick={() => test.mutate({ provider: provider.id })}
126
+ >
127
+ {test.isPending ? "Testing…" : "Test provider"}
128
+ </Button>
129
+ {!builtIn && (
130
+ <Button
131
+ variant="ghost"
132
+ size="sm"
133
+ className="h-11 md:h-8"
134
+ onClick={() => setOpen((o) => !o)}
135
+ aria-expanded={open}
136
+ >
137
+ {open ? "Close" : "Edit"}
138
+ </Button>
139
+ )}
140
+ {onRemove && (
141
+ <Button
142
+ variant="ghost"
143
+ size="sm"
144
+ className="text-destructive h-11 md:h-8"
145
+ onClick={onRemove}
146
+ >
147
+ Remove
148
+ </Button>
149
+ )}
150
+ </div>
151
+ {test.data && (
152
+ <p
153
+ className={cn(
154
+ "text-xs",
155
+ test.data.ok
156
+ ? "text-emerald-700 dark:text-emerald-400"
157
+ : "text-destructive"
158
+ )}
159
+ >
160
+ {test.data.ok ? "Works: " : "Failed: "}
161
+ {test.data.message} ({Math.round(test.data.ms)} ms)
162
+ </p>
163
+ )}
164
+ {test.isError && (
165
+ <p className="text-destructive text-xs">{test.error.message}</p>
166
+ )}
167
+ {open && !builtIn && (
168
+ <form
169
+ className="space-y-2"
170
+ onSubmit={(e) => {
171
+ e.preventDefault();
172
+ onSave(draft());
173
+ }}
174
+ >
175
+ <Field label="Base URL">
176
+ <Input
177
+ value={baseUrl}
178
+ onChange={(e) => setBaseUrl(e.target.value)}
179
+ placeholder="https://…/anthropic"
180
+ className="h-11 md:h-9"
181
+ />
182
+ </Field>
183
+ <Field label="Key from">
184
+ <Select
185
+ value={kind}
186
+ onValueChange={(v) => setKind(v as SecretRef["kind"])}
187
+ >
188
+ <SelectTrigger className="h-11 md:h-9">
189
+ <SelectValue />
190
+ </SelectTrigger>
191
+ <SelectContent>
192
+ {KINDS.map((k) => (
193
+ <SelectItem key={k.value} value={k.value}>
194
+ {k.label}
195
+ </SelectItem>
196
+ ))}
197
+ </SelectContent>
198
+ </Select>
199
+ </Field>
200
+ <div className="grid grid-cols-2 gap-2">
201
+ <Field label={kindInfo.a}>
202
+ <Input
203
+ value={a}
204
+ onChange={(e) => setA(e.target.value)}
205
+ className="h-11 md:h-9"
206
+ autoComplete="off"
207
+ />
208
+ </Field>
209
+ {kindInfo.b && (
210
+ <Field label={`${kindInfo.b} (optional)`}>
211
+ <Input
212
+ value={b}
213
+ onChange={(e) => setB(e.target.value)}
214
+ className="h-11 md:h-9"
215
+ autoComplete="off"
216
+ />
217
+ </Field>
218
+ )}
219
+ </div>
220
+ <p className="text-muted-foreground text-xs">
221
+ Where the key lives, not the key: it&apos;s fetched when an agent
222
+ starts and goes only into that agent&apos;s environment.
223
+ </p>
224
+ <Field label="Models (comma-separated)">
225
+ <Input
226
+ value={models}
227
+ onChange={(e) => setModels(e.target.value)}
228
+ className="h-11 md:h-9"
229
+ />
230
+ </Field>
231
+ <div className="grid grid-cols-3 gap-2">
232
+ {(["opus", "sonnet", "haiku"] as const).map((t) => (
233
+ <Field key={t} label={`${t} tier`}>
234
+ <Input
235
+ value={tiers[t] ?? ""}
236
+ onChange={(e) =>
237
+ setTiers((x) => ({ ...x, [t]: e.target.value.trim() }))
238
+ }
239
+ className="h-11 md:h-9"
240
+ />
241
+ </Field>
242
+ ))}
243
+ </div>
244
+ <Button
245
+ type="submit"
246
+ size="sm"
247
+ className="h-11 md:h-8"
248
+ disabled={saving}
249
+ >
250
+ Save
251
+ </Button>
252
+ </form>
253
+ )}
254
+ </li>
255
+ );
256
+ }
257
+
258
+ function Field({
259
+ label,
260
+ children,
261
+ }: {
262
+ label: string;
263
+ children: React.ReactNode;
264
+ }) {
265
+ return (
266
+ <label className="block space-y-1">
267
+ <span className="text-muted-foreground text-xs">{label}</span>
268
+ {children}
269
+ </label>
270
+ );
271
+ }
@@ -18,6 +18,7 @@ import {
18
18
  Settings,
19
19
  SquareTerminal,
20
20
  Trash2,
21
+ Scale,
21
22
  } from "lucide-react";
22
23
  import {
23
24
  ADropdownMenu,
@@ -112,7 +113,10 @@ export function SidebarHeader(props: SidebarHeaderProps) {
112
113
  menuItem("Archived", () => archivedUiActions.open(), {
113
114
  icon: Archive,
114
115
  }),
115
- menuItem("Usage", usageUiActions.open, { icon: Gauge }),
116
+ menuItem("Usage", () => usageUiActions.open(), { icon: Gauge }),
117
+ menuItem("Model comparison", () => usageUiActions.open("compare"), {
118
+ icon: Scale,
119
+ }),
116
120
  separator(),
117
121
  menuItem("Machines", props.onManageHosts, { icon: Server }),
118
122
  menuItem("Settings", () => settingsUiActions.open(), {
@@ -31,6 +31,9 @@ vi.mock("@/components/Merge/GlobalMergeSection", () => ({
31
31
  vi.mock("@/components/Workspaces", () => ({
32
32
  WorkspaceSettings: stub("workspaces"),
33
33
  }));
34
+ vi.mock("@/components/Models/ModelsSection", () => ({
35
+ ModelsSection: stub("models"),
36
+ }));
34
37
  vi.mock("@/components/Devices", () => ({ DevicesPanel: stub("devices") }));
35
38
  vi.mock("@/components/Schedules", () => ({
36
39
  SchedulesPanel: stub("schedules"),
@@ -8,6 +8,7 @@ import {
8
8
  ChevronRight,
9
9
  ClipboardList,
10
10
  Clock,
11
+ Cpu,
11
12
  GitMerge,
12
13
  LayoutGrid,
13
14
  Smartphone,
@@ -25,6 +26,7 @@ import { Button } from "@/components/ui/button";
25
26
  import { AuditsPanel } from "@/components/Audits/AuditsPanel";
26
27
  import { DevicesPanel } from "@/components/Devices";
27
28
  import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
29
+ import { ModelsSection } from "@/components/Models/ModelsSection";
28
30
  import { SchedulesPanel } from "@/components/Schedules";
29
31
  import { WorkspaceSettings } from "@/components/Workspaces";
30
32
  import { useSettingsUrl } from "@/hooks/useSettingsUrl";
@@ -56,6 +58,11 @@ export const SECTION_INFO: Record<
56
58
  hint: "Pairing, passkeys, network",
57
59
  icon: Smartphone,
58
60
  },
61
+ models: {
62
+ label: "Models",
63
+ hint: "Which model each kind of work runs on, and providers",
64
+ icon: Cpu,
65
+ },
59
66
  notifications: {
60
67
  label: "Notifications",
61
68
  hint: "Sound, browser alerts, your phone",
@@ -85,6 +92,8 @@ function SectionBody({
85
92
  return <GlobalMergeSection />;
86
93
  case "workspaces":
87
94
  return <WorkspaceSettings />;
95
+ case "models":
96
+ return <ModelsSection />;
88
97
  case "devices":
89
98
  return <DevicesPanel />;
90
99
  case "notifications":
@@ -2,6 +2,7 @@ import {
2
2
  Bell,
3
3
  ClipboardList,
4
4
  Clock,
5
+ Cpu,
5
6
  GitMerge,
6
7
  LayoutGrid,
7
8
  Settings,
@@ -43,6 +44,24 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
43
44
  icon: LayoutGrid,
44
45
  run: () => settingsUiActions.open("workspaces"),
45
46
  },
47
+ {
48
+ id: "app.models",
49
+ title: "Model settings",
50
+ group,
51
+ keywords: [
52
+ "model",
53
+ "routing",
54
+ "provider",
55
+ "opus",
56
+ "sonnet",
57
+ "haiku",
58
+ "deepseek",
59
+ "glm",
60
+ "prices",
61
+ ],
62
+ icon: Cpu,
63
+ run: () => settingsUiActions.open("models"),
64
+ },
46
65
  {
47
66
  id: "app.devices",
48
67
  title: "Devices",
@@ -21,6 +21,7 @@ describe("⌘K entry points", () => {
21
21
  ["app.settings", null],
22
22
  ["app.merging", "merging"],
23
23
  ["app.workspace-settings", "workspaces"],
24
+ ["app.models", "models"],
24
25
  ["app.devices", "devices"],
25
26
  ["app.notifications", "notifications"],
26
27
  ["app.phone-notifications", "notifications"],
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Cpu } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ModelSelect } from "@/components/Models/ModelSelect";
7
+ import { useModelsQuery, useResumeOnModel } from "@/data/models";
8
+ import type { TaskView } from "@/lib/tasks";
9
+
10
+ // "Resume on model…": the task carries on in the same conversation and
11
+ // worktree on the model picked, which it keeps from then on.
12
+ export function ResumeOnModel({ task }: { task: TaskView }) {
13
+ const [open, setOpen] = useState(false);
14
+ const [ref, setRef] = useState<string | null>(null);
15
+ const models = useModelsQuery(open);
16
+ const resume = useResumeOnModel();
17
+ if (!open)
18
+ return (
19
+ <Button
20
+ size="sm"
21
+ variant="outline"
22
+ className="h-11 md:h-8"
23
+ title={task.model ? `Runs on ${task.model}` : undefined}
24
+ onClick={() => setOpen(true)}
25
+ >
26
+ <Cpu className="h-3.5 w-3.5" />
27
+ Resume on model…
28
+ </Button>
29
+ );
30
+ return (
31
+ <div className="bg-muted/40 w-full space-y-2 rounded-lg p-2">
32
+ <p className="text-muted-foreground text-xs">
33
+ Now on {task.model ?? "its role's model"}
34
+ {task.planning ? ", planning" : ""}. Its conversation and worktree carry
35
+ on.
36
+ </p>
37
+ <ModelSelect
38
+ value={ref}
39
+ effective={models.data?.effective}
40
+ disabled={!models.data || resume.isPending}
41
+ onChange={setRef}
42
+ />
43
+ <div className="flex gap-2">
44
+ <Button
45
+ size="sm"
46
+ className="h-11 md:h-8"
47
+ disabled={!ref || resume.isPending}
48
+ onClick={() =>
49
+ ref &&
50
+ resume.mutate(
51
+ { taskId: task.id, model: ref },
52
+ { onSuccess: () => setOpen(false) }
53
+ )
54
+ }
55
+ >
56
+ {resume.isPending ? "Resuming…" : "Resume"}
57
+ </Button>
58
+ <Button
59
+ size="sm"
60
+ variant="ghost"
61
+ className="h-11 md:h-8"
62
+ onClick={() => setOpen(false)}
63
+ >
64
+ Cancel
65
+ </Button>
66
+ </div>
67
+ {resume.isError && (
68
+ <p className="text-destructive text-xs">{resume.error.message}</p>
69
+ )}
70
+ </div>
71
+ );
72
+ }
@@ -15,6 +15,7 @@ import { tmuxAttachActions } from "@/stores/tmuxAttach";
15
15
  import { tasksUiActions } from "@/stores/tasksUi";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { MoveTaskButton } from "./MoveTaskButton";
18
+ import { ResumeOnModel } from "./ResumeOnModel";
18
19
 
19
20
  const STATE: Record<TaskState, { label: string; tone: string }> = {
20
21
  working: { label: "Working", tone: "text-muted-foreground" },
@@ -58,6 +59,8 @@ export function TaskRow({ task }: { task: TaskView }) {
58
59
  <p className="text-muted-foreground truncate font-mono text-[11px]">
59
60
  {task.hostName && `${task.hostName} · `}
60
61
  {task.projectName} · {task.branch}
62
+ {task.model &&
63
+ ` · ${task.model}${task.planning ? " (planning)" : ""}`}
61
64
  </p>
62
65
  {task.cardUrl && (
63
66
  <a
@@ -114,6 +117,7 @@ export function TaskRow({ task }: { task: TaskView }) {
114
117
  </Button>
115
118
  )}
116
119
  <MoveTaskButton task={task} onError={setMoveError} />
120
+ {!task.hostId && <ResumeOnModel task={task} />}
117
121
  <span className="flex-1" />
118
122
  {confirmDrop ? (
119
123
  <Button