@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,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Cloud, Save } 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, 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 { Toggle } from "./toggle";
|
|
13
|
+
import type { StorageConfigDto, UpdateStorageConfigInput } from "@/lib/types";
|
|
14
|
+
|
|
15
|
+
const EMPTY: StorageConfigDto = {
|
|
16
|
+
enabled: false,
|
|
17
|
+
endpoint: null,
|
|
18
|
+
bucket: null,
|
|
19
|
+
prefix: null,
|
|
20
|
+
access_key_id: null,
|
|
21
|
+
has_secret: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A labeled text field, stacked label over input with an optional hint. */
|
|
25
|
+
function TextField({
|
|
26
|
+
label,
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
placeholder,
|
|
30
|
+
hint,
|
|
31
|
+
mono,
|
|
32
|
+
}: {
|
|
33
|
+
label: string;
|
|
34
|
+
value: string;
|
|
35
|
+
onChange: (v: string) => void;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
hint?: React.ReactNode;
|
|
38
|
+
mono?: boolean;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="space-y-1.5">
|
|
42
|
+
<Label>{label}</Label>
|
|
43
|
+
<Input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} className={mono ? "font-mono text-[12.5px]" : undefined} />
|
|
44
|
+
{hint && <p className="text-[12px] leading-relaxed text-faint">{hint}</p>}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function StoragePage() {
|
|
50
|
+
const { data, loading, error, reload } = useQuery<{ storage: StorageConfigDto }>("/storage");
|
|
51
|
+
const config = data?.storage ?? EMPTY;
|
|
52
|
+
|
|
53
|
+
const [enabled, setEnabled] = React.useState(false);
|
|
54
|
+
const [endpoint, setEndpoint] = React.useState("");
|
|
55
|
+
const [bucket, setBucket] = React.useState("");
|
|
56
|
+
const [prefix, setPrefix] = React.useState("");
|
|
57
|
+
const [accessKey, setAccessKey] = React.useState("");
|
|
58
|
+
const [secret, setSecret] = React.useState("");
|
|
59
|
+
const [replacingSecret, setReplacingSecret] = React.useState(false);
|
|
60
|
+
const [busy, setBusy] = React.useState(false);
|
|
61
|
+
|
|
62
|
+
// Hydrate the form whenever the server config (re)loads.
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
setEnabled(config.enabled);
|
|
65
|
+
setEndpoint(config.endpoint ?? "");
|
|
66
|
+
setBucket(config.bucket ?? "");
|
|
67
|
+
setPrefix(config.prefix ?? "");
|
|
68
|
+
setAccessKey(config.access_key_id ?? "");
|
|
69
|
+
setSecret("");
|
|
70
|
+
setReplacingSecret(false);
|
|
71
|
+
}, [config.enabled, config.endpoint, config.bucket, config.prefix, config.access_key_id, config.has_secret]);
|
|
72
|
+
|
|
73
|
+
const save = async () => {
|
|
74
|
+
const patch: UpdateStorageConfigInput = {};
|
|
75
|
+
if (enabled !== config.enabled) patch.enabled = enabled;
|
|
76
|
+
if (endpoint.trim() !== (config.endpoint ?? "")) patch.endpoint = endpoint.trim() || null;
|
|
77
|
+
if (bucket.trim() !== (config.bucket ?? "")) patch.bucket = bucket.trim() || null;
|
|
78
|
+
if (prefix.trim() !== (config.prefix ?? "")) patch.prefix = prefix.trim() || null;
|
|
79
|
+
if (accessKey.trim() !== (config.access_key_id ?? "")) patch.access_key_id = accessKey.trim() || null;
|
|
80
|
+
// Secret is write-only: send it only when the user typed a new one.
|
|
81
|
+
if ((!config.has_secret || replacingSecret) && secret) patch.secret_access_key = secret;
|
|
82
|
+
|
|
83
|
+
if (Object.keys(patch).length === 0) {
|
|
84
|
+
toast.message("No changes to save");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setBusy(true);
|
|
88
|
+
try {
|
|
89
|
+
await api("/storage", { method: "PUT", body: patch });
|
|
90
|
+
toast.success("Storage settings saved");
|
|
91
|
+
reload();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
toast.error(e instanceof Error ? e.message : "Failed to save");
|
|
94
|
+
} finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Page>
|
|
101
|
+
<PageHeader
|
|
102
|
+
title="Storage"
|
|
103
|
+
description="Mirror each session's uploads/ folder to an S3-compatible bucket (Cloudflare R2 or any S3 endpoint) so your other systems can reach the same files."
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
{error && <ErrorState message={error} className="mb-4" />}
|
|
107
|
+
|
|
108
|
+
{loading ? (
|
|
109
|
+
<Loading />
|
|
110
|
+
) : (
|
|
111
|
+
<div className="surface max-w-xl space-y-5 p-5">
|
|
112
|
+
<div className="flex items-start justify-between gap-4">
|
|
113
|
+
<div className="min-w-0">
|
|
114
|
+
<div className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
|
|
115
|
+
<Cloud className="size-4 text-faint" /> Remote uploads mirror
|
|
116
|
+
</div>
|
|
117
|
+
<p className="mt-1 text-[12.5px] leading-relaxed text-faint">
|
|
118
|
+
Files sync under <span className="font-mono text-muted-foreground">{prefix.trim() ? `${prefix.trim()}/` : ""}<namespace>/<session>/</span>.
|
|
119
|
+
Local files stay canonical while a session is live.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
<Toggle checked={enabled} onChange={setEnabled} label="Enable remote storage" disabled={busy} />
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="grid gap-4 border-t border-border/60 pt-5">
|
|
126
|
+
<TextField label="Endpoint" value={endpoint} onChange={setEndpoint} mono placeholder="https://<account>.r2.cloudflarestorage.com" />
|
|
127
|
+
<div className="grid grid-cols-2 gap-4">
|
|
128
|
+
<TextField label="Bucket" value={bucket} onChange={setBucket} placeholder="my-uploads" />
|
|
129
|
+
<TextField label="Prefix" value={prefix} onChange={setPrefix} placeholder="(optional)" hint="Key prefix inside the bucket." />
|
|
130
|
+
</div>
|
|
131
|
+
<TextField label="Access key ID" value={accessKey} onChange={setAccessKey} mono placeholder="R2 / S3 access key id" />
|
|
132
|
+
<div className="space-y-1.5">
|
|
133
|
+
<Label>Secret access key</Label>
|
|
134
|
+
{config.has_secret && !replacingSecret ? (
|
|
135
|
+
<div className="flex items-center justify-between rounded-md border border-border bg-surface-2/60 px-3 py-2">
|
|
136
|
+
<span className="inline-flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
|
|
137
|
+
<span className="size-1.5 rounded-full bg-success" /> Secret set
|
|
138
|
+
</span>
|
|
139
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setReplacingSecret(true)}>
|
|
140
|
+
Replace
|
|
141
|
+
</Button>
|
|
142
|
+
</div>
|
|
143
|
+
) : (
|
|
144
|
+
<Input
|
|
145
|
+
type="password"
|
|
146
|
+
value={secret}
|
|
147
|
+
onChange={(e) => setSecret(e.target.value)}
|
|
148
|
+
placeholder={config.has_secret ? "Enter a new secret" : "Paste the secret access key"}
|
|
149
|
+
className="font-mono text-[12.5px]"
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div className="flex justify-end border-t border-border/60 pt-4">
|
|
156
|
+
<Button onClick={save} disabled={busy}>
|
|
157
|
+
{busy ? <Spinner /> : <Save />} Save settings
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</Page>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A compact on/off switch built from design tokens (no shadcn Switch exists in
|
|
7
|
+
* this kit). Brand-tinted track when on, quiet `surface-2` when off; the same
|
|
8
|
+
* focus ring as every other control.
|
|
9
|
+
*/
|
|
10
|
+
export function Toggle({
|
|
11
|
+
checked,
|
|
12
|
+
onChange,
|
|
13
|
+
label,
|
|
14
|
+
disabled,
|
|
15
|
+
}: {
|
|
16
|
+
checked: boolean;
|
|
17
|
+
onChange: (next: boolean) => void;
|
|
18
|
+
label: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
role="switch"
|
|
25
|
+
aria-checked={checked}
|
|
26
|
+
aria-label={label}
|
|
27
|
+
disabled={disabled}
|
|
28
|
+
onClick={() => onChange(!checked)}
|
|
29
|
+
className={cn(
|
|
30
|
+
"relative inline-flex h-[22px] w-9 shrink-0 items-center rounded-full border transition-colors",
|
|
31
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50",
|
|
32
|
+
checked ? "border-brand/40 bg-brand/80 shadow-sheen" : "border-border bg-surface-2",
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
<span
|
|
36
|
+
className={cn(
|
|
37
|
+
"inline-block size-[16px] rounded-full bg-background shadow-sm transition-transform",
|
|
38
|
+
checked ? "translate-x-[16px]" : "translate-x-[3px]",
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
</button>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FolderGit2, Trash2 } from "lucide-react";
|
|
4
|
+
import { timeAgo } from "@/lib/format";
|
|
5
|
+
import { ConfirmButton } from "@/components/shared";
|
|
6
|
+
import type { WorkspaceDto } from "@/lib/types";
|
|
7
|
+
|
|
8
|
+
/** A single workspace: name + mono path, with session weight and age trailing. */
|
|
9
|
+
export function WorkspaceRow({ w, onDelete }: { w: WorkspaceDto; onDelete: (id: string) => void }) {
|
|
10
|
+
const count = w.session_count;
|
|
11
|
+
return (
|
|
12
|
+
<div className="group flex items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-surface-2">
|
|
13
|
+
<FolderGit2 className="size-4 shrink-0 text-faint" />
|
|
14
|
+
<div className="min-w-0 flex-1">
|
|
15
|
+
<div className="truncate text-[13.5px] font-medium text-foreground">{w.name}</div>
|
|
16
|
+
<div className="truncate font-mono text-[12px] text-faint">{w.path}</div>
|
|
17
|
+
</div>
|
|
18
|
+
<span className="tnum hidden w-20 shrink-0 text-right text-[12px] sm:block">
|
|
19
|
+
{count > 0 ? (
|
|
20
|
+
<span className="inline-flex items-center gap-1.5 text-success">
|
|
21
|
+
<span className="size-1.5 rounded-full bg-success" />
|
|
22
|
+
<span className="text-muted-foreground">{count} {count === 1 ? "session" : "sessions"}</span>
|
|
23
|
+
</span>
|
|
24
|
+
) : (
|
|
25
|
+
<span className="text-faint">no sessions</span>
|
|
26
|
+
)}
|
|
27
|
+
</span>
|
|
28
|
+
<span className="tnum hidden w-12 shrink-0 text-right text-[12px] text-faint md:block">{timeAgo(w.created_at)}</span>
|
|
29
|
+
<div className="flex shrink-0 justify-end">
|
|
30
|
+
<ConfirmButton label="" icon={Trash2} onConfirm={() => onDelete(w.id)} />
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { FolderGit2, 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 { WorkspaceRow } from "./list";
|
|
14
|
+
import type { WorkspaceDto } from "@/lib/types";
|
|
15
|
+
|
|
16
|
+
export default function WorkspacesPage() {
|
|
17
|
+
const { data, loading, error, reload } = useQuery<{ workspaces: WorkspaceDto[] }>("/workspaces");
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const [name, setName] = useState("");
|
|
20
|
+
const [path, setPath] = useState("");
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
|
|
23
|
+
const create = async () => {
|
|
24
|
+
setBusy(true);
|
|
25
|
+
try {
|
|
26
|
+
const body: Record<string, string> = {};
|
|
27
|
+
if (name.trim()) body.name = name.trim();
|
|
28
|
+
if (path.trim()) body.path = path.trim();
|
|
29
|
+
await api("/workspaces", { method: "POST", body });
|
|
30
|
+
setOpen(false);
|
|
31
|
+
setName("");
|
|
32
|
+
setPath("");
|
|
33
|
+
reload();
|
|
34
|
+
toast.success("Workspace 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(`/workspaces/${id}`, { method: "DELETE" });
|
|
45
|
+
toast.success("Workspace deleted");
|
|
46
|
+
reload();
|
|
47
|
+
} catch (e) {
|
|
48
|
+
toast.error(e instanceof Error ? e.message : "Delete failed");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const workspaces = data?.workspaces ?? [];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Page>
|
|
56
|
+
<PageHeader
|
|
57
|
+
title="Workspaces"
|
|
58
|
+
description="Named directories on the Garage host that sessions run against — the sandbox each agent works in."
|
|
59
|
+
actions={
|
|
60
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
61
|
+
<DialogTrigger asChild>
|
|
62
|
+
<Button>
|
|
63
|
+
<Plus /> New workspace
|
|
64
|
+
</Button>
|
|
65
|
+
</DialogTrigger>
|
|
66
|
+
<DialogContent>
|
|
67
|
+
<DialogHeader>
|
|
68
|
+
<DialogTitle>New workspace</DialogTitle>
|
|
69
|
+
<DialogDescription>Leave the path blank to create one under the Garage workspace root.</DialogDescription>
|
|
70
|
+
</DialogHeader>
|
|
71
|
+
<div className="space-y-4">
|
|
72
|
+
<div className="space-y-1.5">
|
|
73
|
+
<Label>Name</Label>
|
|
74
|
+
<Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="my-app" />
|
|
75
|
+
</div>
|
|
76
|
+
<div className="space-y-1.5">
|
|
77
|
+
<Label>Path (optional)</Label>
|
|
78
|
+
<Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="/home/dev/my-app" />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<DialogFooter>
|
|
82
|
+
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
|
|
83
|
+
Cancel
|
|
84
|
+
</Button>
|
|
85
|
+
<Button onClick={create} disabled={busy}>
|
|
86
|
+
{busy ? <Spinner /> : null} Create
|
|
87
|
+
</Button>
|
|
88
|
+
</DialogFooter>
|
|
89
|
+
</DialogContent>
|
|
90
|
+
</Dialog>
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
|
|
94
|
+
{error && <ErrorState message={error} className="mb-4" />}
|
|
95
|
+
|
|
96
|
+
<div className="surface overflow-hidden">
|
|
97
|
+
{loading ? (
|
|
98
|
+
<Loading />
|
|
99
|
+
) : workspaces.length === 0 ? (
|
|
100
|
+
<EmptyState icon={FolderGit2} title="No workspaces" description="Create one, or let sessions create them on demand." />
|
|
101
|
+
) : (
|
|
102
|
+
<div className="divide-y divide-border/60">
|
|
103
|
+
{workspaces.map((w) => (
|
|
104
|
+
<WorkspaceRow key={w.id} w={w} onDelete={destroy} />
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</Page>
|
|
110
|
+
);
|
|
111
|
+
}
|
package/app/globals.css
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Garage design language — "sap & sunlight" (solarpunk graphite).
|
|
7
|
+
* Two modes, one structure: surfaces climb the same elevation ladder
|
|
8
|
+
* (background → card → surface-2 → elevated) in both; a single sap-green
|
|
9
|
+
* brand accent is reserved for active nav, primary actions, focus, and live
|
|
10
|
+
* state; solar amber is the working/caution warmth. Depth comes from hairline
|
|
11
|
+
* borders + a top sheen, not heavy shadows. The green and amber SHIFT per
|
|
12
|
+
* mode (light mode runs darker for contrast on cream) — structure never does.
|
|
13
|
+
*/
|
|
14
|
+
@layer base {
|
|
15
|
+
/* Light — "warm paper": cream surfaces, pine ink, darkened sap. */
|
|
16
|
+
:root {
|
|
17
|
+
--background: 44 51% 93%;
|
|
18
|
+
--foreground: 137 20% 14%;
|
|
19
|
+
|
|
20
|
+
--card: 44 60% 95%;
|
|
21
|
+
--card-foreground: 137 20% 14%;
|
|
22
|
+
--surface-2: 43 44% 89%;
|
|
23
|
+
--elevated: 47 90% 98%;
|
|
24
|
+
|
|
25
|
+
--popover: 46 75% 97%;
|
|
26
|
+
--popover-foreground: 137 20% 14%;
|
|
27
|
+
|
|
28
|
+
/* Brand accent (sap green) — used sparingly. */
|
|
29
|
+
--primary: 147 45% 34%;
|
|
30
|
+
--primary-foreground: 44 60% 96%;
|
|
31
|
+
--brand: 147 45% 34%;
|
|
32
|
+
--brand-foreground: 44 60% 96%;
|
|
33
|
+
--brand-strong: 147 52% 27%;
|
|
34
|
+
|
|
35
|
+
--secondary: 43 38% 87%;
|
|
36
|
+
--secondary-foreground: 137 20% 14%;
|
|
37
|
+
|
|
38
|
+
/* Three weights of quiet: muted (labels) → faint (hints) → icon dim. */
|
|
39
|
+
--muted: 43 36% 88%;
|
|
40
|
+
--muted-foreground: 110 10% 35%;
|
|
41
|
+
--faint: 105 8% 48%;
|
|
42
|
+
|
|
43
|
+
--accent: 43 40% 85%;
|
|
44
|
+
--accent-foreground: 137 20% 14%;
|
|
45
|
+
|
|
46
|
+
--destructive: 6 55% 46%;
|
|
47
|
+
--destructive-foreground: 0 0% 100%;
|
|
48
|
+
--success: 147 45% 32%;
|
|
49
|
+
--success-foreground: 0 0% 100%;
|
|
50
|
+
--warning: 38 76% 38%;
|
|
51
|
+
--warning-foreground: 0 0% 100%;
|
|
52
|
+
|
|
53
|
+
--border: 42 35% 81%;
|
|
54
|
+
--border-strong: 42 28% 70%;
|
|
55
|
+
--input: 42 32% 76%;
|
|
56
|
+
--ring: 147 45% 34%;
|
|
57
|
+
|
|
58
|
+
--sidebar: 44 48% 91%;
|
|
59
|
+
--sidebar-foreground: 137 15% 22%;
|
|
60
|
+
--sidebar-accent: 43 38% 85%;
|
|
61
|
+
--sidebar-border: 42 35% 84%;
|
|
62
|
+
|
|
63
|
+
--radius: 0.7rem;
|
|
64
|
+
|
|
65
|
+
/* Depth recipe (referenced from tailwind.config.ts shadows). */
|
|
66
|
+
--sheen: 0 0% 100% / 0.55;
|
|
67
|
+
--shimmer: 40 30% 20% / 0.05;
|
|
68
|
+
--shadow-card: 0 1px 2px 0 hsl(40 35% 25% / 0.1), inset 0 1px 0 0 hsl(0 0% 100% / 0.5);
|
|
69
|
+
--shadow-elevated:
|
|
70
|
+
0 16px 40px -12px hsl(40 35% 20% / 0.22), 0 4px 12px -4px hsl(40 35% 20% / 0.14),
|
|
71
|
+
inset 0 1px 0 0 hsl(0 0% 100% / 0.65);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Dark — "pine at dusk": green-black surfaces, luminous sap, amber glow. */
|
|
75
|
+
.dark {
|
|
76
|
+
--background: 150 25% 6%;
|
|
77
|
+
--foreground: 105 20% 92%;
|
|
78
|
+
|
|
79
|
+
--card: 150 20% 8.5%;
|
|
80
|
+
--card-foreground: 105 20% 92%;
|
|
81
|
+
--surface-2: 150 17% 11%;
|
|
82
|
+
--elevated: 150 16% 13.5%;
|
|
83
|
+
|
|
84
|
+
--popover: 150 18% 9%;
|
|
85
|
+
--popover-foreground: 105 20% 92%;
|
|
86
|
+
|
|
87
|
+
--primary: 145 44% 52%;
|
|
88
|
+
--primary-foreground: 150 25% 6%;
|
|
89
|
+
--brand: 145 44% 52%;
|
|
90
|
+
--brand-foreground: 150 25% 6%;
|
|
91
|
+
--brand-strong: 145 50% 64%;
|
|
92
|
+
|
|
93
|
+
--secondary: 150 14% 13%;
|
|
94
|
+
--secondary-foreground: 105 20% 92%;
|
|
95
|
+
|
|
96
|
+
--muted: 150 14% 12%;
|
|
97
|
+
--muted-foreground: 139 13% 66%;
|
|
98
|
+
--faint: 139 11% 46%;
|
|
99
|
+
|
|
100
|
+
--accent: 150 14% 14%;
|
|
101
|
+
--accent-foreground: 105 20% 92%;
|
|
102
|
+
|
|
103
|
+
--destructive: 6 65% 57%;
|
|
104
|
+
--destructive-foreground: 0 0% 100%;
|
|
105
|
+
--success: 145 44% 50%;
|
|
106
|
+
--success-foreground: 150 25% 6%;
|
|
107
|
+
--warning: 39 87% 60%;
|
|
108
|
+
--warning-foreground: 150 25% 8%;
|
|
109
|
+
|
|
110
|
+
--border: 150 15% 15%;
|
|
111
|
+
--border-strong: 150 13% 23%;
|
|
112
|
+
--input: 150 13% 19%;
|
|
113
|
+
--ring: 145 44% 52%;
|
|
114
|
+
|
|
115
|
+
--sidebar: 150 24% 7%;
|
|
116
|
+
--sidebar-foreground: 105 15% 88%;
|
|
117
|
+
--sidebar-accent: 150 14% 12%;
|
|
118
|
+
--sidebar-border: 150 16% 11%;
|
|
119
|
+
|
|
120
|
+
--sheen: 0 0% 100% / 0.04;
|
|
121
|
+
--shimmer: 0 0% 100% / 0.06;
|
|
122
|
+
--shadow-card: 0 1px 2px 0 hsl(150 20% 2% / 0.5), inset 0 1px 0 0 hsl(0 0% 100% / 0.03);
|
|
123
|
+
--shadow-elevated:
|
|
124
|
+
0 16px 40px -12px hsl(150 25% 2% / 0.75), 0 4px 12px -4px hsl(150 25% 2% / 0.55),
|
|
125
|
+
inset 0 1px 0 0 hsl(0 0% 100% / 0.05);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@layer base {
|
|
130
|
+
* {
|
|
131
|
+
@apply border-border;
|
|
132
|
+
}
|
|
133
|
+
html {
|
|
134
|
+
color-scheme: light;
|
|
135
|
+
}
|
|
136
|
+
html.dark {
|
|
137
|
+
color-scheme: dark;
|
|
138
|
+
}
|
|
139
|
+
body {
|
|
140
|
+
@apply bg-background text-foreground antialiased;
|
|
141
|
+
font-feature-settings: "rlig" 1, "calt" 1, "ss01" 1, "cv01" 1;
|
|
142
|
+
text-rendering: optimizeLegibility;
|
|
143
|
+
}
|
|
144
|
+
::selection {
|
|
145
|
+
background: hsl(var(--brand) / 0.3);
|
|
146
|
+
}
|
|
147
|
+
* {
|
|
148
|
+
scrollbar-width: thin;
|
|
149
|
+
scrollbar-color: hsl(var(--border-strong)) transparent;
|
|
150
|
+
}
|
|
151
|
+
::-webkit-scrollbar {
|
|
152
|
+
width: 10px;
|
|
153
|
+
height: 10px;
|
|
154
|
+
}
|
|
155
|
+
::-webkit-scrollbar-thumb {
|
|
156
|
+
background: hsl(var(--border-strong));
|
|
157
|
+
border-radius: 999px;
|
|
158
|
+
border: 2px solid transparent;
|
|
159
|
+
background-clip: padding-box;
|
|
160
|
+
}
|
|
161
|
+
::-webkit-scrollbar-thumb:hover {
|
|
162
|
+
background: hsl(var(--faint) / 0.6);
|
|
163
|
+
background-clip: padding-box;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@layer components {
|
|
168
|
+
/* Faint top-lit vignette behind the whole app — barely perceptible, but it
|
|
169
|
+
keeps large fields from reading as dead flat. Light mode gets a low sun;
|
|
170
|
+
dark mode gets canopy green with an amber hint. */
|
|
171
|
+
.app-backdrop {
|
|
172
|
+
background:
|
|
173
|
+
radial-gradient(115% 80% at 50% -10%, hsl(39 85% 70% / 0.16), transparent 60%),
|
|
174
|
+
radial-gradient(90% 60% at 100% 0%, hsl(147 45% 55% / 0.1), transparent 55%),
|
|
175
|
+
hsl(var(--background));
|
|
176
|
+
}
|
|
177
|
+
.dark .app-backdrop {
|
|
178
|
+
background:
|
|
179
|
+
radial-gradient(115% 80% at 50% -10%, hsl(150 45% 14% / 0.35), transparent 60%),
|
|
180
|
+
radial-gradient(90% 60% at 100% 0%, hsl(39 60% 30% / 0.1), transparent 55%),
|
|
181
|
+
hsl(var(--background));
|
|
182
|
+
}
|
|
183
|
+
/* Surface with the signature sheen: hairline border + soft inner top edge. */
|
|
184
|
+
.surface {
|
|
185
|
+
@apply rounded-lg border border-border bg-card shadow-card;
|
|
186
|
+
}
|
|
187
|
+
/* Big, tightly-tracked display text for hero moments and metrics. */
|
|
188
|
+
.text-display {
|
|
189
|
+
font-size: clamp(1.5rem, 1.1rem + 1.4vw, 2.1rem);
|
|
190
|
+
line-height: 1.05;
|
|
191
|
+
letter-spacing: -0.02em;
|
|
192
|
+
font-weight: 600;
|
|
193
|
+
}
|
|
194
|
+
.tnum {
|
|
195
|
+
font-variant-numeric: tabular-nums;
|
|
196
|
+
}
|
|
197
|
+
/* Shimmering skeleton sweep. */
|
|
198
|
+
.skeleton {
|
|
199
|
+
@apply relative overflow-hidden rounded-md bg-secondary/60;
|
|
200
|
+
}
|
|
201
|
+
.skeleton::after {
|
|
202
|
+
content: "";
|
|
203
|
+
@apply absolute inset-0 -translate-x-full animate-shimmer;
|
|
204
|
+
background: linear-gradient(90deg, transparent, hsl(var(--shimmer)), transparent);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* Markdown rendering inside chat (prose-lite, theme-aware). */
|
|
209
|
+
@layer components {
|
|
210
|
+
.md > :first-child {
|
|
211
|
+
margin-top: 0;
|
|
212
|
+
}
|
|
213
|
+
.md > :last-child {
|
|
214
|
+
margin-bottom: 0;
|
|
215
|
+
}
|
|
216
|
+
.md p,
|
|
217
|
+
.md ul,
|
|
218
|
+
.md ol,
|
|
219
|
+
.md pre,
|
|
220
|
+
.md blockquote {
|
|
221
|
+
margin: 0.5rem 0;
|
|
222
|
+
}
|
|
223
|
+
.md ul,
|
|
224
|
+
.md ol {
|
|
225
|
+
padding-left: 1.25rem;
|
|
226
|
+
}
|
|
227
|
+
.md ul {
|
|
228
|
+
list-style: disc;
|
|
229
|
+
}
|
|
230
|
+
.md ol {
|
|
231
|
+
list-style: decimal;
|
|
232
|
+
}
|
|
233
|
+
.md li {
|
|
234
|
+
margin: 0.15rem 0;
|
|
235
|
+
}
|
|
236
|
+
.md a {
|
|
237
|
+
color: hsl(var(--brand-strong));
|
|
238
|
+
text-underline-offset: 2px;
|
|
239
|
+
}
|
|
240
|
+
.md a:hover {
|
|
241
|
+
text-decoration: underline;
|
|
242
|
+
}
|
|
243
|
+
.md code {
|
|
244
|
+
font-family: var(--font-geist-mono), monospace;
|
|
245
|
+
font-size: 0.85em;
|
|
246
|
+
background: hsl(var(--secondary));
|
|
247
|
+
border: 1px solid hsl(var(--border));
|
|
248
|
+
border-radius: 5px;
|
|
249
|
+
padding: 0.08em 0.34em;
|
|
250
|
+
}
|
|
251
|
+
.md pre {
|
|
252
|
+
background: hsl(var(--background));
|
|
253
|
+
border: 1px solid hsl(var(--border));
|
|
254
|
+
border-radius: 8px;
|
|
255
|
+
padding: 0.75rem 0.85rem;
|
|
256
|
+
overflow-x: auto;
|
|
257
|
+
}
|
|
258
|
+
.md pre code {
|
|
259
|
+
background: transparent;
|
|
260
|
+
border: none;
|
|
261
|
+
padding: 0;
|
|
262
|
+
font-size: 0.82rem;
|
|
263
|
+
line-height: 1.5;
|
|
264
|
+
}
|
|
265
|
+
.md h1,
|
|
266
|
+
.md h2,
|
|
267
|
+
.md h3 {
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
line-height: 1.3;
|
|
270
|
+
margin: 0.9rem 0 0.4rem;
|
|
271
|
+
letter-spacing: -0.01em;
|
|
272
|
+
}
|
|
273
|
+
.md h1 {
|
|
274
|
+
font-size: 1.05rem;
|
|
275
|
+
}
|
|
276
|
+
.md h2 {
|
|
277
|
+
font-size: 1rem;
|
|
278
|
+
}
|
|
279
|
+
.md h3 {
|
|
280
|
+
font-size: 0.95rem;
|
|
281
|
+
}
|
|
282
|
+
.md blockquote {
|
|
283
|
+
border-left: 2px solid hsl(var(--border-strong));
|
|
284
|
+
padding-left: 0.75rem;
|
|
285
|
+
color: hsl(var(--muted-foreground));
|
|
286
|
+
}
|
|
287
|
+
.md table {
|
|
288
|
+
border-collapse: collapse;
|
|
289
|
+
font-size: 0.85em;
|
|
290
|
+
}
|
|
291
|
+
.md th,
|
|
292
|
+
.md td {
|
|
293
|
+
border: 1px solid hsl(var(--border));
|
|
294
|
+
padding: 0.3rem 0.55rem;
|
|
295
|
+
text-align: left;
|
|
296
|
+
}
|
|
297
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import "./globals.css";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { GeistSans } from "geist/font/sans";
|
|
5
|
+
import { GeistMono } from "geist/font/mono";
|
|
6
|
+
import { AuthProvider } from "@/lib/auth";
|
|
7
|
+
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
import { Toaster } from "@/components/ui/sonner";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = {
|
|
13
|
+
title: "Garage — Glorp orchestration",
|
|
14
|
+
description: "Command console for the Glorp Garage agent runtime.",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
18
|
+
return (
|
|
19
|
+
<html lang="en" className={cn(GeistSans.variable, GeistMono.variable)} suppressHydrationWarning>
|
|
20
|
+
<head>
|
|
21
|
+
{/* Sets the `dark` class before first paint — localStorage, then OS. */}
|
|
22
|
+
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
|
|
23
|
+
</head>
|
|
24
|
+
<body className="min-h-screen bg-background font-sans text-foreground antialiased">
|
|
25
|
+
<AuthProvider>
|
|
26
|
+
<TooltipProvider delayDuration={250}>{children}</TooltipProvider>
|
|
27
|
+
</AuthProvider>
|
|
28
|
+
<Toaster />
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
31
|
+
);
|
|
32
|
+
}
|