@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.
- package/.env.example +3 -0
- package/DESIGN.md +90 -0
- package/README.md +62 -0
- package/app/(app)/credentials/add-profile-modal.tsx +116 -0
- package/app/(app)/credentials/add-provider-modal.tsx +172 -0
- package/app/(app)/credentials/edit-provider-modal.tsx +107 -0
- package/app/(app)/credentials/edit-reasoning-modal.tsx +96 -0
- package/app/(app)/credentials/form.tsx +132 -0
- package/app/(app)/credentials/model-combobox.tsx +151 -0
- package/app/(app)/credentials/page.tsx +126 -0
- package/app/(app)/keys/page.tsx +164 -0
- package/app/(app)/layout.tsx +53 -0
- package/app/(app)/namespaces/list.tsx +47 -0
- package/app/(app)/namespaces/mint-dialog.tsx +64 -0
- package/app/(app)/namespaces/page.tsx +137 -0
- package/app/(app)/page.tsx +86 -0
- package/app/(app)/provisioning/page.tsx +84 -0
- package/app/(app)/sessions/[id]/page.tsx +184 -0
- package/app/(app)/sessions/page.tsx +88 -0
- package/app/(app)/storage/page.tsx +164 -0
- package/app/(app)/storage/toggle.tsx +43 -0
- package/app/(app)/workspaces/list.tsx +34 -0
- package/app/(app)/workspaces/page.tsx +111 -0
- package/app/globals.css +297 -0
- package/app/layout.tsx +32 -0
- package/app/login/page.tsx +62 -0
- package/components/app-sidebar.tsx +120 -0
- package/components/app-topbar.tsx +76 -0
- package/components/brand.tsx +47 -0
- package/components/chat/composer.tsx +199 -0
- package/components/chat/conversation.tsx +86 -0
- package/components/chat/error-card.tsx +89 -0
- package/components/chat/markdown.tsx +12 -0
- package/components/chat/message.tsx +99 -0
- package/components/chat/permission-prompt.tsx +45 -0
- package/components/chat/slash-menu.tsx +54 -0
- package/components/chat/task-list.tsx +56 -0
- package/components/chat/tool-call.tsx +91 -0
- package/components/fleet/lanes.tsx +107 -0
- package/components/fleet/launch.tsx +99 -0
- package/components/fleet/onboarding-launch.tsx +46 -0
- package/components/fleet/onboarding-model.tsx +109 -0
- package/components/fleet/onboarding-provider.tsx +137 -0
- package/components/fleet/onboarding-shared.tsx +66 -0
- package/components/fleet/onboarding.tsx +75 -0
- package/components/primitives.tsx +65 -0
- package/components/provisioning/provision-dialog.tsx +130 -0
- package/components/session/agent-roster.tsx +121 -0
- package/components/session/files-panel.tsx +170 -0
- package/components/session/files-remote.tsx +63 -0
- package/components/session/inspector.tsx +148 -0
- package/components/session/model-switcher.tsx +59 -0
- package/components/session/new-session-dialog.tsx +139 -0
- package/components/session/reasoning-knob.tsx +100 -0
- package/components/session/session-switcher.tsx +62 -0
- package/components/shared.tsx +171 -0
- package/components/theme-toggle.tsx +26 -0
- package/components/ui/avatar.tsx +39 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button.tsx +45 -0
- package/components/ui/card.tsx +44 -0
- package/components/ui/command.tsx +90 -0
- package/components/ui/dialog.tsx +88 -0
- package/components/ui/dropdown-menu.tsx +77 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +22 -0
- package/components/ui/popover.tsx +33 -0
- package/components/ui/scroll-area.tsx +41 -0
- package/components/ui/select.tsx +100 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/skeleton.tsx +7 -0
- package/components/ui/sonner.tsx +28 -0
- package/components/ui/table.tsx +51 -0
- package/components/ui/tabs.tsx +50 -0
- package/components/ui/textarea.tsx +20 -0
- package/components/ui/tooltip.tsx +30 -0
- package/components.json +21 -0
- package/lib/api.ts +85 -0
- package/lib/auth.tsx +80 -0
- package/lib/format.ts +34 -0
- package/lib/hooks.ts +65 -0
- package/lib/launch.ts +25 -0
- package/lib/template.ts +34 -0
- package/lib/theme.ts +71 -0
- package/lib/types.ts +262 -0
- package/lib/useSession.ts +193 -0
- package/lib/utils.ts +7 -0
- package/lib/verify-provider.ts +31 -0
- package/next.config.mjs +16 -0
- package/package.json +49 -0
- package/postcss.config.mjs +6 -0
- package/tailwind.config.ts +94 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Plus } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { launchSession } from "@/lib/launch";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
10
|
+
import { Label } from "@/components/ui/label";
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogTrigger,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
DialogDescription,
|
|
18
|
+
DialogFooter,
|
|
19
|
+
} from "@/components/ui/dialog";
|
|
20
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
21
|
+
import { Spinner } from "@/components/shared";
|
|
22
|
+
import type { WorkspaceDto, ProfileDto } from "@/lib/types";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_WS = "__default__";
|
|
25
|
+
const DEFAULT_MODEL = "__default__";
|
|
26
|
+
|
|
27
|
+
export function NewSessionDialog({ workspaces, profiles }: { workspaces: WorkspaceDto[]; profiles: ProfileDto[] }) {
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const [open, setOpen] = React.useState(false);
|
|
30
|
+
const [prompt, setPrompt] = React.useState("");
|
|
31
|
+
const [ws, setWs] = React.useState(DEFAULT_WS);
|
|
32
|
+
const [profile, setProfile] = React.useState(DEFAULT_MODEL);
|
|
33
|
+
const [mode, setMode] = React.useState("normal");
|
|
34
|
+
const [busy, setBusy] = React.useState(false);
|
|
35
|
+
|
|
36
|
+
// Show what the defaults actually resolve to. With exactly one profile, that
|
|
37
|
+
// profile *is* the default — name it instead of the generic "Default model".
|
|
38
|
+
const defaultModelLabel = profiles.length === 1 ? `Default — ${profiles[0].label}` : "Default model";
|
|
39
|
+
|
|
40
|
+
const create = async () => {
|
|
41
|
+
setBusy(true);
|
|
42
|
+
try {
|
|
43
|
+
const id = await launchSession({
|
|
44
|
+
prompt: prompt.trim() || undefined,
|
|
45
|
+
workspaceId: ws === DEFAULT_WS ? undefined : ws,
|
|
46
|
+
profileId: profile === DEFAULT_MODEL ? undefined : profile,
|
|
47
|
+
permissionMode: mode,
|
|
48
|
+
});
|
|
49
|
+
router.push(`/sessions/${id}`);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
toast.error(e instanceof Error ? e.message : "Could not start the session");
|
|
52
|
+
setBusy(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
58
|
+
<DialogTrigger asChild>
|
|
59
|
+
<Button>
|
|
60
|
+
<Plus /> New session
|
|
61
|
+
</Button>
|
|
62
|
+
</DialogTrigger>
|
|
63
|
+
<DialogContent>
|
|
64
|
+
<DialogHeader>
|
|
65
|
+
<DialogTitle>New session</DialogTitle>
|
|
66
|
+
<DialogDescription>Put an agent to work in a workspace. You can send the first instruction now or later.</DialogDescription>
|
|
67
|
+
</DialogHeader>
|
|
68
|
+
|
|
69
|
+
<div className="flex flex-col gap-5 py-1">
|
|
70
|
+
<div className="flex flex-col gap-2">
|
|
71
|
+
<Label>First message <span className="font-normal text-faint">(optional)</span></Label>
|
|
72
|
+
<Textarea
|
|
73
|
+
autoFocus
|
|
74
|
+
value={prompt}
|
|
75
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
76
|
+
placeholder="e.g. Add rate limiting to the API routes and write a test."
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="grid grid-cols-2 gap-4">
|
|
80
|
+
<div className="flex flex-col gap-2">
|
|
81
|
+
<Label>Workspace</Label>
|
|
82
|
+
<Select value={ws} onValueChange={setWs}>
|
|
83
|
+
<SelectTrigger>
|
|
84
|
+
<SelectValue />
|
|
85
|
+
</SelectTrigger>
|
|
86
|
+
<SelectContent>
|
|
87
|
+
<SelectItem value={DEFAULT_WS}>Default — managed folder</SelectItem>
|
|
88
|
+
{workspaces.map((w) => (
|
|
89
|
+
<SelectItem key={w.id} value={w.id}>
|
|
90
|
+
{w.name}
|
|
91
|
+
</SelectItem>
|
|
92
|
+
))}
|
|
93
|
+
</SelectContent>
|
|
94
|
+
</Select>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex flex-col gap-2">
|
|
97
|
+
<Label>Model</Label>
|
|
98
|
+
<Select value={profile} onValueChange={setProfile}>
|
|
99
|
+
<SelectTrigger>
|
|
100
|
+
<SelectValue />
|
|
101
|
+
</SelectTrigger>
|
|
102
|
+
<SelectContent>
|
|
103
|
+
<SelectItem value={DEFAULT_MODEL}>{defaultModelLabel}</SelectItem>
|
|
104
|
+
{profiles.map((p) => (
|
|
105
|
+
<SelectItem key={p.id} value={p.id}>
|
|
106
|
+
{p.label}
|
|
107
|
+
</SelectItem>
|
|
108
|
+
))}
|
|
109
|
+
</SelectContent>
|
|
110
|
+
</Select>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="flex flex-col gap-2">
|
|
114
|
+
<Label>Permission mode</Label>
|
|
115
|
+
<Select value={mode} onValueChange={setMode}>
|
|
116
|
+
<SelectTrigger>
|
|
117
|
+
<SelectValue />
|
|
118
|
+
</SelectTrigger>
|
|
119
|
+
<SelectContent>
|
|
120
|
+
<SelectItem value="normal">Normal — prompt for risky tools</SelectItem>
|
|
121
|
+
<SelectItem value="auto">Auto — auto-approve</SelectItem>
|
|
122
|
+
<SelectItem value="bypass">Bypass — no prompts</SelectItem>
|
|
123
|
+
</SelectContent>
|
|
124
|
+
</Select>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<DialogFooter className="mt-1">
|
|
129
|
+
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
|
|
130
|
+
Cancel
|
|
131
|
+
</Button>
|
|
132
|
+
<Button onClick={create} disabled={busy}>
|
|
133
|
+
{busy ? <Spinner /> : <Plus />} Start session
|
|
134
|
+
</Button>
|
|
135
|
+
</DialogFooter>
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Brain, ChevronsUpDown } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuLabel,
|
|
14
|
+
} from "@/components/ui/dropdown-menu";
|
|
15
|
+
import type { ProfileWire, ReasoningOption } from "@/lib/types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reasoning-effort knob, inline in the composer next to the model switcher
|
|
19
|
+
* (the Cursor convention). Changing the level rewrites the profile's
|
|
20
|
+
* reasoning (`POST /models/profiles/:id/reasoning`) — which mints a new
|
|
21
|
+
* profile id — and swaps the live session onto it.
|
|
22
|
+
*/
|
|
23
|
+
export function ReasoningKnob({
|
|
24
|
+
profiles,
|
|
25
|
+
currentLabel,
|
|
26
|
+
onSwapped,
|
|
27
|
+
}: {
|
|
28
|
+
profiles: ProfileWire[];
|
|
29
|
+
currentLabel: string | null;
|
|
30
|
+
onSwapped: (profileId: string, label: string) => void;
|
|
31
|
+
}) {
|
|
32
|
+
const current = profiles.find((p) => p.label === currentLabel) ?? null;
|
|
33
|
+
const [options, setOptions] = React.useState<ReasoningOption[] | null>(null);
|
|
34
|
+
const [busy, setBusy] = React.useState(false);
|
|
35
|
+
|
|
36
|
+
React.useEffect(() => {
|
|
37
|
+
setOptions(null);
|
|
38
|
+
if (!current) return;
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
api<{ options: ReasoningOption[] }>(
|
|
41
|
+
`/models/reasoning-options?provider=${encodeURIComponent(current.provider_id)}&model=${encodeURIComponent(current.model)}`,
|
|
42
|
+
)
|
|
43
|
+
.then((r) => !cancelled && setOptions(r.options ?? []))
|
|
44
|
+
.catch(() => !cancelled && setOptions([]));
|
|
45
|
+
return () => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
};
|
|
48
|
+
}, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
49
|
+
|
|
50
|
+
// No profile match or a model with no reasoning levels: nothing to show.
|
|
51
|
+
if (!current || options === null || options.length === 0) return null;
|
|
52
|
+
|
|
53
|
+
const level = current.reasoning_label ?? "off";
|
|
54
|
+
|
|
55
|
+
const pick = async (opt: ReasoningOption) => {
|
|
56
|
+
if (busy) return;
|
|
57
|
+
setBusy(true);
|
|
58
|
+
try {
|
|
59
|
+
const updated = await api<ProfileWire>(`/models/profiles/${current.id}/reasoning`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: { reasoning: opt.value ?? { kind: "off" } },
|
|
62
|
+
});
|
|
63
|
+
onSwapped(updated.id, updated.label);
|
|
64
|
+
toast.success(`Reasoning set to ${opt.label}`);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
toast.error(e instanceof Error ? e.message : "Could not change reasoning");
|
|
67
|
+
} finally {
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<DropdownMenu>
|
|
74
|
+
<DropdownMenuTrigger asChild>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
disabled={busy}
|
|
78
|
+
className={cn(
|
|
79
|
+
"inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-surface-2/60 px-2.5 text-[12.5px] font-medium shadow-sheen transition-colors hover:bg-elevated",
|
|
80
|
+
level !== "off" ? "text-brand-strong" : "text-muted-foreground",
|
|
81
|
+
)}
|
|
82
|
+
title="Reasoning effort"
|
|
83
|
+
>
|
|
84
|
+
<Brain className="size-3.5" />
|
|
85
|
+
{level}
|
|
86
|
+
<ChevronsUpDown className="size-3 text-faint" />
|
|
87
|
+
</button>
|
|
88
|
+
</DropdownMenuTrigger>
|
|
89
|
+
<DropdownMenuContent align="start" side="top" className="w-[230px]">
|
|
90
|
+
<DropdownMenuLabel className="text-[11px] uppercase tracking-wider text-faint">Reasoning effort</DropdownMenuLabel>
|
|
91
|
+
{options.map((opt) => (
|
|
92
|
+
<DropdownMenuItem key={opt.label} onClick={() => pick(opt)} className="flex flex-col items-start gap-0.5">
|
|
93
|
+
<span className={cn("text-[13px]", opt.label === level && "text-brand-strong")}>{opt.label}</span>
|
|
94
|
+
{opt.description && <span className="text-[11.5px] text-faint">{opt.description}</span>}
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
))}
|
|
97
|
+
</DropdownMenuContent>
|
|
98
|
+
</DropdownMenu>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Check, ChevronsUpDown, MessageSquare } from "lucide-react";
|
|
6
|
+
import { useQuery } from "@/lib/hooks";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { timeAgo } from "@/lib/format";
|
|
9
|
+
import {
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuLabel,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
} from "@/components/ui/dropdown-menu";
|
|
17
|
+
import type { SessionDto } from "@/lib/types";
|
|
18
|
+
|
|
19
|
+
const DOT: Record<string, string> = {
|
|
20
|
+
busy: "bg-warning",
|
|
21
|
+
provisioning: "bg-warning",
|
|
22
|
+
idle: "bg-muted-foreground/60",
|
|
23
|
+
error: "bg-destructive",
|
|
24
|
+
destroyed: "bg-muted-foreground/30",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Header switcher: jump between sessions without leaving the chat. Sessions
|
|
28
|
+
* you started keep running server-side whether or not they're open here. */
|
|
29
|
+
export function SessionSwitcher({ currentId, title }: { currentId: string; title: string }) {
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
const { data } = useQuery<{ sessions: SessionDto[] }>("/sessions");
|
|
32
|
+
const sessions = [...(data?.sessions ?? [])].sort((a, b) => b.last_activity.localeCompare(a.last_activity));
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<DropdownMenu>
|
|
36
|
+
<DropdownMenuTrigger asChild>
|
|
37
|
+
<button className="group flex min-w-0 items-center gap-1.5 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
|
|
38
|
+
<h1 className="truncate text-[17px] font-semibold tracking-tight">{title}</h1>
|
|
39
|
+
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100" />
|
|
40
|
+
</button>
|
|
41
|
+
</DropdownMenuTrigger>
|
|
42
|
+
<DropdownMenuContent align="start" className="max-h-[70vh] w-[300px] overflow-y-auto">
|
|
43
|
+
<DropdownMenuLabel>Switch session</DropdownMenuLabel>
|
|
44
|
+
{sessions.length === 0 && <div className="px-2.5 py-1.5 text-[12.5px] text-muted-foreground">No sessions.</div>}
|
|
45
|
+
{sessions.map((s) => (
|
|
46
|
+
<DropdownMenuItem key={s.id} onClick={() => router.push(`/sessions/${s.id}`)} className="gap-2.5">
|
|
47
|
+
<span className={cn("size-1.5 shrink-0 rounded-full", DOT[s.state] ?? "bg-muted-foreground/60")} />
|
|
48
|
+
<span className="min-w-0 flex-1 truncate">{s.title ?? "Untitled session"}</span>
|
|
49
|
+
<span className="shrink-0 text-[11px] text-muted-foreground">{timeAgo(s.last_activity)}</span>
|
|
50
|
+
{s.id === currentId && <Check className="size-3.5 shrink-0 text-brand" />}
|
|
51
|
+
</DropdownMenuItem>
|
|
52
|
+
))}
|
|
53
|
+
<DropdownMenuSeparator />
|
|
54
|
+
<DropdownMenuItem asChild>
|
|
55
|
+
<Link href="/sessions">
|
|
56
|
+
<MessageSquare /> All sessions
|
|
57
|
+
</Link>
|
|
58
|
+
</DropdownMenuItem>
|
|
59
|
+
</DropdownMenuContent>
|
|
60
|
+
</DropdownMenu>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check, Copy, LoaderCircle, type LucideIcon } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
|
|
8
|
+
/** Page title block: heading, optional supporting line, right-aligned actions. */
|
|
9
|
+
export function PageHeader({
|
|
10
|
+
title,
|
|
11
|
+
description,
|
|
12
|
+
actions,
|
|
13
|
+
className,
|
|
14
|
+
}: {
|
|
15
|
+
title: React.ReactNode;
|
|
16
|
+
description?: React.ReactNode;
|
|
17
|
+
actions?: React.ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn("mb-7 flex flex-wrap items-start justify-between gap-4", className)}>
|
|
22
|
+
<div className="min-w-0">
|
|
23
|
+
<h1 className="text-[22px] font-semibold tracking-tight">{title}</h1>
|
|
24
|
+
{description && <p className="mt-1.5 max-w-[64ch] text-[13.5px] leading-relaxed text-muted-foreground">{description}</p>}
|
|
25
|
+
</div>
|
|
26
|
+
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Centered, calm empty state with a brand-tinted line icon. */
|
|
32
|
+
export function EmptyState({
|
|
33
|
+
icon: Icon,
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
action,
|
|
37
|
+
className,
|
|
38
|
+
}: {
|
|
39
|
+
icon: LucideIcon;
|
|
40
|
+
title: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
action?: React.ReactNode;
|
|
43
|
+
className?: string;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn("flex animate-fade-in flex-col items-center justify-center px-6 py-16 text-center", className)}>
|
|
47
|
+
<div className="mb-4 grid size-12 place-items-center rounded-xl border border-border bg-surface-2 text-faint shadow-sheen">
|
|
48
|
+
<Icon className="size-5" />
|
|
49
|
+
</div>
|
|
50
|
+
<p className="text-[14px] font-medium text-foreground">{title}</p>
|
|
51
|
+
{description && <p className="mt-1.5 max-w-[42ch] text-[13px] leading-relaxed text-muted-foreground">{description}</p>}
|
|
52
|
+
{action && <div className="mt-5">{action}</div>}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ErrorState({ message, className }: { message: string; className?: string }) {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex items-start gap-2.5 rounded-lg border border-destructive/25 bg-destructive/[0.07] px-4 py-3 text-[13px]",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<span className="mt-1 size-1.5 shrink-0 rounded-full bg-destructive" />
|
|
66
|
+
<span className="text-foreground/90">{message}</span>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Spinner({ className }: { className?: string }) {
|
|
72
|
+
return <LoaderCircle className={cn("size-4 animate-spin", className)} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Loading({ label = "Loading…", className }: { label?: string; className?: string }) {
|
|
76
|
+
return (
|
|
77
|
+
<div className={cn("flex items-center justify-center gap-2.5 py-14 text-[13px] text-muted-foreground", className)}>
|
|
78
|
+
<Spinner /> {label}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const STATUS: Record<string, { label: string; dot: string; tone: string; pulse?: boolean }> = {
|
|
84
|
+
busy: { label: "running", dot: "bg-success", tone: "text-success", pulse: true },
|
|
85
|
+
provisioning: { label: "provisioning", dot: "bg-warning", tone: "text-warning", pulse: true },
|
|
86
|
+
idle: { label: "idle", dot: "bg-muted-foreground/60", tone: "text-muted-foreground" },
|
|
87
|
+
error: { label: "error", dot: "bg-destructive", tone: "text-destructive" },
|
|
88
|
+
destroyed: { label: "destroyed", dot: "bg-faint", tone: "text-faint" },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** A quiet status indicator: a colored dot + label. Live states ripple. */
|
|
92
|
+
export function SessionStatus({ state, className }: { state: string; className?: string }) {
|
|
93
|
+
const s = STATUS[state] ?? { label: state, dot: "bg-muted-foreground/60", tone: "text-muted-foreground" };
|
|
94
|
+
return (
|
|
95
|
+
<span className={cn("inline-flex items-center gap-1.5 text-[12.5px] font-medium", s.tone, className)}>
|
|
96
|
+
<span className="relative grid size-2 place-items-center">
|
|
97
|
+
{s.pulse && <span className={cn("absolute size-2 rounded-full opacity-60 animate-pulse-ring", s.dot)} />}
|
|
98
|
+
<span className={cn("relative size-2 rounded-full", s.dot)} />
|
|
99
|
+
</span>
|
|
100
|
+
{s.label}
|
|
101
|
+
</span>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Copy-to-clipboard icon button with a transient check. */
|
|
106
|
+
export function CopyButton({ value, className, label }: { value: string; className?: string; label?: string }) {
|
|
107
|
+
const [copied, setCopied] = React.useState(false);
|
|
108
|
+
const copy = async () => {
|
|
109
|
+
try {
|
|
110
|
+
await navigator.clipboard.writeText(value);
|
|
111
|
+
setCopied(true);
|
|
112
|
+
setTimeout(() => setCopied(false), 1200);
|
|
113
|
+
} catch {
|
|
114
|
+
/* clipboard unavailable */
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
return (
|
|
118
|
+
<Button type="button" variant="ghost" size={label ? "sm" : "icon-sm"} className={cn("text-muted-foreground", className)} onClick={copy} title="Copy">
|
|
119
|
+
{copied ? <Check className="text-success" /> : <Copy />}
|
|
120
|
+
{label}
|
|
121
|
+
</Button>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Confirm-on-click destructive action. Stays inline; arms then confirms. */
|
|
126
|
+
export function ConfirmButton({ onConfirm, label = "Delete", icon: Icon }: { onConfirm: () => void; label?: string; icon?: LucideIcon }) {
|
|
127
|
+
const [armed, setArmed] = React.useState(false);
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
if (!armed) return;
|
|
130
|
+
const t = setTimeout(() => setArmed(false), 3000);
|
|
131
|
+
return () => clearTimeout(t);
|
|
132
|
+
}, [armed]);
|
|
133
|
+
|
|
134
|
+
if (armed) {
|
|
135
|
+
return (
|
|
136
|
+
<span className="inline-flex items-center gap-1.5">
|
|
137
|
+
<Button variant="destructive" size="sm" onClick={onConfirm}>
|
|
138
|
+
Confirm
|
|
139
|
+
</Button>
|
|
140
|
+
<Button variant="ghost" size="sm" onClick={() => setArmed(false)}>
|
|
141
|
+
Cancel
|
|
142
|
+
</Button>
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return (
|
|
147
|
+
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive" onClick={() => setArmed(true)}>
|
|
148
|
+
{Icon && <Icon />}
|
|
149
|
+
{label}
|
|
150
|
+
</Button>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Scroll container for list/detail pages: full height, centered max width. */
|
|
155
|
+
export function Page({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
156
|
+
return (
|
|
157
|
+
<div className="h-full overflow-y-auto">
|
|
158
|
+
<div className={cn("mx-auto w-full max-w-[1180px] animate-fade-in px-6 py-8 md:px-9", className)}>{children}</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A revealed secret: monospace, full-width, with a copy affordance. */
|
|
164
|
+
export function SecretReveal({ value }: { value: string }) {
|
|
165
|
+
return (
|
|
166
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-background p-2 pl-3 shadow-sheen">
|
|
167
|
+
<code className="min-w-0 flex-1 break-all font-mono text-[12.5px] text-foreground">{value}</code>
|
|
168
|
+
<CopyButton value={value} />
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Moon, Sun } from "lucide-react";
|
|
4
|
+
import { useTheme } from "@/lib/theme";
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
6
|
+
|
|
7
|
+
/** Quiet light/dark switch — sun in the dark, moon in the light. */
|
|
8
|
+
export function ThemeToggle() {
|
|
9
|
+
const { resolved, setPref } = useTheme();
|
|
10
|
+
const next = resolved === "dark" ? "light" : "dark";
|
|
11
|
+
return (
|
|
12
|
+
<Tooltip>
|
|
13
|
+
<TooltipTrigger asChild>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
aria-label={`Switch to ${next} mode`}
|
|
17
|
+
onClick={() => setPref(next)}
|
|
18
|
+
className="grid size-8 place-items-center rounded-md text-faint transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
19
|
+
>
|
|
20
|
+
{resolved === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
21
|
+
</button>
|
|
22
|
+
</TooltipTrigger>
|
|
23
|
+
<TooltipContent side="bottom">{next === "light" ? "Light mode" : "Dark mode"}</TooltipContent>
|
|
24
|
+
</Tooltip>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const Avatar = React.forwardRef<
|
|
8
|
+
React.ElementRef<typeof AvatarPrimitive.Root>,
|
|
9
|
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
|
10
|
+
>(({ className, ...props }, ref) => (
|
|
11
|
+
<AvatarPrimitive.Root
|
|
12
|
+
ref={ref}
|
|
13
|
+
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
));
|
|
17
|
+
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
|
18
|
+
|
|
19
|
+
const AvatarImage = React.forwardRef<
|
|
20
|
+
React.ElementRef<typeof AvatarPrimitive.Image>,
|
|
21
|
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
|
22
|
+
>(({ className, ...props }, ref) => (
|
|
23
|
+
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
|
|
24
|
+
));
|
|
25
|
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
|
26
|
+
|
|
27
|
+
const AvatarFallback = React.forwardRef<
|
|
28
|
+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
|
29
|
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
|
30
|
+
>(({ className, ...props }, ref) => (
|
|
31
|
+
<AvatarPrimitive.Fallback
|
|
32
|
+
ref={ref}
|
|
33
|
+
className={cn("flex h-full w-full items-center justify-center rounded-full bg-secondary text-xs font-medium text-foreground", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
));
|
|
37
|
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
|
38
|
+
|
|
39
|
+
export { Avatar, AvatarImage, AvatarFallback };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "border-border bg-secondary text-muted-foreground",
|
|
11
|
+
outline: "border-border text-muted-foreground",
|
|
12
|
+
brand: "border-brand/30 bg-brand/10 text-brand",
|
|
13
|
+
success: "border-success/30 bg-success/10 text-success",
|
|
14
|
+
warning: "border-warning/30 bg-warning/10 text-warning",
|
|
15
|
+
destructive: "border-destructive/30 bg-destructive/10 text-destructive",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: { variant: "default" },
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export interface BadgeProps
|
|
23
|
+
extends React.HTMLAttributes<HTMLSpanElement>,
|
|
24
|
+
VariantProps<typeof badgeVariants> {}
|
|
25
|
+
|
|
26
|
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
27
|
+
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { Badge, badgeVariants };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-brand text-brand-foreground shadow-sheen hover:bg-brand-strong active:scale-[0.98]",
|
|
12
|
+
destructive:
|
|
13
|
+
"border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20",
|
|
14
|
+
outline: "border border-border bg-transparent shadow-sheen hover:border-border-strong hover:bg-secondary hover:text-foreground",
|
|
15
|
+
secondary: "bg-secondary text-secondary-foreground shadow-sheen hover:bg-elevated",
|
|
16
|
+
ghost: "text-muted-foreground hover:bg-secondary hover:text-foreground",
|
|
17
|
+
link: "text-foreground underline-offset-4 hover:underline",
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: "h-9 px-4 py-2",
|
|
21
|
+
sm: "h-8 rounded-md px-3 text-[13px]",
|
|
22
|
+
lg: "h-10 rounded-md px-6",
|
|
23
|
+
icon: "h-9 w-9",
|
|
24
|
+
"icon-sm": "h-8 w-8",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: { variant: "default", size: "default" },
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export interface ButtonProps
|
|
32
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
33
|
+
VariantProps<typeof buttonVariants> {
|
|
34
|
+
asChild?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
38
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
39
|
+
const Comp = asChild ? Slot : "button";
|
|
40
|
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
Button.displayName = "Button";
|
|
44
|
+
|
|
45
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div ref={ref} className={cn("rounded-lg border border-border bg-card text-card-foreground shadow-card", className)} {...props} />
|
|
7
|
+
),
|
|
8
|
+
);
|
|
9
|
+
Card.displayName = "Card";
|
|
10
|
+
|
|
11
|
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
12
|
+
({ className, ...props }, ref) => (
|
|
13
|
+
<div ref={ref} className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
|
|
14
|
+
),
|
|
15
|
+
);
|
|
16
|
+
CardHeader.displayName = "CardHeader";
|
|
17
|
+
|
|
18
|
+
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
19
|
+
({ className, ...props }, ref) => (
|
|
20
|
+
<div ref={ref} className={cn("text-[15px] font-semibold leading-none tracking-tight", className)} {...props} />
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
CardTitle.displayName = "CardTitle";
|
|
24
|
+
|
|
25
|
+
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
26
|
+
({ className, ...props }, ref) => (
|
|
27
|
+
<div ref={ref} className={cn("text-[13px] text-muted-foreground", className)} {...props} />
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
CardDescription.displayName = "CardDescription";
|
|
31
|
+
|
|
32
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
33
|
+
({ className, ...props }, ref) => <div ref={ref} className={cn("p-5 pt-0", className)} {...props} />,
|
|
34
|
+
);
|
|
35
|
+
CardContent.displayName = "CardContent";
|
|
36
|
+
|
|
37
|
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
38
|
+
({ className, ...props }, ref) => (
|
|
39
|
+
<div ref={ref} className={cn("flex items-center p-5 pt-0", className)} {...props} />
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
CardFooter.displayName = "CardFooter";
|
|
43
|
+
|
|
44
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|