@porkytheblack/garage-dashboard 0.1.0

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 (93) hide show
  1. package/.env.example +3 -0
  2. package/DESIGN.md +90 -0
  3. package/README.md +62 -0
  4. package/app/(app)/credentials/add-profile-modal.tsx +116 -0
  5. package/app/(app)/credentials/add-provider-modal.tsx +172 -0
  6. package/app/(app)/credentials/edit-provider-modal.tsx +107 -0
  7. package/app/(app)/credentials/edit-reasoning-modal.tsx +96 -0
  8. package/app/(app)/credentials/form.tsx +132 -0
  9. package/app/(app)/credentials/model-combobox.tsx +151 -0
  10. package/app/(app)/credentials/page.tsx +126 -0
  11. package/app/(app)/keys/page.tsx +164 -0
  12. package/app/(app)/layout.tsx +53 -0
  13. package/app/(app)/namespaces/list.tsx +47 -0
  14. package/app/(app)/namespaces/mint-dialog.tsx +64 -0
  15. package/app/(app)/namespaces/page.tsx +137 -0
  16. package/app/(app)/page.tsx +86 -0
  17. package/app/(app)/provisioning/page.tsx +84 -0
  18. package/app/(app)/sessions/[id]/page.tsx +184 -0
  19. package/app/(app)/sessions/page.tsx +88 -0
  20. package/app/(app)/storage/page.tsx +164 -0
  21. package/app/(app)/storage/toggle.tsx +43 -0
  22. package/app/(app)/workspaces/list.tsx +34 -0
  23. package/app/(app)/workspaces/page.tsx +111 -0
  24. package/app/globals.css +297 -0
  25. package/app/layout.tsx +32 -0
  26. package/app/login/page.tsx +62 -0
  27. package/components/app-sidebar.tsx +120 -0
  28. package/components/app-topbar.tsx +76 -0
  29. package/components/brand.tsx +47 -0
  30. package/components/chat/composer.tsx +199 -0
  31. package/components/chat/conversation.tsx +86 -0
  32. package/components/chat/error-card.tsx +89 -0
  33. package/components/chat/markdown.tsx +12 -0
  34. package/components/chat/message.tsx +99 -0
  35. package/components/chat/permission-prompt.tsx +45 -0
  36. package/components/chat/slash-menu.tsx +54 -0
  37. package/components/chat/task-list.tsx +56 -0
  38. package/components/chat/tool-call.tsx +91 -0
  39. package/components/fleet/lanes.tsx +107 -0
  40. package/components/fleet/launch.tsx +99 -0
  41. package/components/fleet/onboarding-launch.tsx +46 -0
  42. package/components/fleet/onboarding-model.tsx +109 -0
  43. package/components/fleet/onboarding-provider.tsx +137 -0
  44. package/components/fleet/onboarding-shared.tsx +66 -0
  45. package/components/fleet/onboarding.tsx +75 -0
  46. package/components/primitives.tsx +65 -0
  47. package/components/provisioning/provision-dialog.tsx +130 -0
  48. package/components/session/agent-roster.tsx +121 -0
  49. package/components/session/files-panel.tsx +170 -0
  50. package/components/session/files-remote.tsx +63 -0
  51. package/components/session/inspector.tsx +148 -0
  52. package/components/session/model-switcher.tsx +59 -0
  53. package/components/session/new-session-dialog.tsx +139 -0
  54. package/components/session/reasoning-knob.tsx +100 -0
  55. package/components/session/session-switcher.tsx +62 -0
  56. package/components/shared.tsx +171 -0
  57. package/components/theme-toggle.tsx +26 -0
  58. package/components/ui/avatar.tsx +39 -0
  59. package/components/ui/badge.tsx +30 -0
  60. package/components/ui/button.tsx +45 -0
  61. package/components/ui/card.tsx +44 -0
  62. package/components/ui/command.tsx +90 -0
  63. package/components/ui/dialog.tsx +88 -0
  64. package/components/ui/dropdown-menu.tsx +77 -0
  65. package/components/ui/input.tsx +22 -0
  66. package/components/ui/label.tsx +22 -0
  67. package/components/ui/popover.tsx +33 -0
  68. package/components/ui/scroll-area.tsx +41 -0
  69. package/components/ui/select.tsx +100 -0
  70. package/components/ui/separator.tsx +25 -0
  71. package/components/ui/skeleton.tsx +7 -0
  72. package/components/ui/sonner.tsx +28 -0
  73. package/components/ui/table.tsx +51 -0
  74. package/components/ui/tabs.tsx +50 -0
  75. package/components/ui/textarea.tsx +20 -0
  76. package/components/ui/tooltip.tsx +30 -0
  77. package/components.json +21 -0
  78. package/lib/api.ts +85 -0
  79. package/lib/auth.tsx +80 -0
  80. package/lib/format.ts +34 -0
  81. package/lib/hooks.ts +65 -0
  82. package/lib/launch.ts +25 -0
  83. package/lib/template.ts +34 -0
  84. package/lib/theme.ts +71 -0
  85. package/lib/types.ts +262 -0
  86. package/lib/useSession.ts +193 -0
  87. package/lib/utils.ts +7 -0
  88. package/lib/verify-provider.ts +31 -0
  89. package/next.config.mjs +16 -0
  90. package/package.json +49 -0
  91. package/postcss.config.mjs +6 -0
  92. package/tailwind.config.ts +94 -0
  93. package/tsconfig.json +21 -0
