@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,64 @@
1
+ "use client";
2
+
3
+ import { KeyRound } from "lucide-react";
4
+ import { SecretReveal, Spinner } from "@/components/shared";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
9
+ import type { NamespaceDto } from "@/lib/types";
10
+
11
+ /** Mints a namespace-bound key, then reveals it once. Restyled to the form idiom. */
12
+ export function MintKeyDialog({
13
+ namespace,
14
+ keyName,
15
+ minted,
16
+ busy,
17
+ onKeyName,
18
+ onMint,
19
+ onClose,
20
+ }: {
21
+ namespace: NamespaceDto | null;
22
+ keyName: string;
23
+ minted: string | null;
24
+ busy: boolean;
25
+ onKeyName: (v: string) => void;
26
+ onMint: () => void;
27
+ onClose: () => void;
28
+ }) {
29
+ return (
30
+ <Dialog open={!!namespace} onOpenChange={(o) => !o && onClose()}>
31
+ <DialogContent>
32
+ <DialogHeader>
33
+ <DialogTitle>Mint key for {namespace?.name}</DialogTitle>
34
+ <DialogDescription>A namespace-bound key may act only within this namespace.</DialogDescription>
35
+ </DialogHeader>
36
+ {minted ? (
37
+ <div className="space-y-2">
38
+ <p className="text-[13px] text-muted-foreground">Copy this key now — it won&apos;t be shown again.</p>
39
+ <SecretReveal value={minted} />
40
+ </div>
41
+ ) : (
42
+ <div className="space-y-1.5">
43
+ <Label>Key name</Label>
44
+ <Input autoFocus value={keyName} onChange={(e) => onKeyName(e.target.value)} placeholder="ci-runner" />
45
+ </div>
46
+ )}
47
+ <DialogFooter>
48
+ {minted ? (
49
+ <Button onClick={onClose}>Done</Button>
50
+ ) : (
51
+ <>
52
+ <Button variant="ghost" onClick={onClose} disabled={busy}>
53
+ Cancel
54
+ </Button>
55
+ <Button onClick={onMint} disabled={busy}>
56
+ {busy ? <Spinner /> : <KeyRound />} Mint key
57
+ </Button>
58
+ </>
59
+ )}
60
+ </DialogFooter>
61
+ </DialogContent>
62
+ </Dialog>
63
+ );
64
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Boxes, Plus } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { useQuery } from "@/lib/hooks";
7
+ import { api } from "@/lib/api";
8
+ import { Page, PageHeader, Loading, EmptyState, ErrorState, Spinner } from "@/components/shared";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Label } from "@/components/ui/label";
12
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
13
+ import { NamespaceRow } from "./list";
14
+ import { MintKeyDialog } from "./mint-dialog";
15
+ import type { NamespaceDto } from "@/lib/types";
16
+
17
+ export default function NamespacesPage() {
18
+ const { data, loading, error, reload } = useQuery<{ namespaces: NamespaceDto[] }>("/namespaces");
19
+ const [createOpen, setCreateOpen] = useState(false);
20
+ const [name, setName] = useState("");
21
+ const [busy, setBusy] = useState(false);
22
+
23
+ const [mintFor, setMintFor] = useState<NamespaceDto | null>(null);
24
+ const [keyName, setKeyName] = useState("");
25
+ const [minted, setMinted] = useState<string | null>(null);
26
+
27
+ const create = async () => {
28
+ setBusy(true);
29
+ try {
30
+ await api("/namespaces", { method: "POST", body: { name: name.trim() } });
31
+ setCreateOpen(false);
32
+ setName("");
33
+ reload();
34
+ toast.success("Namespace created");
35
+ } catch (e) {
36
+ toast.error(e instanceof Error ? e.message : "Create failed");
37
+ } finally {
38
+ setBusy(false);
39
+ }
40
+ };
41
+
42
+ const destroy = async (id: string) => {
43
+ try {
44
+ await api(`/namespaces/${id}`, { method: "DELETE" });
45
+ toast.success("Namespace deleted");
46
+ reload();
47
+ } catch (e) {
48
+ toast.error(e instanceof Error ? e.message : "Delete failed");
49
+ }
50
+ };
51
+
52
+ const mint = async () => {
53
+ if (!mintFor) return;
54
+ setBusy(true);
55
+ try {
56
+ const res = await api<{ data: { key: string } }>(`/namespaces/${mintFor.id}/keys`, {
57
+ method: "POST",
58
+ body: { name: keyName.trim() || "namespace-key" },
59
+ });
60
+ setMinted(res.data.key);
61
+ } catch (e) {
62
+ toast.error(e instanceof Error ? e.message : "Mint failed");
63
+ } finally {
64
+ setBusy(false);
65
+ }
66
+ };
67
+
68
+ const closeMint = () => {
69
+ setMintFor(null);
70
+ setKeyName("");
71
+ setMinted(null);
72
+ };
73
+
74
+ const namespaces = data?.namespaces ?? [];
75
+
76
+ return (
77
+ <Page>
78
+ <PageHeader
79
+ title="Namespaces"
80
+ description="Isolated tenant partitions — each owns its workspaces, sessions, credentials, and keys."
81
+ actions={
82
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
83
+ <DialogTrigger asChild>
84
+ <Button>
85
+ <Plus /> New namespace
86
+ </Button>
87
+ </DialogTrigger>
88
+ <DialogContent>
89
+ <DialogHeader>
90
+ <DialogTitle>New namespace</DialogTitle>
91
+ <DialogDescription>A fresh tenant partition with its own isolated resources.</DialogDescription>
92
+ </DialogHeader>
93
+ <div className="space-y-1.5">
94
+ <Label>Name</Label>
95
+ <Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="acme-corp" />
96
+ </div>
97
+ <DialogFooter>
98
+ <Button variant="ghost" onClick={() => setCreateOpen(false)} disabled={busy}>
99
+ Cancel
100
+ </Button>
101
+ <Button onClick={create} disabled={busy || !name.trim()}>
102
+ {busy ? <Spinner /> : null} Create
103
+ </Button>
104
+ </DialogFooter>
105
+ </DialogContent>
106
+ </Dialog>
107
+ }
108
+ />
109
+
110
+ {error && <ErrorState message={error} className="mb-4" />}
111
+
112
+ <div className="surface overflow-hidden">
113
+ {loading ? (
114
+ <Loading />
115
+ ) : namespaces.length === 0 ? (
116
+ <EmptyState icon={Boxes} title="No namespaces" description="Create one to partition tenants and their resources." />
117
+ ) : (
118
+ <div className="divide-y divide-border/60">
119
+ {namespaces.map((n) => (
120
+ <NamespaceRow key={n.id} n={n} onMint={setMintFor} onDelete={destroy} />
121
+ ))}
122
+ </div>
123
+ )}
124
+ </div>
125
+
126
+ <MintKeyDialog
127
+ namespace={mintFor}
128
+ keyName={keyName}
129
+ minted={minted}
130
+ busy={busy}
131
+ onKeyName={setKeyName}
132
+ onMint={mint}
133
+ onClose={closeMint}
134
+ />
135
+ </Page>
136
+ );
137
+ }
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Activity, CircleDashed, FolderGit2, Gauge } from "lucide-react";
5
+ import { useQuery } from "@/lib/hooks";
6
+ import { compact } from "@/lib/format";
7
+ import { ErrorState } from "@/components/shared";
8
+ import { Metric } from "@/components/primitives";
9
+ import { LaunchComposer } from "@/components/fleet/launch";
10
+ import { ActiveSessions } from "@/components/fleet/lanes";
11
+ import { OnboardingFlow } from "@/components/fleet/onboarding";
12
+ import type { SessionDto, WorkspaceDto, ProfileDto } from "@/lib/types";
13
+
14
+ const SKIP_KEY = "garage.onboarding.skipped";
15
+
16
+ export default function FleetPage() {
17
+ // The fleet is live: poll sessions so running/idle counts and lanes stay fresh.
18
+ const sessions = useQuery<{ sessions: SessionDto[]; total: number }>("/sessions", [], 4000);
19
+ const workspaces = useQuery<{ workspaces: WorkspaceDto[] }>("/workspaces");
20
+ const profiles = useQuery<{ profiles: ProfileDto[]; active_profile_id?: string | null }>("/models/profiles");
21
+
22
+ const [skipped, setSkipped] = React.useState(false);
23
+ React.useEffect(() => {
24
+ setSkipped(sessionStorage.getItem(SKIP_KEY) === "1");
25
+ }, []);
26
+
27
+ const all = sessions.data?.sessions ?? [];
28
+ const live = all.filter((s) => s.state !== "destroyed");
29
+ const running = live.filter((s) => s.state === "busy" || s.state === "provisioning").length;
30
+ const idle = live.filter((s) => s.state === "idle").length;
31
+ const wsCount = workspaces.data?.workspaces?.length ?? 0;
32
+ const tokensOut = live.reduce((n, s) => n + (s.tokens_out ?? 0), 0);
33
+ const ready = !sessions.loading || all.length > 0;
34
+ const v = (n: number) => (ready ? n : "—");
35
+
36
+ // Zero-state: no model means the Fleet can't launch — guide setup instead.
37
+ const profs = profiles.data?.profiles ?? [];
38
+ const onboard = !profiles.loading && profs.length === 0 && !skipped;
39
+ // Hide metrics until there's something to count.
40
+ const showMetrics = live.length > 0 && running + idle + wsCount + tokensOut > 0;
41
+
42
+ if (onboard) {
43
+ return (
44
+ <div className="h-full overflow-y-auto">
45
+ <div className="mx-auto w-full max-w-[1080px] animate-fade-in px-6 py-10 md:px-9 md:py-12">
46
+ <OnboardingFlow
47
+ workspaces={workspaces.data?.workspaces ?? []}
48
+ profiles={profs}
49
+ onDone={profiles.reload}
50
+ onSkip={() => {
51
+ sessionStorage.setItem(SKIP_KEY, "1");
52
+ setSkipped(true);
53
+ }}
54
+ />
55
+ </div>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <div className="h-full overflow-y-auto">
62
+ <div className="mx-auto w-full max-w-[1080px] animate-fade-in px-6 py-10 md:px-9 md:py-12">
63
+ <div className="mx-auto max-w-2xl">
64
+ <LaunchComposer
65
+ workspaces={workspaces.data?.workspaces ?? []}
66
+ profiles={profs}
67
+ defaultModelLabel={profs.find((p) => p.id === profiles.data?.active_profile_id)?.label ?? null}
68
+ />
69
+ </div>
70
+
71
+ {showMetrics && (
72
+ <div className="mt-11 grid grid-cols-2 gap-3 lg:grid-cols-4">
73
+ <Metric label="Running" value={v(running)} icon={Activity} tone="success" hint="busy or provisioning" />
74
+ <Metric label="Idle" value={v(idle)} icon={CircleDashed} hint="loaded, awaiting work" />
75
+ <Metric label="Workspaces" value={ready ? wsCount : "—"} icon={FolderGit2} hint="in this namespace" />
76
+ <Metric label="Tokens out" value={ready ? compact(tokensOut) : "—"} icon={Gauge} tone="brand" hint="across live sessions" />
77
+ </div>
78
+ )}
79
+
80
+ {sessions.error && <ErrorState message={sessions.error} className="mt-8" />}
81
+
82
+ <ActiveSessions sessions={live} className="mt-9" />
83
+ </div>
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronRight, Rocket, GitBranch, Terminal, Files } from "lucide-react";
5
+ import { useQuery } from "@/lib/hooks";
6
+ import { Page, PageHeader, Loading, EmptyState, ErrorState } from "@/components/shared";
7
+ import { SectionHeading } from "@/components/primitives";
8
+ import { plural } from "@/lib/format";
9
+ import { ProvisionDialog } from "@/components/provisioning/provision-dialog";
10
+ import type { TemplateSummaryDto } from "@/lib/types";
11
+
12
+ /** One template, dense but legible — click anywhere to review & provision. */
13
+ function TemplateRow({ t, onLaunch }: { t: TemplateSummaryDto; onLaunch: () => void }) {
14
+ return (
15
+ <button
16
+ type="button"
17
+ onClick={onLaunch}
18
+ className="group flex w-full items-center gap-3 px-3.5 py-3 text-left transition-colors hover:bg-surface-2"
19
+ >
20
+ <span className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-surface-2 text-faint shadow-sheen transition-colors group-hover:text-brand">
21
+ <Rocket className="size-4" />
22
+ </span>
23
+ <div className="min-w-0 flex-1">
24
+ <div className="truncate text-[13.5px] font-medium text-foreground">{t.name}</div>
25
+ <div className="truncate text-[12px] text-muted-foreground">{t.description ?? "No description."}</div>
26
+ </div>
27
+ <span className="tnum hidden w-14 shrink-0 text-right text-[12px] text-faint sm:block">{plural(t.step_count ?? 0, "step")}</span>
28
+ <ChevronRight className="size-4 shrink-0 text-faint/60 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
29
+ </button>
30
+ );
31
+ }
32
+
33
+ export default function ProvisioningPage() {
34
+ const { data, loading, error } = useQuery<{ templates: TemplateSummaryDto[] }>("/templates");
35
+ const [launch, setLaunch] = useState<TemplateSummaryDto | null>(null);
36
+
37
+ const templates = data?.templates ?? [];
38
+
39
+ return (
40
+ <Page>
41
+ <PageHeader title="Provisioning" description="Reproducible setup recipes that prepare a fresh workspace before an agent starts." />
42
+
43
+ <div className="surface mb-6 flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
44
+ <p className="max-w-2xl text-[13px] leading-relaxed text-muted-foreground">
45
+ A <span className="text-foreground">template</span> is an ordered list of steps — it can clone a repo, run setup commands, and copy files, with{" "}
46
+ <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[11px]">{"{param}"}</code> placeholders you fill in when provisioning.
47
+ </p>
48
+ <div className="flex shrink-0 gap-4 text-[12px] text-faint">
49
+ <span className="flex items-center gap-1.5"><GitBranch className="size-3.5" /> clone</span>
50
+ <span className="flex items-center gap-1.5"><Terminal className="size-3.5" /> shell</span>
51
+ <span className="flex items-center gap-1.5"><Files className="size-3.5" /> copy</span>
52
+ </div>
53
+ </div>
54
+
55
+ {error && <ErrorState message={error} className="mb-4" />}
56
+
57
+ <SectionHeading eyebrow="Library" title="Templates" />
58
+
59
+ {loading ? (
60
+ <div className="surface">
61
+ <Loading />
62
+ </div>
63
+ ) : templates.length === 0 ? (
64
+ <div className="surface">
65
+ <EmptyState
66
+ icon={Rocket}
67
+ title="No templates"
68
+ description="Drop a JSON template (name, description, steps[]) into the Garage templates directory to provision workspaces here."
69
+ />
70
+ </div>
71
+ ) : (
72
+ <div className="surface overflow-hidden">
73
+ <div className="divide-y divide-border/60">
74
+ {templates.map((t) => (
75
+ <TemplateRow key={t.name} t={t} onLaunch={() => setLaunch(t)} />
76
+ ))}
77
+ </div>
78
+ </div>
79
+ )}
80
+
81
+ <ProvisionDialog template={launch} onClose={() => setLaunch(null)} />
82
+ </Page>
83
+ );
84
+ }
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import { use, useState } from "react";
4
+ import Link from "next/link";
5
+ import { ArrowLeft, CircleStop, FolderGit2, PanelRightClose, PanelRightOpen } from "lucide-react";
6
+ import { useQuery } from "@/lib/hooks";
7
+ import { useAuth } from "@/lib/auth";
8
+ import { useSession } from "@/lib/useSession";
9
+ import { baseName } from "@/lib/format";
10
+ import { cn } from "@/lib/utils";
11
+ import { SessionStatus, Loading, ErrorState } from "@/components/shared";
12
+ import { Conversation } from "@/components/chat/conversation";
13
+ import { Composer } from "@/components/chat/composer";
14
+ import { PermissionPrompt } from "@/components/chat/permission-prompt";
15
+ import { Inspector } from "@/components/session/inspector";
16
+ import { ModelSwitcher } from "@/components/session/model-switcher";
17
+ import { SessionSwitcher } from "@/components/session/session-switcher";
18
+ import { ReasoningKnob } from "@/components/session/reasoning-knob";
19
+ import { Button } from "@/components/ui/button";
20
+ import { toast } from "sonner";
21
+ import type { SessionDto, ProfileWire } from "@/lib/types";
22
+
23
+ export default function SessionPage({ params }: { params: Promise<{ id: string }> }) {
24
+ const { id } = use(params);
25
+ const { data: session, loading, error } = useQuery<SessionDto>(`/sessions/${id}`);
26
+ const profiles = useQuery<{ profiles: ProfileWire[] }>("/models/profiles");
27
+
28
+ const { identity } = useAuth();
29
+ const live = useSession(id);
30
+ // The agent (and therefore its command catalogue) is built lazily on the
31
+ // first WS connect — refetch once the socket is live so the slash menu has
32
+ // real data instead of the pre-build empty list.
33
+ const extensions = useQuery<{ slash: Array<{ name: string; description: string }> }>(`/sessions/${id}/extensions`, [live.connected]);
34
+ // /quit and /help are TUI affordances — the dashboard has its own surfaces.
35
+ const commands = (extensions.data?.slash ?? []).filter((c) => c.name !== "/quit" && c.name !== "/help");
36
+ const [panel, setPanel] = useState(true);
37
+ const [pickedModel, setPickedModel] = useState<string | null>(null);
38
+
39
+ const title = live.title ?? session?.title ?? "Untitled session";
40
+ // Prefer the live signal: once the socket is connected the REST snapshot's
41
+ // lifecycle (e.g. a stale "provisioning") no longer reflects reality.
42
+ const state = live.busy ? "busy" : live.connected ? "idle" : session?.state ?? "idle";
43
+ const mode = live.mode ?? session?.permission_mode ?? "normal";
44
+ const currentModel = pickedModel ?? session?.model_label ?? null;
45
+ const currentProfile = (profiles.data?.profiles ?? []).find((p) => p.label === currentModel) ?? null;
46
+ // false = catalog says text-only; null = unknown model — don't block.
47
+ const imageSupport = currentProfile?.input_modalities ? currentProfile.input_modalities.includes("image") : null;
48
+ const permSlots = live.slots.filter((s) => s.isPermissionRequest);
49
+ const userInitial = (identity?.user ?? "U").slice(0, 1).toUpperCase();
50
+
51
+ const swap = (profileId: string, label: string) => {
52
+ live.swapProfile(profileId);
53
+ setPickedModel(label);
54
+ toast.success(`Model set to ${label}`);
55
+ };
56
+
57
+ return (
58
+ <div className="flex h-full flex-col">
59
+ <div className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-6 py-3.5">
60
+ <div className="min-w-0">
61
+ <div className="mb-1.5 flex items-center gap-1.5 text-[12px] text-faint">
62
+ <Link href="/sessions" className="inline-flex items-center gap-1 transition-colors hover:text-foreground">
63
+ <ArrowLeft className="size-3" /> Sessions
64
+ </Link>
65
+ {session?.workspace && (
66
+ <>
67
+ <span className="text-faint/50">/</span>
68
+ <span className="inline-flex items-center gap-1">
69
+ <FolderGit2 className="size-3" /> {baseName(session.workspace)}
70
+ </span>
71
+ </>
72
+ )}
73
+ </div>
74
+ <div className="flex min-w-0 items-center gap-3">
75
+ <SessionSwitcher currentId={id} title={title} />
76
+ <SessionStatus state={state} className="shrink-0" />
77
+ </div>
78
+ </div>
79
+ <div className="flex shrink-0 items-center gap-2.5">
80
+ {/* Honest status: surface silent model waits and queued messages
81
+ instead of looking frozen. */}
82
+ {live.busy && live.waitingSec != null && (
83
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-warning/30 bg-warning/10 px-2.5 py-0.5 text-[11.5px] font-medium text-warning">
84
+ <span className="relative grid size-1.5 place-items-center">
85
+ <span className="absolute size-1.5 rounded-full bg-warning opacity-60 animate-pulse-ring" />
86
+ <span className="relative size-1.5 rounded-full bg-warning" />
87
+ </span>
88
+ waiting on model · {live.waitingSec}s
89
+ </span>
90
+ )}
91
+ {live.queueDepth > 0 && (
92
+ <span className="tnum rounded-full border border-border bg-surface-2 px-2.5 py-0.5 text-[11.5px] font-medium text-muted-foreground">
93
+ {live.queueDepth} queued
94
+ </span>
95
+ )}
96
+ <span className={cn("inline-flex items-center gap-1.5 text-[12px] font-medium", live.connected ? "text-success" : "text-faint")}>
97
+ <span className="relative grid size-2 place-items-center">
98
+ {live.connected && <span className="absolute size-2 rounded-full bg-success opacity-60 animate-pulse-ring" />}
99
+ <span className={cn("relative size-2 rounded-full", live.connected ? "bg-success" : "bg-faint")} />
100
+ </span>
101
+ {live.connected ? "live" : "offline"}
102
+ </span>
103
+ {live.busy && (
104
+ <Button size="sm" variant="secondary" onClick={live.abort}>
105
+ <CircleStop /> Stop
106
+ </Button>
107
+ )}
108
+ <Button
109
+ size="icon-sm"
110
+ variant="ghost"
111
+ className="hidden text-muted-foreground md:inline-flex"
112
+ onClick={() => setPanel((p) => !p)}
113
+ title={panel ? "Hide panel" : "Show panel"}
114
+ >
115
+ {panel ? <PanelRightClose /> : <PanelRightOpen />}
116
+ </Button>
117
+ </div>
118
+ </div>
119
+
120
+ {loading && <Loading label="Loading session…" />}
121
+ {error && (
122
+ <div className="p-6">
123
+ <ErrorState message={error} />
124
+ </div>
125
+ )}
126
+
127
+ {session && (
128
+ <div className="flex min-h-0 flex-1">
129
+ <div className="flex min-w-0 flex-1 flex-col">
130
+ <Conversation items={live.items} streaming={live.streaming} busy={live.busy} userInitial={userInitial} className="flex-1" />
131
+ {permSlots.length > 0 && (
132
+ <div className="px-6 md:px-8">
133
+ <div className="w-full space-y-2 pb-1">
134
+ {permSlots.map((s) => (
135
+ <PermissionPrompt key={s.slotId} slot={s} onResolve={live.resolvePermission} />
136
+ ))}
137
+ </div>
138
+ </div>
139
+ )}
140
+ <Composer
141
+ busy={live.busy}
142
+ disabled={!live.connected}
143
+ onSend={live.send}
144
+ onStop={live.abort}
145
+ commands={commands}
146
+ imageSupport={imageSupport}
147
+ controls={
148
+ <>
149
+ <ModelSwitcher profiles={profiles.data?.profiles ?? []} current={currentModel} onSwap={swap} />
150
+ <ReasoningKnob
151
+ profiles={profiles.data?.profiles ?? []}
152
+ currentLabel={currentModel}
153
+ onSwapped={(id, label) => {
154
+ swap(id, label);
155
+ profiles.reload();
156
+ }}
157
+ />
158
+ </>
159
+ }
160
+ />
161
+ </div>
162
+
163
+ {panel && (
164
+ <aside className="hidden w-[340px] shrink-0 flex-col border-l border-border md:flex">
165
+ <Inspector
166
+ session={session}
167
+ stats={live.stats}
168
+ tasks={live.tasks}
169
+ agents={live.agents}
170
+ activeAgentId={live.activeAgentId}
171
+ mode={mode}
172
+ busyRefresh={live.busy}
173
+ onMode={live.setMode}
174
+ onSwitchAgent={live.switchAgent}
175
+ onAddAgent={live.addAgent}
176
+ onRemoveAgent={live.removeAgent}
177
+ />
178
+ </aside>
179
+ )}
180
+ </div>
181
+ )}
182
+ </div>
183
+ );
184
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { ChevronRight, MessageSquare, Trash2 } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { useQuery } from "@/lib/hooks";
7
+ import { api } from "@/lib/api";
8
+ import { Page, PageHeader, Loading, EmptyState, ErrorState, SessionStatus, ConfirmButton } from "@/components/shared";
9
+ import { timeAgo, compact } from "@/lib/format";
10
+ import { NewSessionDialog } from "@/components/session/new-session-dialog";
11
+ import type { SessionDto, WorkspaceDto, ProfileDto } from "@/lib/types";
12
+
13
+ const RANK: Record<string, number> = { busy: 0, provisioning: 1, error: 2, idle: 3, destroyed: 4 };
14
+ const rank = (s: SessionDto) => RANK[s.state] ?? 3;
15
+
16
+ /** One session in the registry: status, title + model, token/turn columns, activity. */
17
+ function SessionRow({ s, onOpen, onDelete }: { s: SessionDto; onOpen: () => void; onDelete: () => void }) {
18
+ const tokens = (s.tokens_in ?? 0) + (s.tokens_out ?? 0);
19
+ return (
20
+ <div
21
+ role="button"
22
+ tabIndex={0}
23
+ onClick={onOpen}
24
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onOpen())}
25
+ className="group flex cursor-pointer items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-surface-2"
26
+ >
27
+ <SessionStatus state={s.state} className="w-[104px] shrink-0" />
28
+ <div className="min-w-0 flex-1">
29
+ <div className="truncate text-[13.5px] text-foreground">{s.title ?? "Untitled session"}</div>
30
+ <div className="truncate text-[11.5px] text-faint">{s.model_label ?? "Default model"}</div>
31
+ </div>
32
+ <span className="tnum hidden w-16 shrink-0 text-right text-[12px] text-muted-foreground sm:block">{compact(tokens)} tok</span>
33
+ <span className="tnum hidden w-12 shrink-0 text-right text-[12px] text-faint md:block">{s.turn_count} {s.turn_count === 1 ? "turn" : "turns"}</span>
34
+ <span className="tnum w-12 shrink-0 text-right text-[12px] text-faint">{timeAgo(s.last_activity)}</span>
35
+ <span onClick={(e) => e.stopPropagation()} className="shrink-0">
36
+ <ConfirmButton label="" icon={Trash2} onConfirm={onDelete} />
37
+ </span>
38
+ <ChevronRight className="size-4 shrink-0 text-faint/60 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export default function SessionsPage() {
44
+ const router = useRouter();
45
+ const { data, loading, error, reload } = useQuery<{ sessions: SessionDto[]; total: number }>("/sessions", [], 4000);
46
+ const workspaces = useQuery<{ workspaces: WorkspaceDto[] }>("/workspaces");
47
+ const profiles = useQuery<{ profiles: ProfileDto[] }>("/models/profiles");
48
+
49
+ const destroy = async (id: string) => {
50
+ try {
51
+ await api(`/sessions/${id}`, { method: "DELETE" });
52
+ toast.success("Session destroyed");
53
+ reload();
54
+ } catch (e) {
55
+ toast.error(e instanceof Error ? e.message : "Delete failed");
56
+ }
57
+ };
58
+
59
+ const sessions = [...(data?.sessions ?? [])].sort(
60
+ (a, b) => rank(a) - rank(b) || b.last_activity.localeCompare(a.last_activity),
61
+ );
62
+
63
+ return (
64
+ <Page>
65
+ <PageHeader
66
+ title="Sessions"
67
+ description="Every agent session in this namespace — live, idle, or rehydratable from disk."
68
+ actions={<NewSessionDialog workspaces={workspaces.data?.workspaces ?? []} profiles={profiles.data?.profiles ?? []} />}
69
+ />
70
+
71
+ {error && <ErrorState message={error} className="mb-4" />}
72
+
73
+ <div className="surface overflow-hidden">
74
+ {loading ? (
75
+ <Loading />
76
+ ) : sessions.length === 0 ? (
77
+ <EmptyState icon={MessageSquare} title="No sessions yet" description="Launch one to put an agent to work — it runs in a sandboxed workspace." />
78
+ ) : (
79
+ <div className="divide-y divide-border/60">
80
+ {sessions.map((s) => (
81
+ <SessionRow key={s.id} s={s} onOpen={() => router.push(`/sessions/${s.id}`)} onDelete={() => destroy(s.id)} />
82
+ ))}
83
+ </div>
84
+ )}
85
+ </div>
86
+ </Page>
87
+ );
88
+ }