@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.
- package/README.md +100 -0
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +7 -4
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +38 -3
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +3 -0
- package/lib/agents/launch.ts +11 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +13 -2
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +28 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claude-cli.ts +44 -15
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/review.ts +2 -0
- package/lib/orchestrator/scope.ts +2 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +43 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/index.ts +59 -8
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +5 -2
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +26 -0
- package/lib/usage/turns.ts +79 -18
- package/package.json +1 -1
- 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's estimates. Claude'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's fetched when an agent
|
|
222
|
+
starts and goes only into that agent'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
|