package/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ # Base URL of the running Garage server (REST + WebSocket).
2
+ # The dashboard talks to it directly from the browser using the admin JWT.
3
+ NEXT_PUBLIC_GARAGE_URL=http://127.0.0.1:4271
package/DESIGN.md ADDED
@@ -0,0 +1,90 @@
1
+ # Garage design language — "sap & sunlight"
2
+
3
+ Refined, product-grade UI in two modes: **dark "pine at dusk"** (green-black
4
+ surfaces, luminous sap green, solar amber) and **light "warm paper"** (cream
5
+ surfaces, pine ink, darkened sap). Calm confidence: deliberate hierarchy,
6
+ depth from hairline borders + a top sheen (never heavy drop shadows on
7
+ surfaces), one sap-green accent used sparingly. The two modes share ONE
8
+ structure — only hues shift, and they shift inside globals.css, never in
9
+ components. The reference implementations are the Fleet
10
+ console (`app/(app)/page.tsx`, `components/fleet/*`) and the app shell
11
+ (`components/app-sidebar.tsx`, `components/app-topbar.tsx`). When in doubt,
12
+ imitate those files.
13
+
14
+ ## Tokens (globals.css / tailwind.config.ts)
15
+
16
+ - **Elevation ladder** — surfaces climb, never jump:
17
+ `bg-background` (page) → `bg-card` (containers) → `bg-surface-2` (bands,
18
+ hovers, insets) → `bg-elevated` (highest inline emphasis). Overlays
19
+ (dialogs/menus) sit on `bg-popover` + `shadow-elevated`.
20
+ - **Borders**: `border-border` everywhere; `border-border-strong` only for
21
+ emphasis/hover. Dividers inside lists: `divide-border/60`.
22
+ - **Brand**: `brand` (sap green) for primary actions, active nav, focus,
23
+ live accents; `brand-strong` for hover/links. Brand should touch few
24
+ pixels. Green/amber/teal values are darker in light mode for contrast on
25
+ cream — always use the token, never a literal, and it stays legible.
26
+ - **Quiet scale** (3 weights of gray text): `text-foreground` (content) →
27
+ `text-muted-foreground` (secondary) → `text-faint` (hints, icons, meta).
28
+ Decorative icons default to `text-faint`.
29
+ - **Semantic**: `success` (running/live), `warning` (solar amber —
30
+ provisioning/working/caution), `destructive` (red clay — errors/dangerous
31
+ actions). Tint surfaces with `/10`–`/[0.07]` alphas and `/25`–`/30`
32
+ borders, as in `ErrorState`.
33
+
34
+ ## Utilities & shadows
35
+
36
+ - `.surface` = card + border + `shadow-card` (the standard container).
37
+ - `shadow-sheen` inner top highlight for small chips/buttons.
38
+ - Shadows + sheen + `.skeleton` shimmer are CSS variables (`--shadow-card`,
39
+ `--shadow-elevated`, `--sheen`, `--shimmer`) tuned per mode in globals.css
40
+ — never write a literal box-shadow with a baked-in tint.
41
+ - `shadow-elevated` for overlays only. `shadow-glow` for hero focus moments
42
+ (one per screen, e.g. the launch composer's `focus-within`).
43
+ - `.text-display` for hero headings; `.tnum` on every numeric column;
44
+ `.skeleton` for loading blocks; `.app-backdrop` only on full-screen roots
45
+ outside the app shell (e.g. login).
46
+
47
+ ## Type scale
48
+
49
+ - Page title: `PageHeader` (22px semibold tight). Section: `SectionHeading`
50
+ (eyebrow 11px uppercase tracking-wider text-faint + 15px semibold title).
51
+ - Body 13.5px · secondary 13px · meta 12–12.5px · hints 11.5px `text-faint`
52
+ · mono `font-mono` 12–12.5px. Never `text-base` or larger outside heroes.
53
+ - Keyboard hints: `<kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[10px]">`.
54
+
55
+ ## Composition patterns
56
+
57
+ - Lists/tables live inside a `.surface` with `overflow-hidden`; optional
58
+ header band: `border-b border-border/70 bg-surface-2/40 px-3.5 py-2`.
59
+ - Dense rows: `px-3.5 py-2.5`, hover `hover:bg-surface-2`, trailing chevron
60
+ that nudges on hover (`group-hover:translate-x-0.5`). Numeric columns
61
+ right-aligned with `.tnum`.
62
+ - Live state: `SessionStatus` (pulse-ring on running states). Poll lists that
63
+ should feel alive: `useQuery(path, [], 4000)` — it refreshes silently.
64
+ - Empty/error/loading: always `EmptyState` / `ErrorState` / `Loading` or
65
+ `.skeleton` — never a bare "No data" string.
66
+ - Forms: `Label` 13px + control per row, `gap-4`+ between rows; dialogs get a
67
+ clear title + one-line description; primary action right-aligned in footer.
68
+ - Motion: `animate-fade-in` for page mounts (built into `Page`),
69
+ `animate-slide-up` for hero/section entrances, transitions ~150ms. No
70
+ animation on data refresh.
71
+
72
+ ## Primitives — compose, don't reinvent
73
+
74
+ `Page`, `PageHeader`, `SectionHeading`, `Metric`, `EmptyState`, `ErrorState`,
75
+ `SessionStatus`, `Loading`, `Spinner`, `CopyButton`, `ConfirmButton`,
76
+ `SecretReveal` (from `components/shared.tsx`, `components/primitives.tsx`)
77
+ plus the shadcn primitives in `components/ui/`.
78
+
79
+ ## Hard rules
80
+
81
+ - Two modes, one structure: every screen must hold up in light AND dark.
82
+ No new color literals — tokens only. Mode-specific styling means a token
83
+ (or `--var`) tuned in globals.css, not a `dark:` utility in a component.
84
+ - Theme plumbing: boot script in `app/layout.tsx`, `useTheme` in
85
+ `lib/theme.ts`, `ThemeToggle` in the topbar. localStorage `garage.theme`,
86
+ absent = follow OS.
87
+ - Keep every file under 200 lines; split before exceeding.
88
+ - Don't restyle by overriding primitives with long `className` chains — if a
89
+ pattern repeats three times, it belongs in a shared component.
90
+ - Visual changes must not alter API calls, data flow, or component contracts.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Garage Dashboard
2
+
3
+ A clean, professional console for the Glorp **Garage** orchestration layer. It
4
+ talks directly to a running Garage server's REST + WebSocket API and gives you
5
+ live observability and control over the core primitives:
6
+
7
+ - **Sessions** — list, create, destroy, and watch a session's live event stream;
8
+ send messages and abort runs.
9
+ - **Agents** — inspect and manage a session's multi-agent roster.
10
+ - **Messages** — the agent's latest answer and task list per session.
11
+ - **Namespaces** — create/delete tenant partitions and mint namespace-bound keys.
12
+ - **Workspaces** — manage the host directories sessions run against.
13
+ - **Provisioning** — launch sessions from declarative setup templates.
14
+ - **Credentials** — model providers and the profiles sessions inherit.
15
+ - **API Keys** — mint/revoke keys for the REST API and the MCP server.
16
+
17
+ ## Auth
18
+
19
+ The dashboard signs in with the admin identity provisioned on the Garage server:
20
+
21
+ ```bash
22
+ export GARAGE_ADMIN_USER=admin
23
+ export GARAGE_ADMIN_PASSWORD=change-me
24
+ export GARAGE_JWT_SECRET=$(openssl rand -hex 32) # optional; derived from the password if unset
25
+ ```
26
+
27
+ Login exchanges these for a short-lived JWT, which the browser then sends as a
28
+ Bearer token on every API request (and as `?api_key=` on the WebSocket).
29
+
30
+ ## Run
31
+
32
+ ```bash
33
+ cd dashboard
34
+ cp .env.example .env.local # point NEXT_PUBLIC_GARAGE_URL at your Garage
35
+ bun install
36
+ bun run dev # http://localhost:3270
37
+
38
+ # in another terminal, with admin env vars set:
39
+ glorp garage # the Garage API on http://127.0.0.1:4271
40
+ ```
41
+
42
+ For a non-loopback Garage bind, API-key auth is enforced automatically — the
43
+ admin JWT satisfies it (admin scope). On loopback, the API is open but the
44
+ dashboard still requires login when admin credentials are configured.
45
+
46
+ ## Build
47
+
48
+ ```bash
49
+ bun run build && bun run start
50
+ ```
51
+
52
+ The dashboard holds no server-side secrets: it is a pure client of the Garage
53
+ API, configured via `NEXT_PUBLIC_GARAGE_URL` (baked into the bundle at build).
54
+
55
+ ## Design system
56
+
57
+ Built on **Tailwind CSS + shadcn/ui** (Radix primitives) with a graphite
58
+ monochrome theme — tokens live in `app/globals.css`, primitives in
59
+ `components/ui/`, and the Garage brand mark in `components/brand.tsx`. The app is
60
+ session-centric: the session view (`/sessions/[id]`) hosts the live chat with
61
+ **Chat · Tasks · Agents · Details** tabs, so agents and messages aren't separate
62
+ pages.
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Plus } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { api } from "@/lib/api";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
10
+ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
11
+ import { Field, FieldRow, ModalFooter } from "./form";
12
+ import { ModelCombobox } from "./model-combobox";
13
+ import type { Catalog, ProviderWire } from "@/lib/types";
14
+
15
+ /** Add a (provider, model) profile. Reasoning effort is configured afterwards on
16
+ * the profile row — so a model stays a single entry, the way the TUI works. */
17
+ export function AddProfileModal({ providers, catalog, onSaved }: { providers: ProviderWire[]; catalog: Catalog | null; onSaved: () => void }) {
18
+ const [open, setOpen] = React.useState(false);
19
+ const [providerId, setProviderId] = React.useState("");
20
+ const [model, setModel] = React.useState("");
21
+ const [label, setLabel] = React.useState("");
22
+ const [busy, setBusy] = React.useState(false);
23
+
24
+ React.useEffect(() => {
25
+ if (open && !providerId && providers[0]) setProviderId(providers[0].id);
26
+ }, [open, providers, providerId]);
27
+
28
+ const suggestions = React.useMemo(() => {
29
+ const prov = providers.find((p) => p.id === providerId);
30
+ const key = prov?.based_on ?? providerId;
31
+ return catalog?.providers.find((c) => c.id === key)?.default_models ?? [];
32
+ }, [providers, catalog, providerId]);
33
+
34
+
35
+ const save = async () => {
36
+ if (!providerId || !model.trim()) {
37
+ toast.error("Provider and model are required.");
38
+ return;
39
+ }
40
+ setBusy(true);
41
+ try {
42
+ await api("/models/profiles", {
43
+ method: "POST",
44
+ body: {
45
+ providerId,
46
+ model: model.trim(),
47
+ label: label.trim() || undefined,
48
+ activate: true,
49
+ },
50
+ });
51
+ toast.success("Profile added & activated");
52
+ onSaved();
53
+ setOpen(false);
54
+ setModel("");
55
+ setLabel("");
56
+ } catch (e) {
57
+ toast.error(e instanceof Error ? e.message : "Failed to add profile");
58
+ } finally {
59
+ setBusy(false);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <Dialog open={open} onOpenChange={setOpen}>
65
+ <DialogTrigger asChild>
66
+ <Button variant="secondary" size="sm" disabled={providers.length === 0}>
67
+ <Plus /> Add model
68
+ </Button>
69
+ </DialogTrigger>
70
+ <DialogContent>
71
+ <DialogHeader>
72
+ <DialogTitle>Add model</DialogTitle>
73
+ <DialogDescription>Pair a provider with a model. Set reasoning effort afterwards from the model’s row.</DialogDescription>
74
+ </DialogHeader>
75
+
76
+ <div className="space-y-5">
77
+ <FieldRow>
78
+ <Field label="Provider">
79
+ <Select
80
+ value={providerId}
81
+ onValueChange={(id) => {
82
+ setProviderId(id);
83
+ setModel("");
84
+ }}
85
+ >
86
+ <SelectTrigger>
87
+ <SelectValue placeholder="Select…" />
88
+ </SelectTrigger>
89
+ <SelectContent>
90
+ {providers.map((p) => (
91
+ <SelectItem key={p.id} value={p.id}>
92
+ {p.id}
93
+ </SelectItem>
94
+ ))}
95
+ </SelectContent>
96
+ </Select>
97
+ </Field>
98
+ <Field label="Model">
99
+ <ModelCombobox providerId={providerId} catalog={suggestions} value={model} onChange={setModel} />
100
+ </Field>
101
+ </FieldRow>
102
+ <FieldRow>
103
+ <Field label="Label (optional)">
104
+ <Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder={`${providerId || "provider"} · ${model || "model"}`} />
105
+ </Field>
106
+ <Field label="Context window" hint="Detected automatically from the model catalog. Override per provider under its edit dialog if needed.">
107
+ <div className="flex h-9 items-center rounded-md border border-border/60 bg-surface-2/40 px-3 text-[13px] text-muted-foreground">Auto</div>
108
+ </Field>
109
+ </FieldRow>
110
+ </div>
111
+
112
+ <ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Add model" busy={busy} />
113
+ </DialogContent>
114
+ </Dialog>
115
+ );
116
+ }
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Plus } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { api } from "@/lib/api";
7
+ import { verifyProvider } from "@/lib/verify-provider";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
11
+ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
12
+ import { Field, FieldRow, KeyInput, ModalFooter, VerifyBanner, slugify } from "./form";
13
+ import type { Catalog } from "@/lib/types";
14
+
15
+ const CUSTOM = "__custom__";
16
+ const NONE = "__none__";
17
+ type Verdict = { ok: true; models: number } | { ok: false; message: string };
18
+
19
+ export function AddProviderModal({ catalog, onSaved }: { catalog: Catalog | null; onSaved: () => void }) {
20
+ const known = catalog?.providers ?? [];
21
+ const adapters = catalog?.adapters ?? [];
22
+
23
+ const [open, setOpen] = React.useState(false);
24
+ const [choice, setChoice] = React.useState("");
25
+ const [name, setName] = React.useState("");
26
+ const [apiKey, setApiKey] = React.useState("");
27
+ const [baseURL, setBaseURL] = React.useState("");
28
+ const [adapter, setAdapter] = React.useState(adapters[0]?.id ?? "openai-compat");
29
+ const [basedOn, setBasedOn] = React.useState(NONE);
30
+ const [contextLimit, setContextLimit] = React.useState("");
31
+ const [busy, setBusy] = React.useState(false);
32
+ const [verdict, setVerdict] = React.useState<Verdict | null>(null);
33
+
34
+ const isCustom = choice === CUSTOM;
35
+ const meta = known.find((p) => p.id === choice);
36
+ const slug = slugify(name);
37
+ const id = isCustom ? (slug ? `custom-${slug}` : "") : choice;
38
+ const needsKey = isCustom ? false : meta?.needs_api_key ?? true;
39
+
40
+ React.useEffect(() => {
41
+ if (!open) {
42
+ setChoice(""); setName(""); setApiKey(""); setBaseURL(""); setBasedOn(NONE); setContextLimit(""); setVerdict(null);
43
+ }
44
+ }, [open]);
45
+
46
+ const save = async () => {
47
+ if (!id) {
48
+ toast.error(isCustom ? "Name the custom endpoint." : "Pick a provider.");
49
+ return;
50
+ }
51
+ setBusy(true);
52
+ setVerdict(null);
53
+ try {
54
+ await api("/models/providers", {
55
+ method: "POST",
56
+ body: {
57
+ id,
58
+ type: isCustom ? "custom" : "known",
59
+ apiKey: apiKey.trim() || undefined,
60
+ baseURL: isCustom ? baseURL.trim() || undefined : undefined,
61
+ ...(isCustom ? { adapter } : {}),
62
+ ...(isCustom && basedOn !== NONE ? { basedOn } : {}),
63
+ ...(contextLimit.trim() ? { contextLimit: Number(contextLimit) } : {}),
64
+ },
65
+ });
66
+ onSaved();
67
+ const v = await verifyProvider(id);
68
+ if (v.ok) {
69
+ setVerdict({ ok: true, models: v.models.length });
70
+ setTimeout(() => setOpen(false), 900);
71
+ } else {
72
+ setVerdict({ ok: false, message: v.message });
73
+ }
74
+ } catch (e) {
75
+ toast.error(e instanceof Error ? e.message : "Failed to save provider");
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ };
80
+
81
+ const failed = verdict && !verdict.ok;
82
+
83
+ return (
84
+ <Dialog open={open} onOpenChange={setOpen}>
85
+ <DialogTrigger asChild>
86
+ <Button variant="secondary" size="sm">
87
+ <Plus /> Add provider
88
+ </Button>
89
+ </DialogTrigger>
90
+ <DialogContent>
91
+ <DialogHeader>
92
+ <DialogTitle>Add provider</DialogTitle>
93
+ <DialogDescription>Pick a known provider, or wire up a custom OpenAI-compatible endpoint.</DialogDescription>
94
+ </DialogHeader>
95
+
96
+ <div className="space-y-5">
97
+ <Field label="Provider" hint={meta?.description}>
98
+ <Select value={choice} onValueChange={(v) => { setChoice(v); setVerdict(null); }}>
99
+ <SelectTrigger>
100
+ <SelectValue placeholder="Select a provider…" />
101
+ </SelectTrigger>
102
+ <SelectContent>
103
+ {known.map((p) => (
104
+ <SelectItem key={p.id} value={p.id}>{p.label}</SelectItem>
105
+ ))}
106
+ <SelectItem value={CUSTOM}>Custom (OpenAI-compatible)…</SelectItem>
107
+ </SelectContent>
108
+ </Select>
109
+ </Field>
110
+
111
+ {isCustom && (
112
+ <FieldRow>
113
+ <Field label="Name" hint={id ? <>id: <span className="font-mono text-faint">{id}</span></> : "Becomes custom-<slug>"}>
114
+ <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My proxy" />
115
+ </Field>
116
+ <Field label="Adapter" hint="OpenAI-compatible">
117
+ <Select value={adapter} onValueChange={setAdapter}>
118
+ <SelectTrigger><SelectValue /></SelectTrigger>
119
+ <SelectContent>
120
+ {adapters.map((a) => (<SelectItem key={a.id} value={a.id}>{a.label}</SelectItem>))}
121
+ </SelectContent>
122
+ </Select>
123
+ </Field>
124
+ </FieldRow>
125
+ )}
126
+
127
+ {(choice || isCustom) && (
128
+ <Field
129
+ label={needsKey ? "API key" : "API key (optional)"}
130
+ hint={!isCustom && meta?.env_var ? <>Usually the key from <span className="font-mono">${meta.env_var}</span>.</> : undefined}
131
+ >
132
+ <KeyInput value={apiKey} onChange={setApiKey} placeholder={needsKey ? "Paste the API key" : "Leave blank if none"} />
133
+ </Field>
134
+ )}
135
+
136
+ {isCustom && (
137
+ <>
138
+ <Field label="Base URL">
139
+ <Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…/v1" />
140
+ </Field>
141
+ <FieldRow>
142
+ <Field label="Based on" hint="Borrow a known provider's models">
143
+ <Select value={basedOn} onValueChange={setBasedOn}>
144
+ <SelectTrigger><SelectValue /></SelectTrigger>
145
+ <SelectContent>
146
+ <SelectItem value={NONE}>None</SelectItem>
147
+ {known.map((p) => (<SelectItem key={p.id} value={p.id}>{p.label}</SelectItem>))}
148
+ </SelectContent>
149
+ </Select>
150
+ </Field>
151
+ <Field label="Context limit">
152
+ <Input inputMode="numeric" value={contextLimit} onChange={(e) => setContextLimit(e.target.value)} placeholder="e.g. 200000" />
153
+ </Field>
154
+ </FieldRow>
155
+ </>
156
+ )}
157
+
158
+ {verdict && <VerifyBanner state={verdict} />}
159
+ </div>
160
+
161
+ {failed ? (
162
+ <div className="flex justify-end gap-2 sm:flex-row">
163
+ <Button variant="ghost" onClick={() => setOpen(false)}>Keep anyway</Button>
164
+ <Button onClick={() => setVerdict(null)}>Fix key</Button>
165
+ </div>
166
+ ) : (
167
+ <ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Save provider" busy={busy} disabled={!!verdict} />
168
+ )}
169
+ </DialogContent>
170
+ </Dialog>
171
+ );
172
+ }
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Pencil } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { api } from "@/lib/api";
7
+ import { verifyProvider } from "@/lib/verify-provider";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
11
+ import { Field, FieldRow, KeyInput, ModalFooter, VerifyBanner } from "./form";
12
+ import type { ProviderWire } from "@/lib/types";
13
+
14
+ type Verdict = { ok: true; models: number } | { ok: false; message: string };
15
+
16
+ /** Update an existing provider's API key (and optionally base URL / context
17
+ * limit). Other fields are preserved server-side, so leaving the key blank
18
+ * keeps the current one. A rotated key is verified live after saving. */
19
+ export function EditProviderModal({ provider, onSaved }: { provider: ProviderWire; onSaved: () => void }) {
20
+ const [open, setOpen] = React.useState(false);
21
+ const [apiKey, setApiKey] = React.useState("");
22
+ const [baseURL, setBaseURL] = React.useState(provider.base_url ?? "");
23
+ const [contextLimit, setContextLimit] = React.useState(provider.context_limit ? String(provider.context_limit) : "");
24
+ const [busy, setBusy] = React.useState(false);
25
+ const [verdict, setVerdict] = React.useState<Verdict | null>(null);
26
+
27
+ const rotated = apiKey.trim().length > 0;
28
+
29
+ const save = async () => {
30
+ setBusy(true);
31
+ setVerdict(null);
32
+ try {
33
+ await api("/models/providers", {
34
+ method: "POST",
35
+ body: {
36
+ id: provider.id,
37
+ ...(rotated ? { apiKey: apiKey.trim() } : {}),
38
+ ...(baseURL.trim() ? { baseURL: baseURL.trim() } : {}),
39
+ ...(contextLimit.trim() ? { contextLimit: Number(contextLimit) } : {}),
40
+ },
41
+ });
42
+ onSaved();
43
+ if (!rotated) {
44
+ toast.success("Provider updated");
45
+ setOpen(false);
46
+ return;
47
+ }
48
+ const v = await verifyProvider(provider.id);
49
+ if (v.ok) {
50
+ setVerdict({ ok: true, models: v.models.length });
51
+ setApiKey("");
52
+ setTimeout(() => setOpen(false), 900);
53
+ } else {
54
+ setVerdict({ ok: false, message: v.message });
55
+ }
56
+ } catch (e) {
57
+ toast.error(e instanceof Error ? e.message : "Update failed");
58
+ } finally {
59
+ setBusy(false);
60
+ }
61
+ };
62
+
63
+ const failed = verdict && !verdict.ok;
64
+
65
+ return (
66
+ <Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) { setApiKey(""); setVerdict(null); } }}>
67
+ <DialogTrigger asChild>
68
+ <Button variant="ghost" size="icon-sm" className="text-muted-foreground" title="Edit provider">
69
+ <Pencil />
70
+ </Button>
71
+ </DialogTrigger>
72
+ <DialogContent>
73
+ <DialogHeader>
74
+ <DialogTitle>Edit {provider.id}</DialogTitle>
75
+ <DialogDescription>Rotate the API key or adjust the endpoint. Anything left blank keeps its current value.</DialogDescription>
76
+ </DialogHeader>
77
+ <div className="space-y-5">
78
+ <Field label="API key">
79
+ <KeyInput
80
+ autoFocus
81
+ value={apiKey}
82
+ onChange={setApiKey}
83
+ placeholder={provider.has_api_key ? "•••• stored — leave blank to keep" : "Set an API key"}
84
+ />
85
+ </Field>
86
+ <FieldRow>
87
+ <Field label="Base URL">
88
+ <Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…/v1" />
89
+ </Field>
90
+ <Field label="Context limit">
91
+ <Input inputMode="numeric" value={contextLimit} onChange={(e) => setContextLimit(e.target.value)} placeholder="e.g. 200000" />
92
+ </Field>
93
+ </FieldRow>
94
+ {verdict && <VerifyBanner state={verdict} />}
95
+ </div>
96
+ {failed ? (
97
+ <div className="flex justify-end gap-2 sm:flex-row">
98
+ <Button variant="ghost" onClick={() => setOpen(false)}>Keep anyway</Button>
99
+ <Button onClick={() => setVerdict(null)}>Fix key</Button>
100
+ </div>
101
+ ) : (
102
+ <ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Save" busy={busy} disabled={!!verdict} />
103
+ )}
104
+ </DialogContent>
105
+ </Dialog>
106
+ );
107
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Sparkles } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { api } from "@/lib/api";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Spinner } from "@/components/shared";
9
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
10
+ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
11
+ import { Field, ModalFooter } from "./form";
12
+ import type { ProfileWire, ReasoningOption } from "@/lib/types";
13
+
14
+ /** Change a profile's reasoning effort in place (mirrors the TUI `r` cycle):
15
+ * one model entry, with reasoning as an editable attribute — not a duplicate. */
16
+ export function EditReasoningModal({ profile, onSaved }: { profile: ProfileWire; onSaved: () => void }) {
17
+ const [open, setOpen] = React.useState(false);
18
+ const [opts, setOpts] = React.useState<ReasoningOption[] | null>(null);
19
+ const [idx, setIdx] = React.useState("0");
20
+ const [busy, setBusy] = React.useState(false);
21
+
22
+ React.useEffect(() => {
23
+ if (!open) return;
24
+ setOpts(null);
25
+ const q = `?provider=${encodeURIComponent(profile.provider_id)}&model=${encodeURIComponent(profile.model)}`;
26
+ api<{ options: ReasoningOption[] }>(`/models/reasoning-options${q}`)
27
+ .then((r) => {
28
+ setOpts(r.options);
29
+ const cur = JSON.stringify(profile.reasoning ?? { kind: "off" });
30
+ const found = r.options.findIndex((o) => JSON.stringify(o.value) === cur);
31
+ setIdx(String(found >= 0 ? found : 0));
32
+ })
33
+ .catch(() => setOpts([]));
34
+ }, [open, profile.provider_id, profile.model, profile.reasoning]);
35
+
36
+ const save = async () => {
37
+ if (!opts) return;
38
+ setBusy(true);
39
+ try {
40
+ await api(`/models/profiles/${profile.id}/reasoning`, { method: "POST", body: { reasoning: opts[Number(idx)]?.value ?? { kind: "off" } } });
41
+ toast.success("Reasoning updated");
42
+ onSaved();
43
+ setOpen(false);
44
+ } catch (e) {
45
+ toast.error(e instanceof Error ? e.message : "Update failed");
46
+ } finally {
47
+ setBusy(false);
48
+ }
49
+ };
50
+
51
+ return (
52
+ <Dialog open={open} onOpenChange={setOpen}>
53
+ <DialogTrigger asChild>
54
+ <Button variant="ghost" size="sm" className="text-muted-foreground" title="Reasoning effort">
55
+ <Sparkles /> Reasoning
56
+ </Button>
57
+ </DialogTrigger>
58
+ <DialogContent>
59
+ <DialogHeader>
60
+ <DialogTitle>Reasoning effort</DialogTitle>
61
+ <DialogDescription>
62
+ {profile.provider_id} · <span className="font-mono">{profile.model}</span> — sets the thinking effort this model uses.
63
+ </DialogDescription>
64
+ </DialogHeader>
65
+ <div className="space-y-5">
66
+ {opts === null ? (
67
+ <div className="flex items-center justify-center gap-2.5 py-6 text-[13px] text-muted-foreground">
68
+ <Spinner /> Loading effort levels…
69
+ </div>
70
+ ) : opts.length === 0 ? (
71
+ <p className="rounded-lg border border-border bg-surface-2/40 px-4 py-3 text-[13px] text-muted-foreground">
72
+ This model doesn’t expose reasoning controls.
73
+ </p>
74
+ ) : (
75
+ <Field label="Effort">
76
+ <Select value={idx} onValueChange={setIdx}>
77
+ <SelectTrigger>
78
+ <SelectValue />
79
+ </SelectTrigger>
80
+ <SelectContent>
81
+ {opts.map((o, i) => (
82
+ <SelectItem key={i} value={String(i)}>
83
+ {o.label}
84
+ {o.description ? ` — ${o.description}` : ""}
85
+ </SelectItem>
86
+ ))}
87
+ </SelectContent>
88
+ </Select>
89
+ </Field>
90
+ )}
91
+ </div>
92
+ <ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Save" busy={busy} disabled={!opts || opts.length === 0} />
93
+ </DialogContent>
94
+ </Dialog>
95
+ );
96
+ }