@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,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from "react";
|
|
4
|
+
import { useAuth } from "@/lib/auth";
|
|
5
|
+
import { BrandLockup } from "@/components/brand";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Label } from "@/components/ui/label";
|
|
9
|
+
import { ErrorState, Spinner } from "@/components/shared";
|
|
10
|
+
|
|
11
|
+
export default function LoginPage() {
|
|
12
|
+
const { login } = useAuth();
|
|
13
|
+
const [username, setUsername] = useState("");
|
|
14
|
+
const [password, setPassword] = useState("");
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
const [busy, setBusy] = useState(false);
|
|
17
|
+
|
|
18
|
+
const submit = async (e: FormEvent) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
setError(null);
|
|
21
|
+
setBusy(true);
|
|
22
|
+
try {
|
|
23
|
+
await login(username, password);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
setError(err instanceof Error ? err.message : "Login failed");
|
|
26
|
+
} finally {
|
|
27
|
+
setBusy(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="app-backdrop grid min-h-screen place-items-center px-5">
|
|
33
|
+
<div className="w-full max-w-sm animate-slide-up">
|
|
34
|
+
<div className="mb-8 flex justify-center">
|
|
35
|
+
<BrandLockup markClassName="size-5" className="text-[17px]" />
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<form onSubmit={submit} className="surface p-6">
|
|
39
|
+
<h1 className="text-[15px] font-semibold tracking-tight">Sign in</h1>
|
|
40
|
+
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">Use the admin credentials provisioned on the Garage server.</p>
|
|
41
|
+
|
|
42
|
+
<div className="mt-6 space-y-4">
|
|
43
|
+
<div className="space-y-1.5">
|
|
44
|
+
<Label>Username</Label>
|
|
45
|
+
<Input value={username} autoFocus onChange={(e) => setUsername(e.target.value)} />
|
|
46
|
+
</div>
|
|
47
|
+
<div className="space-y-1.5">
|
|
48
|
+
<Label>Password</Label>
|
|
49
|
+
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
|
50
|
+
</div>
|
|
51
|
+
{error && <ErrorState message={error} />}
|
|
52
|
+
<Button type="submit" className="w-full" disabled={busy}>
|
|
53
|
+
{busy ? <Spinner /> : null} Sign in
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
|
|
58
|
+
<p className="mt-5 text-center text-[12px] text-faint">Command console for the Glorp agent runtime.</p>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import {
|
|
6
|
+
Radar,
|
|
7
|
+
MessageSquare,
|
|
8
|
+
FolderGit2,
|
|
9
|
+
Boxes,
|
|
10
|
+
Rocket,
|
|
11
|
+
Cpu,
|
|
12
|
+
KeyRound,
|
|
13
|
+
Cloud,
|
|
14
|
+
LogOut,
|
|
15
|
+
ChevronsUpDown,
|
|
16
|
+
type LucideIcon,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
import { useAuth } from "@/lib/auth";
|
|
19
|
+
import { BrandLockup } from "@/components/brand";
|
|
20
|
+
import { cn } from "@/lib/utils";
|
|
21
|
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
22
|
+
import {
|
|
23
|
+
DropdownMenu,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
DropdownMenuContent,
|
|
26
|
+
DropdownMenuItem,
|
|
27
|
+
DropdownMenuLabel,
|
|
28
|
+
DropdownMenuSeparator,
|
|
29
|
+
} from "@/components/ui/dropdown-menu";
|
|
30
|
+
|
|
31
|
+
interface NavLink {
|
|
32
|
+
href: string;
|
|
33
|
+
label: string;
|
|
34
|
+
icon: LucideIcon;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Operate = the live surfaces; Configure = the tenancy/runtime setup. */
|
|
38
|
+
const OPERATE: NavLink[] = [
|
|
39
|
+
{ href: "/", label: "Fleet", icon: Radar },
|
|
40
|
+
{ href: "/sessions", label: "Sessions", icon: MessageSquare },
|
|
41
|
+
{ href: "/workspaces", label: "Workspaces", icon: FolderGit2 },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const CONFIGURE: NavLink[] = [
|
|
45
|
+
{ href: "/namespaces", label: "Namespaces", icon: Boxes },
|
|
46
|
+
{ href: "/provisioning", label: "Provisioning", icon: Rocket },
|
|
47
|
+
{ href: "/credentials", label: "Models", icon: Cpu },
|
|
48
|
+
{ href: "/storage", label: "Storage", icon: Cloud },
|
|
49
|
+
{ href: "/keys", label: "API Keys", icon: KeyRound },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function NavItem({ link, active }: { link: NavLink; active: boolean }) {
|
|
53
|
+
const Icon = link.icon;
|
|
54
|
+
return (
|
|
55
|
+
<Link
|
|
56
|
+
href={link.href}
|
|
57
|
+
className={cn(
|
|
58
|
+
"group relative flex items-center gap-2.5 rounded-md px-2.5 py-[7px] text-[13.5px] font-medium transition-colors",
|
|
59
|
+
active ? "bg-sidebar-accent text-foreground shadow-sheen" : "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-brand shadow-[0_0_8px_hsl(var(--brand)_/_0.7)]" />}
|
|
63
|
+
<Icon className={cn("size-[17px] shrink-0 transition-colors", active ? "text-brand" : "text-faint group-hover:text-foreground")} />
|
|
64
|
+
{link.label}
|
|
65
|
+
</Link>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function NavGroup({ label, links, isActive }: { label: string; links: NavLink[]; isActive: (h: string) => boolean }) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex flex-col gap-0.5">
|
|
72
|
+
<p className="px-2.5 pb-1 pt-1 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-faint">{label}</p>
|
|
73
|
+
{links.map((l) => (
|
|
74
|
+
<NavItem key={l.href} link={l} active={isActive(l.href)} />
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AppSidebar() {
|
|
81
|
+
const pathname = usePathname();
|
|
82
|
+
const { identity, logout } = useAuth();
|
|
83
|
+
const isActive = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
|
|
84
|
+
const user = identity?.user ?? "admin";
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<aside className="sticky top-0 flex h-screen w-[238px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
|
88
|
+
<div className="flex h-14 items-center px-4">
|
|
89
|
+
<BrandLockup />
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<nav className="flex flex-1 flex-col gap-5 overflow-y-auto px-3 py-3">
|
|
93
|
+
<NavGroup label="Operate" links={OPERATE} isActive={isActive} />
|
|
94
|
+
<NavGroup label="Configure" links={CONFIGURE} isActive={isActive} />
|
|
95
|
+
</nav>
|
|
96
|
+
|
|
97
|
+
<div className="border-t border-sidebar-border p-2">
|
|
98
|
+
<DropdownMenu>
|
|
99
|
+
<DropdownMenuTrigger className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
|
|
100
|
+
<Avatar className="size-7">
|
|
101
|
+
<AvatarFallback className="bg-surface-2 text-[11px]">{user.slice(0, 1).toUpperCase()}</AvatarFallback>
|
|
102
|
+
</Avatar>
|
|
103
|
+
<div className="min-w-0 flex-1">
|
|
104
|
+
<div className="truncate text-[13px] font-medium text-foreground">{user}</div>
|
|
105
|
+
<div className="truncate text-[11px] text-muted-foreground">{identity?.is_admin ? "Administrator" : "Member"}</div>
|
|
106
|
+
</div>
|
|
107
|
+
<ChevronsUpDown className="size-4 shrink-0 text-faint" />
|
|
108
|
+
</DropdownMenuTrigger>
|
|
109
|
+
<DropdownMenuContent align="start" side="top" className="w-[210px]">
|
|
110
|
+
<DropdownMenuLabel>Signed in as {user}</DropdownMenuLabel>
|
|
111
|
+
<DropdownMenuSeparator />
|
|
112
|
+
<DropdownMenuItem onClick={logout} className="text-foreground">
|
|
113
|
+
<LogOut /> Sign out
|
|
114
|
+
</DropdownMenuItem>
|
|
115
|
+
</DropdownMenuContent>
|
|
116
|
+
</DropdownMenu>
|
|
117
|
+
</div>
|
|
118
|
+
</aside>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Boxes } from "lucide-react";
|
|
5
|
+
import { useQuery } from "@/lib/hooks";
|
|
6
|
+
import { getNamespace, setNamespace } from "@/lib/api";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import type { NamespaceDto, SessionDto } from "@/lib/types";
|
|
9
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
10
|
+
import { ThemeToggle } from "@/components/theme-toggle";
|
|
11
|
+
|
|
12
|
+
const DEFAULT = "__default__";
|
|
13
|
+
|
|
14
|
+
/** Live count of running sessions across the current namespace. */
|
|
15
|
+
function FleetPulse() {
|
|
16
|
+
const { data } = useQuery<{ sessions: SessionDto[] }>("/sessions", [], 5000);
|
|
17
|
+
const running = (data?.sessions ?? []).filter((s) => s.state === "busy" || s.state === "provisioning").length;
|
|
18
|
+
const live = running > 0;
|
|
19
|
+
return (
|
|
20
|
+
<span className={cn("hidden items-center gap-1.5 text-[12px] font-medium sm:inline-flex", live ? "text-success" : "text-muted-foreground")}>
|
|
21
|
+
<span className="relative grid size-2 place-items-center">
|
|
22
|
+
{live && <span className="absolute size-2 rounded-full bg-success opacity-60 animate-pulse-ring" />}
|
|
23
|
+
<span className={cn("relative size-2 rounded-full", live ? "bg-success" : "bg-faint")} />
|
|
24
|
+
</span>
|
|
25
|
+
{live ? `${running} running` : "all idle"}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Slim top bar: page title + live pulse on the left, namespace scope on the right. */
|
|
31
|
+
export function AppTopbar({ title }: { title: string }) {
|
|
32
|
+
const { data } = useQuery<{ namespaces: NamespaceDto[] }>("/namespaces");
|
|
33
|
+
const [ns, setNs] = useState<string>(DEFAULT);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setNs(getNamespace() ?? DEFAULT);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const onChange = (value: string) => {
|
|
40
|
+
setNs(value);
|
|
41
|
+
setNamespace(value === DEFAULT ? null : value);
|
|
42
|
+
// Namespace scope affects every fetched list — reload to re-scope cleanly.
|
|
43
|
+
window.location.reload();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const tenants = (data?.namespaces ?? []).filter((n) => !n.is_default);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between gap-4 border-b border-border bg-background/70 px-6 backdrop-blur-xl">
|
|
50
|
+
<div className="flex items-center gap-3.5">
|
|
51
|
+
<h1 className="text-[13.5px] font-semibold tracking-tight text-foreground">{title}</h1>
|
|
52
|
+
<span className="h-3.5 w-px bg-border" />
|
|
53
|
+
<FleetPulse />
|
|
54
|
+
</div>
|
|
55
|
+
<div className="flex items-center gap-2">
|
|
56
|
+
<span className="hidden whitespace-nowrap text-[10.5px] font-medium uppercase tracking-[0.12em] text-faint md:inline">Namespace</span>
|
|
57
|
+
<Select value={ns} onValueChange={onChange}>
|
|
58
|
+
<SelectTrigger className="h-8 w-[168px] whitespace-nowrap text-[13px]">
|
|
59
|
+
<Boxes className="size-3.5 shrink-0 text-faint" />
|
|
60
|
+
<SelectValue />
|
|
61
|
+
</SelectTrigger>
|
|
62
|
+
<SelectContent>
|
|
63
|
+
<SelectItem value={DEFAULT}>default</SelectItem>
|
|
64
|
+
{tenants.map((n) => (
|
|
65
|
+
<SelectItem key={n.id} value={n.id}>
|
|
66
|
+
{n.name}
|
|
67
|
+
</SelectItem>
|
|
68
|
+
))}
|
|
69
|
+
</SelectContent>
|
|
70
|
+
</Select>
|
|
71
|
+
<span className="mx-1 h-3.5 w-px bg-border" />
|
|
72
|
+
<ThemeToggle />
|
|
73
|
+
</div>
|
|
74
|
+
</header>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Garage mark — a minimal archway (the garage opening / sandbox) with a
|
|
5
|
+
* roller door and a single "spark" inside: the agent at work within the
|
|
6
|
+
* sandbox. Drawn in currentColor so it inherits text color; the spark is the
|
|
7
|
+
* one deliberate touch of brand accent. Reads cleanly from 16px to 64px.
|
|
8
|
+
*/
|
|
9
|
+
export function GarageMark({ className, spark = true }: { className?: string; spark?: boolean }) {
|
|
10
|
+
return (
|
|
11
|
+
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
|
|
12
|
+
{/* archway: two legs rising to a rounded top */}
|
|
13
|
+
<path
|
|
14
|
+
d="M4 21V11a8 8 0 0 1 16 0v10"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
strokeWidth="1.8"
|
|
17
|
+
strokeLinecap="round"
|
|
18
|
+
strokeLinejoin="round"
|
|
19
|
+
/>
|
|
20
|
+
{/* threshold / ground line */}
|
|
21
|
+
<path d="M2.5 21h19" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
22
|
+
{/* roller door */}
|
|
23
|
+
<path
|
|
24
|
+
d="M8 21v-4.6h8V21"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
strokeWidth="1.8"
|
|
27
|
+
strokeLinecap="round"
|
|
28
|
+
strokeLinejoin="round"
|
|
29
|
+
/>
|
|
30
|
+
<path d="M8 18.5h8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" opacity="0.45" />
|
|
31
|
+
{/* the agent, working inside */}
|
|
32
|
+
<circle cx="12" cy="9.6" r="1.55" fill={spark ? "hsl(var(--brand))" : "currentColor"} />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mark + wordmark lockup for the sidebar / login. */
|
|
38
|
+
export function BrandLockup({ className, markClassName }: { className?: string; markClassName?: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<span className={cn("inline-flex items-center gap-2.5", className)}>
|
|
41
|
+
<span className="grid size-8 place-items-center rounded-lg border border-border bg-surface-2 text-foreground shadow-sheen">
|
|
42
|
+
<GarageMark className={cn("size-[18px]", markClassName)} />
|
|
43
|
+
</span>
|
|
44
|
+
<span className="text-[15px] font-semibold tracking-tight">Garage</span>
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { CircleStop, ImagePlus, SendHorizontal, X } from "lucide-react";
|
|
5
|
+
import { Spinner } from "@/components/shared";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { SlashMenu, type SlashCommand } from "./slash-menu";
|
|
8
|
+
|
|
9
|
+
/** Message composer: auto-growing textarea, Enter to send, Stop while busy.
|
|
10
|
+
* Typing "/" surfaces the agent's commands (hooks + skills) inline. */
|
|
11
|
+
export function Composer({
|
|
12
|
+
busy,
|
|
13
|
+
disabled,
|
|
14
|
+
onSend,
|
|
15
|
+
onStop,
|
|
16
|
+
controls,
|
|
17
|
+
commands = [],
|
|
18
|
+
imageSupport,
|
|
19
|
+
}: {
|
|
20
|
+
busy: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onSend: (text: string, images?: Array<{ data: string; media_type: string }>) => void;
|
|
23
|
+
onStop: () => void;
|
|
24
|
+
controls?: React.ReactNode;
|
|
25
|
+
commands?: SlashCommand[];
|
|
26
|
+
/** false = the current model cannot see images (attach disabled); null/undefined = unknown (allowed). */
|
|
27
|
+
imageSupport?: boolean | null;
|
|
28
|
+
}) {
|
|
29
|
+
const [text, setText] = React.useState("");
|
|
30
|
+
const [caret, setCaret] = React.useState(0);
|
|
31
|
+
const [slashIdx, setSlashIdx] = React.useState(0);
|
|
32
|
+
const [slashDismissed, setSlashDismissed] = React.useState(false);
|
|
33
|
+
const [images, setImages] = React.useState<Array<{ data: string; media_type: string; preview: string }>>([]);
|
|
34
|
+
const ref = React.useRef<HTMLTextAreaElement>(null);
|
|
35
|
+
const fileRef = React.useRef<HTMLInputElement>(null);
|
|
36
|
+
|
|
37
|
+
const addImageFiles = React.useCallback((files: Iterable<File>) => {
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
if (!file.type.startsWith("image/")) continue;
|
|
40
|
+
const reader = new FileReader();
|
|
41
|
+
reader.onload = () => {
|
|
42
|
+
const url = String(reader.result);
|
|
43
|
+
const data = url.slice(url.indexOf(",") + 1); // strip the data: prefix
|
|
44
|
+
setImages((prev) => (prev.length >= 6 ? prev : [...prev, { data, media_type: file.type, preview: url }]));
|
|
45
|
+
};
|
|
46
|
+
reader.readAsDataURL(file);
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
// The menu engages while the caret sits in a "/token" — anywhere in the
|
|
51
|
+
// message, as long as the slash follows start-of-text or whitespace
|
|
52
|
+
// (mirrors glove's own directive parser).
|
|
53
|
+
const beforeCaret = text.slice(0, caret);
|
|
54
|
+
const slashMatch = /(^|\s)\/([a-z0-9_-]*)$/i.exec(beforeCaret);
|
|
55
|
+
const slashQuery = slashMatch?.[2] ?? null;
|
|
56
|
+
const tokenStart = slashMatch ? beforeCaret.length - slashMatch[2]!.length - 1 : -1;
|
|
57
|
+
const slashMatches =
|
|
58
|
+
slashQuery !== null && !slashDismissed
|
|
59
|
+
? commands.filter((c) => c.name.slice(1).toLowerCase().startsWith(slashQuery.toLowerCase())).slice(0, 8)
|
|
60
|
+
: [];
|
|
61
|
+
const slashOpen = slashMatches.length > 0;
|
|
62
|
+
|
|
63
|
+
const pickSlash = (cmd: SlashCommand) => {
|
|
64
|
+
const next = `${text.slice(0, tokenStart)}${cmd.name} ${text.slice(caret)}`;
|
|
65
|
+
const newCaret = tokenStart + cmd.name.length + 1;
|
|
66
|
+
setText(next);
|
|
67
|
+
setSlashIdx(0);
|
|
68
|
+
requestAnimationFrame(() => {
|
|
69
|
+
const el = ref.current;
|
|
70
|
+
if (!el) return;
|
|
71
|
+
el.focus();
|
|
72
|
+
el.setSelectionRange(newCaret, newCaret);
|
|
73
|
+
setCaret(newCaret);
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const grow = React.useCallback(() => {
|
|
78
|
+
const el = ref.current;
|
|
79
|
+
if (!el) return;
|
|
80
|
+
el.style.height = "auto";
|
|
81
|
+
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
React.useEffect(grow, [text, grow]);
|
|
85
|
+
|
|
86
|
+
const submit = () => {
|
|
87
|
+
if ((!text.trim() && images.length === 0) || disabled) return;
|
|
88
|
+
onSend(text, images.length ? images.map(({ data, media_type }) => ({ data, media_type })) : undefined);
|
|
89
|
+
setText("");
|
|
90
|
+
setImages([]);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="border-t border-border bg-background px-6 py-3.5 md:px-8">
|
|
95
|
+
<div className="group relative w-full rounded-xl border border-border bg-card p-2.5 shadow-card transition-shadow focus-within:border-brand/40 focus-within:shadow-glow">
|
|
96
|
+
{slashOpen && <SlashMenu commands={slashMatches} activeIndex={slashIdx} onPick={pickSlash} />}
|
|
97
|
+
{images.length > 0 && (
|
|
98
|
+
<div className="flex flex-wrap gap-2 px-2 pb-1 pt-1.5">
|
|
99
|
+
{images.map((img, i) => (
|
|
100
|
+
<span key={i} className="group/img relative overflow-hidden rounded-md border border-border shadow-sheen">
|
|
101
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
102
|
+
<img src={img.preview} alt="attachment" className="size-14 object-cover" />
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))}
|
|
106
|
+
className="absolute right-0.5 top-0.5 grid size-4 place-items-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/img:opacity-100"
|
|
107
|
+
title="Remove image"
|
|
108
|
+
>
|
|
109
|
+
<X className="size-3" />
|
|
110
|
+
</button>
|
|
111
|
+
</span>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
<textarea
|
|
116
|
+
ref={ref}
|
|
117
|
+
rows={1}
|
|
118
|
+
value={text}
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
placeholder={disabled ? "Session offline…" : "Message Glorp…"}
|
|
121
|
+
onChange={(e) => {
|
|
122
|
+
setText(e.target.value);
|
|
123
|
+
setCaret(e.target.selectionStart ?? e.target.value.length);
|
|
124
|
+
setSlashDismissed(false);
|
|
125
|
+
setSlashIdx(0);
|
|
126
|
+
}}
|
|
127
|
+
onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)}
|
|
128
|
+
onPaste={(e) => {
|
|
129
|
+
const files = [...e.clipboardData.items].filter((it) => it.kind === "file").map((it) => it.getAsFile()).filter((f): f is File => Boolean(f));
|
|
130
|
+
if (files.length) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
if (imageSupport === false) return; // text-only model — ignore the paste
|
|
133
|
+
addImageFiles(files);
|
|
134
|
+
}
|
|
135
|
+
}}
|
|
136
|
+
onKeyDown={(e) => {
|
|
137
|
+
if (slashOpen) {
|
|
138
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setSlashIdx((i) => (i + 1) % slashMatches.length); return; }
|
|
139
|
+
if (e.key === "ArrowUp") { e.preventDefault(); setSlashIdx((i) => (i - 1 + slashMatches.length) % slashMatches.length); return; }
|
|
140
|
+
if (e.key === "Tab" || e.key === "Enter") { e.preventDefault(); pickSlash(slashMatches[slashIdx] ?? slashMatches[0]!); return; }
|
|
141
|
+
if (e.key === "Escape") { e.preventDefault(); setSlashDismissed(true); return; }
|
|
142
|
+
}
|
|
143
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
submit();
|
|
146
|
+
}
|
|
147
|
+
}}
|
|
148
|
+
className="max-h-[200px] min-h-[24px] w-full resize-none bg-transparent px-2.5 py-1.5 text-[13.5px] leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/60 disabled:opacity-60"
|
|
149
|
+
/>
|
|
150
|
+
<div className="flex items-center justify-between gap-2 border-t border-border/70 pt-2.5">
|
|
151
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
152
|
+
{controls}
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => fileRef.current?.click()}
|
|
156
|
+
disabled={disabled || imageSupport === false}
|
|
157
|
+
className="grid size-8 shrink-0 place-items-center rounded-md text-faint transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-40"
|
|
158
|
+
title={imageSupport === false ? "The current model can't see images" : "Attach image (or paste one)"}
|
|
159
|
+
>
|
|
160
|
+
<ImagePlus className="size-4" />
|
|
161
|
+
</button>
|
|
162
|
+
<input
|
|
163
|
+
ref={fileRef}
|
|
164
|
+
type="file"
|
|
165
|
+
accept="image/*"
|
|
166
|
+
multiple
|
|
167
|
+
className="hidden"
|
|
168
|
+
onChange={(e) => {
|
|
169
|
+
if (e.target.files) addImageFiles(e.target.files);
|
|
170
|
+
e.target.value = "";
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="flex shrink-0 items-center gap-2.5">
|
|
175
|
+
{busy ? (
|
|
176
|
+
<>
|
|
177
|
+
<span className="text-[11.5px] font-medium text-warning">Working…</span>
|
|
178
|
+
<Button size="sm" variant="secondary" onClick={onStop} title="Stop the agent">
|
|
179
|
+
<CircleStop /> Stop
|
|
180
|
+
</Button>
|
|
181
|
+
</>
|
|
182
|
+
) : (
|
|
183
|
+
<>
|
|
184
|
+
<span className="hidden text-[11px] text-faint sm:inline">
|
|
185
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[10px]">↵</kbd> send
|
|
186
|
+
<span className="mx-1 text-faint/60">·</span>
|
|
187
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[10px]">⇧↵</kbd> newline
|
|
188
|
+
</span>
|
|
189
|
+
<Button size="sm" onClick={submit} disabled={(!text.trim() && images.length === 0) || disabled} title="Send">
|
|
190
|
+
{disabled ? <Spinner /> : <SendHorizontal />} Send
|
|
191
|
+
</Button>
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ArrowDown, MessageSquare } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { EmptyState } from "@/components/shared";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Message, StreamingMessage } from "./message";
|
|
9
|
+
import { ToolCall } from "./tool-call";
|
|
10
|
+
import type { ChatTurn } from "@/lib/types";
|
|
11
|
+
|
|
12
|
+
function Thinking() {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex items-center gap-2 pl-10 text-[12px] text-faint">
|
|
15
|
+
<span className="relative grid size-2 place-items-center">
|
|
16
|
+
<span className="absolute size-2 rounded-full bg-brand opacity-60 animate-pulse-ring" />
|
|
17
|
+
<span className="relative size-2 rounded-full bg-brand" />
|
|
18
|
+
</span>
|
|
19
|
+
Thinking…
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Conversation({
|
|
25
|
+
items,
|
|
26
|
+
streaming,
|
|
27
|
+
busy,
|
|
28
|
+
userInitial,
|
|
29
|
+
className,
|
|
30
|
+
}: {
|
|
31
|
+
items: ChatTurn[];
|
|
32
|
+
streaming: string;
|
|
33
|
+
busy: boolean;
|
|
34
|
+
userInitial?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}) {
|
|
37
|
+
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
38
|
+
const [stuck, setStuck] = React.useState(true);
|
|
39
|
+
|
|
40
|
+
const onScroll = () => {
|
|
41
|
+
const el = scrollRef.current;
|
|
42
|
+
if (!el) return;
|
|
43
|
+
setStuck(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
if (!stuck) return;
|
|
48
|
+
const el = scrollRef.current;
|
|
49
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
50
|
+
}, [items, streaming, busy, stuck]);
|
|
51
|
+
|
|
52
|
+
const jump = () => {
|
|
53
|
+
const el = scrollRef.current;
|
|
54
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
55
|
+
setStuck(true);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const empty = items.length === 0 && !streaming;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn("relative min-h-0", className)}>
|
|
62
|
+
<div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto px-6 md:px-8">
|
|
63
|
+
<div className="flex w-full flex-col gap-6 py-7">
|
|
64
|
+
{empty ? (
|
|
65
|
+
<div className="pt-10">
|
|
66
|
+
<EmptyState icon={MessageSquare} title="Start the conversation" description="Send a message to put the agent to work in this session." />
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
items.map((t) =>
|
|
70
|
+
t.kind === "tool" && t.tool ? <ToolCall key={t.id} tool={t.tool} /> : <Message key={t.id} turn={t} userInitial={userInitial} />,
|
|
71
|
+
)
|
|
72
|
+
)}
|
|
73
|
+
{streaming && <StreamingMessage text={streaming} />}
|
|
74
|
+
{busy && !streaming && !empty && <Thinking />}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
{!stuck && (
|
|
78
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">
|
|
79
|
+
<Button size="sm" variant="secondary" onClick={jump} className="pointer-events-auto shadow-elevated">
|
|
80
|
+
<ArrowDown /> Jump to latest
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|