@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
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Sparkles } from "lucide-react";
5
+ import { LaunchComposer } from "./launch";
6
+ import { VerifiedLine } from "./onboarding-shared";
7
+ import type { WorkspaceDto, ProfileDto } from "@/lib/types";
8
+
9
+ const EXAMPLES = [
10
+ "Explore this workspace and summarize what's here.",
11
+ "Find the project's entry point and explain how it boots.",
12
+ "List the dependencies and flag anything outdated.",
13
+ ];
14
+
15
+ /** Step 3 — a brief success beat, then the real launch composer with primers. */
16
+ export function PutToWork({ model, workspaces, profiles }: { model: string; workspaces: WorkspaceDto[]; profiles: ProfileDto[] }) {
17
+ const [prompt, setPrompt] = React.useState("");
18
+
19
+ return (
20
+ <div className="animate-slide-up space-y-5">
21
+ <div className="mx-auto max-w-md">
22
+ <VerifiedLine>
23
+ <span className="font-mono">{model}</span> is ready
24
+ </VerifiedLine>
25
+ </div>
26
+
27
+ <div className="mx-auto max-w-2xl">
28
+ <LaunchComposer key={prompt} workspaces={workspaces} profiles={profiles} initialPrompt={prompt} />
29
+ </div>
30
+
31
+ <div className="mx-auto flex max-w-2xl flex-wrap justify-center gap-2">
32
+ {EXAMPLES.map((e) => (
33
+ <button
34
+ key={e}
35
+ type="button"
36
+ onClick={() => setPrompt(e)}
37
+ className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2/40 px-3 py-1.5 text-[12px] text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground"
38
+ >
39
+ <Sparkles className="size-3 text-brand/70" />
40
+ {e}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ArrowLeft, Check, Cpu } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { api } from "@/lib/api";
7
+ import { cn } from "@/lib/utils";
8
+ import { verifyProvider, type VerifyOutcome } from "@/lib/verify-provider";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { ErrorState, Spinner } from "@/components/shared";
12
+ import { StepCard, ShimmerLine, VerifiedLine } from "./onboarding-shared";
13
+
14
+ /** Step 2 — verify the saved key, then pick a model from the live list it returns. */
15
+ export function PickModel({ providerId, catalog, onBack, onPicked }: { providerId: string; catalog: string[]; onBack: () => void; onPicked: (model: string) => void }) {
16
+ const [outcome, setOutcome] = React.useState<VerifyOutcome | null>(null);
17
+ const [model, setModel] = React.useState("");
18
+ const [manual, setManual] = React.useState(false);
19
+ const [busy, setBusy] = React.useState(false);
20
+
21
+ React.useEffect(() => {
22
+ let live = true;
23
+ setOutcome(null);
24
+ verifyProvider(providerId).then((o) => live && setOutcome(o));
25
+ return () => {
26
+ live = false;
27
+ };
28
+ }, [providerId]);
29
+
30
+ const models = outcome?.ok ? outcome.models : [];
31
+ const escapeHatch = manual || (outcome && !outcome.ok);
32
+
33
+ const pick = async (chosen: string) => {
34
+ if (!chosen.trim() || busy) return;
35
+ setBusy(true);
36
+ try {
37
+ await api("/models/profiles", { method: "POST", body: { providerId, model: chosen.trim(), activate: true } });
38
+ onPicked(chosen.trim());
39
+ } catch (e) {
40
+ toast.error(e instanceof Error ? e.message : "Could not save the model");
41
+ setBusy(false);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <StepCard title="Pick a model" subtitle="We verified the key against the provider — choose the model your agent should run on.">
47
+ {!outcome && <ShimmerLine label="Checking the key against the provider…" />}
48
+
49
+ {outcome?.ok && (
50
+ <>
51
+ <VerifiedLine>Key verified — {models.length} {models.length === 1 ? "model" : "models"} available</VerifiedLine>
52
+ <div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border bg-surface-2/30 p-1.5">
53
+ {models.length === 0 && <p className="px-2 py-3 text-[12.5px] text-muted-foreground">No models reported. Type one below.</p>}
54
+ {models.map((m) => (
55
+ <button
56
+ key={m}
57
+ type="button"
58
+ onClick={() => setModel(m)}
59
+ className={cn(
60
+ "flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left font-mono text-[12.5px] transition-colors",
61
+ model === m ? "bg-brand/15 text-foreground" : "text-muted-foreground hover:bg-surface-2 hover:text-foreground",
62
+ )}
63
+ >
64
+ <Check className={cn("size-3.5 shrink-0 text-brand transition-opacity", model === m ? "opacity-100" : "opacity-0")} />
65
+ <span className="truncate">{m}</span>
66
+ </button>
67
+ ))}
68
+ </div>
69
+ </>
70
+ )}
71
+
72
+ {outcome && !outcome.ok && (
73
+ <ErrorState message={outcome.reason === "network" ? `${outcome.message} You can go back and fix the base URL.` : outcome.message} />
74
+ )}
75
+
76
+ {escapeHatch && (
77
+ <div className="space-y-1.5">
78
+ <span className="text-[13px] font-medium text-foreground">Model id</span>
79
+ <Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. gpt-4o" className="font-mono text-[12.5px]" />
80
+ {catalog.length > 0 && (
81
+ <div className="flex flex-wrap gap-1.5 pt-0.5">
82
+ {catalog.slice(0, 6).map((m) => (
83
+ <button key={m} type="button" onClick={() => setModel(m)} className="rounded-md border border-border bg-surface-2/40 px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground">
84
+ {m}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ )}
89
+ </div>
90
+ )}
91
+
92
+ <div className="flex items-center justify-between pt-1">
93
+ <Button variant="ghost" size="sm" onClick={onBack} disabled={busy}>
94
+ <ArrowLeft /> Back
95
+ </Button>
96
+ <div className="flex items-center gap-2">
97
+ {outcome?.ok && !manual && (
98
+ <button type="button" onClick={() => setManual(true)} className="text-[12px] text-faint transition-colors hover:text-muted-foreground">
99
+ Continue anyway
100
+ </button>
101
+ )}
102
+ <Button onClick={() => pick(model)} disabled={!model.trim() || busy}>
103
+ {busy ? <Spinner /> : <Cpu />} Use model
104
+ </Button>
105
+ </div>
106
+ </div>
107
+ </StepCard>
108
+ );
109
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, Eye, EyeOff, Plug, SlidersHorizontal } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { api } from "@/lib/api";
7
+ import { cn } from "@/lib/utils";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Spinner } from "@/components/shared";
11
+ import { StepCard } from "./onboarding-shared";
12
+ import type { Catalog, CatalogProvider } from "@/lib/types";
13
+
14
+ const CUSTOM = "__custom__";
15
+ const slug = (s: string) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16
+
17
+ /** Step 1 — connect a known provider, or wire a custom OpenAI-compatible endpoint. */
18
+ export function ConnectProvider({ catalog, onConnected }: { catalog: Catalog | null; onConnected: (id: string, models: string[]) => void }) {
19
+ const known = catalog?.providers ?? [];
20
+ const [choice, setChoice] = React.useState("");
21
+ const [customName, setCustomName] = React.useState("");
22
+ const [baseURL, setBaseURL] = React.useState("");
23
+ const [apiKey, setApiKey] = React.useState("");
24
+ const [reveal, setReveal] = React.useState(false);
25
+ const [busy, setBusy] = React.useState(false);
26
+
27
+ const isCustom = choice === CUSTOM;
28
+ const meta = known.find((p) => p.id === choice);
29
+ const needsKey = isCustom ? true : meta?.needs_api_key ?? true;
30
+ const id = isCustom ? `custom-${slug(customName)}` : choice;
31
+ const ready = isCustom ? Boolean(slug(customName) && baseURL.trim()) : Boolean(choice);
32
+
33
+ const connect = async () => {
34
+ if (!ready || busy) return;
35
+ setBusy(true);
36
+ try {
37
+ await api("/models/providers", {
38
+ method: "POST",
39
+ body: { id, type: isCustom ? "custom" : "known", apiKey: apiKey.trim() || undefined, baseURL: baseURL.trim() || undefined },
40
+ });
41
+ onConnected(id, meta?.default_models ?? []);
42
+ } catch (e) {
43
+ toast.error(e instanceof Error ? e.message : "Could not save the provider");
44
+ setBusy(false);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <StepCard title="Connect a provider" subtitle="Choose where your agent's model runs. The key is stored server-side and never returned to the browser.">
50
+ <div className="grid gap-2 sm:grid-cols-2">
51
+ {known.map((p) => (
52
+ <ProviderRow key={p.id} p={p} selected={choice === p.id} onSelect={() => setChoice(p.id)} />
53
+ ))}
54
+ <CustomRow selected={isCustom} onSelect={() => setChoice(CUSTOM)} />
55
+ </div>
56
+
57
+ {isCustom && (
58
+ <div className="grid gap-3 sm:grid-cols-2">
59
+ <Field label="Name">
60
+ <Input value={customName} onChange={(e) => setCustomName(e.target.value)} placeholder="my-proxy" />
61
+ </Field>
62
+ <Field label="Base URL" hint="OpenAI-compatible · ends in /v1">
63
+ <Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…/v1" />
64
+ </Field>
65
+ </div>
66
+ )}
67
+
68
+ {choice && needsKey && (
69
+ <Field label="API key">
70
+ <div className="relative">
71
+ <Input type={reveal ? "text" : "password"} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" className="pr-9 font-mono text-[12.5px]" />
72
+ <button type="button" onClick={() => setReveal((r) => !r)} className="absolute inset-y-0 right-0 grid w-9 place-items-center text-faint transition-colors hover:text-foreground" aria-label={reveal ? "Hide key" : "Show key"}>
73
+ {reveal ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
74
+ </button>
75
+ </div>
76
+ </Field>
77
+ )}
78
+
79
+ <div className="flex justify-end pt-1">
80
+ <Button onClick={connect} disabled={!ready || busy}>
81
+ {busy ? <Spinner /> : <Plug />} Continue
82
+ </Button>
83
+ </div>
84
+ </StepCard>
85
+ );
86
+ }
87
+
88
+ function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
89
+ return (
90
+ <div className="space-y-1.5">
91
+ <span className="text-[13px] font-medium text-foreground">{label}</span>
92
+ {children}
93
+ {hint && <p className="text-[11.5px] text-faint">{hint}</p>}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ function ProviderRow({ p, selected, onSelect }: { p: CatalogProvider; selected: boolean; onSelect: () => void }) {
99
+ return (
100
+ <PickRow selected={selected} onSelect={onSelect}>
101
+ <div className="min-w-0">
102
+ <div className="truncate text-[13px] font-medium text-foreground">{p.label}</div>
103
+ <div className="truncate text-[11.5px] text-faint">{p.description}</div>
104
+ </div>
105
+ </PickRow>
106
+ );
107
+ }
108
+
109
+ function CustomRow({ selected, onSelect }: { selected: boolean; onSelect: () => void }) {
110
+ return (
111
+ <PickRow selected={selected} onSelect={onSelect}>
112
+ <SlidersHorizontal className="size-4 shrink-0 text-faint" />
113
+ <div className="min-w-0">
114
+ <div className="text-[13px] font-medium text-foreground">Custom endpoint</div>
115
+ <div className="text-[11.5px] text-faint">Any OpenAI-compatible API</div>
116
+ </div>
117
+ </PickRow>
118
+ );
119
+ }
120
+
121
+ function PickRow({ selected, onSelect, children }: { selected: boolean; onSelect: () => void; children: React.ReactNode }) {
122
+ return (
123
+ <button
124
+ type="button"
125
+ onClick={onSelect}
126
+ className={cn(
127
+ "flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors",
128
+ selected ? "border-brand/50 bg-brand/[0.07]" : "border-border bg-surface-2/30 hover:border-border-strong hover:bg-surface-2",
129
+ )}
130
+ >
131
+ <span className={cn("grid size-4 shrink-0 place-items-center rounded-full border transition-colors", selected ? "border-brand bg-brand text-brand-foreground" : "border-border-strong")}>
132
+ {selected && <Check className="size-2.5" />}
133
+ </span>
134
+ {children}
135
+ </button>
136
+ );
137
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const STEPS = ["Connect a provider", "Pick a model", "Put the agent to work"] as const;
8
+
9
+ /** Quiet 1 · 2 · 3 rail: done steps get a check, the current step the brand accent. */
10
+ export function StepRail({ step }: { step: number }) {
11
+ return (
12
+ <ol className="mb-9 flex items-center justify-center gap-2.5" aria-label={`Step ${step + 1} of ${STEPS.length}`}>
13
+ {STEPS.map((label, i) => {
14
+ const done = i < step;
15
+ const active = i === step;
16
+ return (
17
+ <li key={label} className="flex items-center gap-2.5">
18
+ <span
19
+ className={cn(
20
+ "grid size-5 place-items-center rounded-full border text-[11px] font-semibold tabular-nums transition-colors",
21
+ done && "border-brand/40 bg-brand/15 text-brand",
22
+ active && "border-brand bg-brand text-brand-foreground shadow-sheen",
23
+ !done && !active && "border-border bg-surface-2 text-faint",
24
+ )}
25
+ >
26
+ {done ? <Check className="size-3" /> : i + 1}
27
+ </span>
28
+ <span className={cn("hidden text-[12px] font-medium sm:block", active ? "text-foreground" : "text-faint")}>{label}</span>
29
+ {i < STEPS.length - 1 && <span className="h-px w-5 bg-border sm:w-7" />}
30
+ </li>
31
+ );
32
+ })}
33
+ </ol>
34
+ );
35
+ }
36
+
37
+ /** The framed body each step lives in: title, subtitle, then its controls. */
38
+ export function StepCard({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
39
+ return (
40
+ <div className="surface animate-slide-up p-6 md:p-7">
41
+ <h2 className="text-[16px] font-semibold tracking-tight text-foreground">{title}</h2>
42
+ <p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{subtitle}</p>
43
+ <div className="mt-6 space-y-4">{children}</div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ /** A shimmering line of placeholder copy, e.g. while a key is being checked. */
49
+ export function ShimmerLine({ label }: { label: string }) {
50
+ return (
51
+ <div className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2/40 px-3.5 py-3">
52
+ <div className="skeleton size-3.5 rounded-full" />
53
+ <span className="text-[12.5px] text-muted-foreground">{label}</span>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ /** A success line in the success tone — used after key verification + at handoff. */
59
+ export function VerifiedLine({ children }: { children: React.ReactNode }) {
60
+ return (
61
+ <div className="flex items-center gap-2 rounded-lg border border-success/25 bg-success/[0.07] px-3.5 py-2.5 text-[12.5px] font-medium text-success">
62
+ <Check className="size-3.5 shrink-0" />
63
+ {children}
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useQuery } from "@/lib/hooks";
5
+ import { StepRail } from "./onboarding-shared";
6
+ import { ConnectProvider } from "./onboarding-provider";
7
+ import { PickModel } from "./onboarding-model";
8
+ import { PutToWork } from "./onboarding-launch";
9
+ import type { Catalog, WorkspaceDto, ProfileDto } from "@/lib/types";
10
+
11
+ interface Picked {
12
+ providerId: string;
13
+ models: string[];
14
+ model: string;
15
+ }
16
+
17
+ /**
18
+ * First-run setup: connect a provider → verify + pick a model → put the agent
19
+ * to work. Shown in place of the hero while the namespace has no usable model.
20
+ * Calling `onDone` once a profile is active reveals the normal Fleet.
21
+ */
22
+ export function OnboardingFlow({ workspaces, profiles, onDone, onSkip }: { workspaces: WorkspaceDto[]; profiles: ProfileDto[]; onDone: () => void; onSkip: () => void }) {
23
+ const catalog = useQuery<Catalog>("/models/catalog");
24
+ const [step, setStep] = React.useState(0);
25
+ const [picked, setPicked] = React.useState<Picked>({ providerId: "", models: [], model: "" });
26
+
27
+ return (
28
+ <section className="mx-auto w-full max-w-xl animate-slide-up">
29
+ <div className="mb-7 text-center">
30
+ <h1 className="text-display">Set up your first model</h1>
31
+ <p className="mx-auto mt-2.5 max-w-md text-[13.5px] leading-relaxed text-muted-foreground">
32
+ Three quick steps and Glorp can start working in a sandboxed workspace.
33
+ </p>
34
+ </div>
35
+
36
+ <StepRail step={step} />
37
+
38
+ {step === 0 && (
39
+ <ConnectProvider
40
+ catalog={catalog.data}
41
+ onConnected={(providerId, models) => {
42
+ setPicked({ providerId, models, model: "" });
43
+ setStep(1);
44
+ }}
45
+ />
46
+ )}
47
+
48
+ {step === 1 && (
49
+ <PickModel
50
+ providerId={picked.providerId}
51
+ catalog={picked.models}
52
+ onBack={() => setStep(0)}
53
+ onPicked={(model) => {
54
+ setPicked((p) => ({ ...p, model }));
55
+ setStep(2);
56
+ }}
57
+ />
58
+ )}
59
+
60
+ {step === 2 && <PutToWork model={picked.model} workspaces={workspaces} profiles={profiles} />}
61
+
62
+ <div className="mt-7 text-center">
63
+ {step === 2 ? (
64
+ <button type="button" onClick={onDone} className="text-[12px] text-faint transition-colors hover:text-muted-foreground">
65
+ Go to the Fleet →
66
+ </button>
67
+ ) : (
68
+ <button type="button" onClick={onSkip} className="text-[12px] text-faint transition-colors hover:text-muted-foreground">
69
+ Skip — I&apos;ll configure later
70
+ </button>
71
+ )}
72
+ </div>
73
+ </section>
74
+ );
75
+ }
@@ -0,0 +1,65 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import type { LucideIcon } from "lucide-react";
4
+
5
+ type Tone = "neutral" | "brand" | "success" | "warning" | "destructive";
6
+
7
+ const TONE: Record<Tone, { value: string; icon: string }> = {
8
+ neutral: { value: "text-foreground", icon: "text-faint" },
9
+ brand: { value: "text-foreground", icon: "text-brand" },
10
+ success: { value: "text-foreground", icon: "text-success" },
11
+ warning: { value: "text-foreground", icon: "text-warning" },
12
+ destructive: { value: "text-foreground", icon: "text-destructive" },
13
+ };
14
+
15
+ /** A single metric tile: quiet label, prominent tabular value, optional icon. */
16
+ export function Metric({
17
+ label,
18
+ value,
19
+ icon: Icon,
20
+ tone = "neutral",
21
+ hint,
22
+ className,
23
+ }: {
24
+ label: string;
25
+ value: React.ReactNode;
26
+ icon?: LucideIcon;
27
+ tone?: Tone;
28
+ hint?: string;
29
+ className?: string;
30
+ }) {
31
+ const t = TONE[tone];
32
+ return (
33
+ <div className={cn("surface flex flex-col gap-1 p-4", className)}>
34
+ <div className="flex items-center justify-between">
35
+ <span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">{label}</span>
36
+ {Icon && <Icon className={cn("size-3.5", t.icon)} />}
37
+ </div>
38
+ <div className={cn("tnum text-[26px] font-semibold leading-none tracking-tight", t.value)}>{value}</div>
39
+ {hint && <span className="text-[11.5px] text-faint">{hint}</span>}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ /** Eyebrow + title row that opens a section, with an optional trailing action. */
45
+ export function SectionHeading({
46
+ eyebrow,
47
+ title,
48
+ action,
49
+ className,
50
+ }: {
51
+ eyebrow?: string;
52
+ title: React.ReactNode;
53
+ action?: React.ReactNode;
54
+ className?: string;
55
+ }) {
56
+ return (
57
+ <div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
58
+ <div className="min-w-0">
59
+ {eyebrow && <div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">{eyebrow}</div>}
60
+ <h2 className="text-[15px] font-semibold tracking-tight text-foreground">{title}</h2>
61
+ </div>
62
+ {action && <div className="shrink-0">{action}</div>}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Copy, FolderGit2, Rocket, Terminal, type LucideIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { api } from "@/lib/api";
8
+ import { useQuery } from "@/lib/hooks";
9
+ import { stepSummary } from "@/lib/template";
10
+ import { Loading, Spinner } from "@/components/shared";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Label } from "@/components/ui/label";
14
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
15
+ import type { TemplateSummaryDto, TemplateParamDto, TemplateFull, TemplateStep, SessionDto } from "@/lib/types";
16
+
17
+ const STEP_ICON: Record<TemplateStep["type"], LucideIcon> = { "git-clone": FolderGit2, shell: Terminal, copy: Copy };
18
+
19
+ /** Seed the form with each declared param's default (blank when none). */
20
+ function defaultsFor(params: TemplateParamDto[]): Record<string, string> {
21
+ const out: Record<string, string> = {};
22
+ for (const p of params) out[p.name] = p.default ?? "";
23
+ return out;
24
+ }
25
+
26
+ /** One declared-param input: required marking, default prefilled, secret → password. */
27
+ function ParamField({ param, value, onChange }: { param: TemplateParamDto; value: string; onChange: (v: string) => void }) {
28
+ return (
29
+ <div className="space-y-1.5">
30
+ <Label className="flex items-center gap-1.5 font-mono">
31
+ {param.name}
32
+ {param.required && <span className="text-destructive" aria-hidden>*</span>}
33
+ {param.secret && <span className="rounded bg-surface-2 px-1 py-0.5 text-[10px] font-normal text-faint">secret</span>}
34
+ </Label>
35
+ {param.description && <p className="text-[12px] text-muted-foreground">{param.description}</p>}
36
+ <Input
37
+ type={param.secret ? "password" : "text"}
38
+ value={value}
39
+ onChange={(e) => onChange(e.target.value)}
40
+ placeholder={param.default ? `default: ${param.default}` : `value for ${param.name}`}
41
+ />
42
+ </div>
43
+ );
44
+ }
45
+
46
+ export function ProvisionDialog({ template, onClose }: { template: TemplateSummaryDto | null; onClose: () => void }) {
47
+ const router = useRouter();
48
+ const { data, loading } = useQuery<{ template: TemplateFull }>(template ? `/templates/${template.name}` : null, [template?.name]);
49
+ const [params, setParams] = React.useState<Record<string, string>>({});
50
+ const [busy, setBusy] = React.useState(false);
51
+
52
+ const decls = template?.params ?? [];
53
+ const steps = data?.template.steps ?? [];
54
+
55
+ // Reset to the template's declared defaults whenever the open template changes.
56
+ React.useEffect(() => {
57
+ setParams(defaultsFor(template?.params ?? []));
58
+ }, [template?.name]);
59
+
60
+ // Required params with no value block provisioning; optional ones never do.
61
+ const incomplete = decls.some((p) => p.required && !(params[p.name] ?? "").trim());
62
+
63
+ const provision = async () => {
64
+ if (!template) return;
65
+ setBusy(true);
66
+ try {
67
+ // Drop blank optional values so the server applies the template's defaults.
68
+ const filled = Object.fromEntries(Object.entries(params).filter(([, v]) => v.trim() !== ""));
69
+ const s = await api<SessionDto>("/sessions", { method: "POST", body: { template: template.name, params: filled } });
70
+ toast.success("Provisioning session created");
71
+ router.push(`/sessions/${s.id}`);
72
+ } catch (e) {
73
+ toast.error(e instanceof Error ? e.message : "Provision failed");
74
+ setBusy(false);
75
+ }
76
+ };
77
+
78
+ return (
79
+ <Dialog open={!!template} onOpenChange={(o) => !o && onClose()}>
80
+ <DialogContent>
81
+ <DialogHeader>
82
+ <DialogTitle>Provision “{template?.name}”</DialogTitle>
83
+ <DialogDescription>This template prepares a fresh workspace, then it’s handed to a new session.</DialogDescription>
84
+ </DialogHeader>
85
+
86
+ {loading ? (
87
+ <Loading />
88
+ ) : (
89
+ <div className="space-y-5">
90
+ {steps.length > 0 && (
91
+ <div className="space-y-2">
92
+ <p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">Steps</p>
93
+ <ol className="overflow-hidden rounded-lg border border-border bg-surface-2/40 divide-y divide-border/60">
94
+ {steps.map((step, i) => {
95
+ const Icon = STEP_ICON[step.type];
96
+ return (
97
+ <li key={i} className="flex items-start gap-2.5 px-3 py-2">
98
+ <span className="tnum mt-0.5 w-4 shrink-0 text-center text-[12px] text-faint">{i + 1}</span>
99
+ <Icon className="mt-0.5 size-3.5 shrink-0 text-faint" />
100
+ <span className="min-w-0 break-words font-mono text-[12.5px] text-foreground/85">{stepSummary(step)}</span>
101
+ </li>
102
+ );
103
+ })}
104
+ </ol>
105
+ </div>
106
+ )}
107
+
108
+ {decls.length > 0 && (
109
+ <div className="space-y-4 border-t border-border pt-4">
110
+ <p className="text-[12px] text-muted-foreground">Fill in this template’s parameters:</p>
111
+ {decls.map((p) => (
112
+ <ParamField key={p.name} param={p} value={params[p.name] ?? ""} onChange={(v) => setParams((cur) => ({ ...cur, [p.name]: v }))} />
113
+ ))}
114
+ </div>
115
+ )}
116
+ </div>
117
+ )}
118
+
119
+ <DialogFooter>
120
+ <Button variant="ghost" onClick={onClose} disabled={busy}>
121
+ Cancel
122
+ </Button>
123
+ <Button onClick={provision} disabled={busy || loading || incomplete}>
124
+ {busy ? <Spinner /> : <Rocket />} Provision
125
+ </Button>
126
+ </DialogFooter>
127
+ </DialogContent>
128
+ </Dialog>
129
+ );
130
+ }