@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,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { ChevronRight, KeyRound, Gauge, WifiOff, ServerCrash, AlertTriangle, ArrowRight, Eye, type LucideIcon } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import type { ChatTurn } from "@/lib/types";
|
|
8
|
+
|
|
9
|
+
type Kind = "config" | "auth" | "modality" | "rate_limit" | "quota" | "network" | "upstream" | "internal";
|
|
10
|
+
|
|
11
|
+
const KIND: Record<Kind, { icon: LucideIcon; label: string }> = {
|
|
12
|
+
config: { icon: KeyRound, label: "Setup" },
|
|
13
|
+
auth: { icon: KeyRound, label: "Authentication" },
|
|
14
|
+
modality: { icon: Eye, label: "Modality" },
|
|
15
|
+
rate_limit: { icon: Gauge, label: "Rate limit" },
|
|
16
|
+
quota: { icon: Gauge, label: "Quota" },
|
|
17
|
+
network: { icon: WifiOff, label: "Network" },
|
|
18
|
+
upstream: { icon: ServerCrash, label: "Provider" },
|
|
19
|
+
internal: { icon: AlertTriangle, label: "Agent" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function resumesAt(retryAfterSec: number, createdAt: number): string {
|
|
23
|
+
const t = new Date(createdAt + retryAfterSec * 1000);
|
|
24
|
+
return t.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A failed turn, rendered as something a person can act on: human headline,
|
|
29
|
+
* one-line recovery hint, a direct action, and the raw error tucked into a
|
|
30
|
+
* collapsed technical view — never a bare stack trace in the conversation.
|
|
31
|
+
*/
|
|
32
|
+
export function ErrorCard({ turn }: { turn: ChatTurn }) {
|
|
33
|
+
const [showDetail, setShowDetail] = React.useState(false);
|
|
34
|
+
const meta = (turn.meta ?? {}) as { kind?: Kind; hint?: string; retryAfterSec?: number; detail?: string };
|
|
35
|
+
const kind: Kind = meta.kind && meta.kind in KIND ? meta.kind : "internal";
|
|
36
|
+
const { icon: Icon, label } = KIND[kind];
|
|
37
|
+
const needsKey = kind === "auth" || kind === "config";
|
|
38
|
+
const needsModel = kind === "quota" || kind === "rate_limit" || kind === "modality";
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="mx-auto w-full max-w-xl rounded-lg border border-destructive/25 bg-destructive/[0.06] px-4 py-3 shadow-sheen">
|
|
42
|
+
<div className="flex items-start gap-3">
|
|
43
|
+
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md border border-destructive/25 bg-destructive/10 text-destructive">
|
|
44
|
+
<Icon className="size-3.5" />
|
|
45
|
+
</span>
|
|
46
|
+
<div className="min-w-0 flex-1">
|
|
47
|
+
<div className="flex items-baseline gap-2">
|
|
48
|
+
<p className="text-[13.5px] font-semibold text-foreground">{turn.text}</p>
|
|
49
|
+
<span className="text-[10.5px] font-medium uppercase tracking-wider text-destructive/80">{label}</span>
|
|
50
|
+
</div>
|
|
51
|
+
{meta.hint && <p className="mt-1 text-[12.5px] leading-relaxed text-muted-foreground">{meta.hint}</p>}
|
|
52
|
+
{meta.retryAfterSec != null && (
|
|
53
|
+
<p className="mt-1 text-[12px] text-faint">Provider window resets around {resumesAt(meta.retryAfterSec, turn.createdAt)}.</p>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<div className="mt-2.5 flex items-center gap-3">
|
|
57
|
+
{needsKey && (
|
|
58
|
+
<Link
|
|
59
|
+
href="/credentials"
|
|
60
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-[12px] font-medium text-foreground shadow-sheen transition-colors hover:bg-elevated"
|
|
61
|
+
>
|
|
62
|
+
Open Models <ArrowRight className="size-3" />
|
|
63
|
+
</Link>
|
|
64
|
+
)}
|
|
65
|
+
{needsModel && (
|
|
66
|
+
<span className="text-[12px] text-muted-foreground">Switch models from the composer below to keep working.</span>
|
|
67
|
+
)}
|
|
68
|
+
{meta.detail && (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => setShowDetail((s) => !s)}
|
|
72
|
+
className="inline-flex items-center gap-1 text-[12px] text-faint transition-colors hover:text-muted-foreground"
|
|
73
|
+
>
|
|
74
|
+
<ChevronRight className={cn("size-3 transition-transform", showDetail && "rotate-90")} />
|
|
75
|
+
Technical details
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{showDetail && meta.detail && (
|
|
81
|
+
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md border border-border bg-background p-2.5 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
|
82
|
+
{meta.detail}
|
|
83
|
+
</pre>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Markdown from "react-markdown";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
/** Theme-aware markdown for assistant messages (styles live in globals.css `.md`). */
|
|
6
|
+
export function Md({ children, className }: { children: string; className?: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={cn("md max-w-3xl text-[13.5px] leading-relaxed text-foreground", className)}>
|
|
9
|
+
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronRight } from "lucide-react";
|
|
5
|
+
import { GarageMark } from "@/components/brand";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { Md } from "./markdown";
|
|
8
|
+
import { ErrorCard } from "./error-card";
|
|
9
|
+
import type { ChatTurn } from "@/lib/types";
|
|
10
|
+
|
|
11
|
+
/** The Glorp avatar glyph — quiet brand-tinted disc, shared by message + stream. */
|
|
12
|
+
function AgentGlyph() {
|
|
13
|
+
return (
|
|
14
|
+
<span className="grid size-7 shrink-0 place-items-center rounded-full border border-brand/30 bg-brand/10 text-brand shadow-sheen">
|
|
15
|
+
<GarageMark className="size-4" spark={false} />
|
|
16
|
+
</span>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function Reasoning({ text }: { text: string }) {
|
|
21
|
+
const [open, setOpen] = React.useState(false);
|
|
22
|
+
return (
|
|
23
|
+
<div className="mb-2">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={() => setOpen((o) => !o)}
|
|
27
|
+
className="inline-flex items-center gap-1 text-[12px] text-faint transition-colors hover:text-muted-foreground"
|
|
28
|
+
>
|
|
29
|
+
<ChevronRight className={cn("size-3 transition-transform", open && "rotate-90")} /> Reasoning
|
|
30
|
+
</button>
|
|
31
|
+
{open && (
|
|
32
|
+
<div className="mt-1.5 border-l-2 border-border/60 pl-3 text-[12px] italic leading-relaxed text-faint">{text}</div>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Message({ turn, userInitial = "U" }: { turn: ChatTurn; userInitial?: string }) {
|
|
39
|
+
if (turn.kind === "system") {
|
|
40
|
+
// Failed turns get the full actionable treatment, never a raw trace.
|
|
41
|
+
if (turn.error) return <ErrorCard turn={turn} />;
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex justify-center py-0.5">
|
|
44
|
+
<span className="rounded-full border border-border bg-surface-2/60 px-3 py-1 text-[11.5px] text-faint">{turn.text}</span>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isUser = turn.kind === "user";
|
|
50
|
+
const imageCount = typeof turn.meta?.imageCount === "number" ? (turn.meta.imageCount as number) : 0;
|
|
51
|
+
|
|
52
|
+
// User turns: a quiet inset block, visually distinct but not a loud bubble.
|
|
53
|
+
if (isUser) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-col items-end gap-1">
|
|
56
|
+
<div className="text-[11px] font-medium uppercase tracking-wider text-faint">You</div>
|
|
57
|
+
<div className="max-w-[85%] rounded-lg rounded-tr-sm border border-border bg-surface-2 px-3.5 py-2.5 shadow-sheen">
|
|
58
|
+
{turn.text && <p className="max-w-[78ch] whitespace-pre-wrap break-words text-[13.5px] leading-relaxed text-foreground">{turn.text}</p>}
|
|
59
|
+
{imageCount > 0 && (
|
|
60
|
+
<p className="mt-1 text-[12px] text-faint">+ {imageCount} image{imageCount === 1 ? "" : "s"}</p>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Agent turns: prose is the content — full-width, comfortable measure.
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex gap-3">
|
|
70
|
+
<div className="pt-0.5">
|
|
71
|
+
<AgentGlyph />
|
|
72
|
+
</div>
|
|
73
|
+
<div className="min-w-0 flex-1">
|
|
74
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-faint">Glorp</div>
|
|
75
|
+
{turn.reasoning && <Reasoning text={turn.reasoning} />}
|
|
76
|
+
{turn.text && <Md className="max-w-[78ch]">{turn.text}</Md>}
|
|
77
|
+
{imageCount > 0 && (
|
|
78
|
+
<p className="mt-1 text-[12px] text-faint">+ {imageCount} image{imageCount === 1 ? "" : "s"}</p>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The in-progress assistant message (streaming text), with a blinking caret. */
|
|
86
|
+
export function StreamingMessage({ text }: { text: string }) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex gap-3">
|
|
89
|
+
<div className="pt-0.5">
|
|
90
|
+
<AgentGlyph />
|
|
91
|
+
</div>
|
|
92
|
+
<div className="min-w-0 flex-1">
|
|
93
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-faint">Glorp</div>
|
|
94
|
+
<Md className="max-w-[78ch]">{text}</Md>
|
|
95
|
+
<span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-0.5 animate-caret-blink bg-brand align-middle" />
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ShieldAlert } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import type { DisplaySlot } from "@/lib/types";
|
|
6
|
+
|
|
7
|
+
function describe(input: unknown): string {
|
|
8
|
+
if (input && typeof input === "object") {
|
|
9
|
+
const o = input as Record<string, unknown>;
|
|
10
|
+
for (const k of ["title", "tool", "name", "command", "summary", "message"]) {
|
|
11
|
+
if (typeof o[k] === "string") return o[k] as string;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return "The agent is requesting permission to run a tool.";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Inline approval card for a pending permission request — blocks the agent,
|
|
18
|
+
* so it reads urgent (warning-tinted) but composed. */
|
|
19
|
+
export function PermissionPrompt({
|
|
20
|
+
slot,
|
|
21
|
+
onResolve,
|
|
22
|
+
}: {
|
|
23
|
+
slot: DisplaySlot;
|
|
24
|
+
onResolve: (slotId: string, allow: boolean) => void;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 shadow-sheen">
|
|
28
|
+
<span className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md border border-warning/30 bg-warning/10 text-warning">
|
|
29
|
+
<ShieldAlert className="size-3.5" />
|
|
30
|
+
</span>
|
|
31
|
+
<div className="min-w-0 flex-1">
|
|
32
|
+
<p className="text-[12.5px] font-semibold text-foreground">Permission requested</p>
|
|
33
|
+
<p className="mt-0.5 truncate font-mono text-[12px] text-muted-foreground">{describe(slot.input)}</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
36
|
+
<Button size="sm" variant="ghost" onClick={() => onResolve(slot.slotId, false)}>
|
|
37
|
+
Deny
|
|
38
|
+
</Button>
|
|
39
|
+
<Button size="sm" onClick={() => onResolve(slot.slotId, true)}>
|
|
40
|
+
Allow
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SlashSquare } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
export interface SlashCommand {
|
|
7
|
+
name: string; // "/compact"
|
|
8
|
+
description: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Autocomplete popover for "/" commands, anchored above the composer.
|
|
12
|
+
* Pure presentation — the composer owns filtering and keyboard state. */
|
|
13
|
+
export function SlashMenu({
|
|
14
|
+
commands,
|
|
15
|
+
activeIndex,
|
|
16
|
+
onPick,
|
|
17
|
+
}: {
|
|
18
|
+
commands: SlashCommand[];
|
|
19
|
+
activeIndex: number;
|
|
20
|
+
onPick: (cmd: SlashCommand) => void;
|
|
21
|
+
}) {
|
|
22
|
+
if (commands.length === 0) return null;
|
|
23
|
+
return (
|
|
24
|
+
<div className="absolute bottom-full left-0 z-30 mb-2 w-full max-w-sm animate-pop-in rounded-lg border border-border bg-popover p-1 shadow-elevated">
|
|
25
|
+
<div className="px-2 pb-1 pt-1.5 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-faint">Commands</div>
|
|
26
|
+
<div className="max-h-56 overflow-y-auto">
|
|
27
|
+
{commands.map((cmd, i) => (
|
|
28
|
+
<button
|
|
29
|
+
key={cmd.name}
|
|
30
|
+
type="button"
|
|
31
|
+
// preventDefault so the textarea keeps focus through the click
|
|
32
|
+
onMouseDown={(e) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
onPick(cmd);
|
|
35
|
+
}}
|
|
36
|
+
className={cn(
|
|
37
|
+
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors",
|
|
38
|
+
i === activeIndex ? "bg-surface-2 text-foreground" : "text-muted-foreground hover:bg-surface-2/60",
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<SlashSquare className={cn("size-3.5 shrink-0", i === activeIndex ? "text-brand" : "text-faint")} />
|
|
42
|
+
<span className="font-mono text-[12.5px] text-foreground">{cmd.name}</span>
|
|
43
|
+
<span className="min-w-0 flex-1 truncate text-[12px] text-faint">{cmd.description}</span>
|
|
44
|
+
</button>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="border-t border-border/60 px-2 py-1 text-[10.5px] text-faint">
|
|
48
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 font-mono text-[9px]">↑↓</kbd> navigate ·{" "}
|
|
49
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 font-mono text-[9px]">⇥</kbd> complete ·{" "}
|
|
50
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 font-mono text-[9px]">esc</kbd> dismiss
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Circle } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { EmptyState } from "@/components/shared";
|
|
6
|
+
import { ListChecks } from "lucide-react";
|
|
7
|
+
import type { TaskItem } from "@/lib/types";
|
|
8
|
+
|
|
9
|
+
/** Status glyph: pending faint circle, in_progress brand pulse, completed check. */
|
|
10
|
+
function Glyph({ status }: { status: TaskItem["status"] }) {
|
|
11
|
+
if (status === "completed") return <Check className="size-3.5 text-success" />;
|
|
12
|
+
if (status === "in_progress")
|
|
13
|
+
return (
|
|
14
|
+
<span className="relative grid size-3.5 place-items-center">
|
|
15
|
+
<span className="absolute size-2 rounded-full bg-brand opacity-60 animate-pulse-ring" />
|
|
16
|
+
<span className="relative size-2 rounded-full bg-brand" />
|
|
17
|
+
</span>
|
|
18
|
+
);
|
|
19
|
+
return <Circle className="size-3.5 text-faint" />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function TaskRow({ task }: { task: TaskItem }) {
|
|
23
|
+
const done = task.status === "completed";
|
|
24
|
+
const active = task.status === "in_progress";
|
|
25
|
+
return (
|
|
26
|
+
<li className="flex items-start gap-2.5 py-1.5">
|
|
27
|
+
<span className="mt-px shrink-0">
|
|
28
|
+
<Glyph status={task.status} />
|
|
29
|
+
</span>
|
|
30
|
+
<span
|
|
31
|
+
className={cn(
|
|
32
|
+
"text-[12.5px] leading-snug",
|
|
33
|
+
done ? "text-faint line-through" : active ? "font-medium text-foreground" : "text-muted-foreground",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{task.content}
|
|
37
|
+
</span>
|
|
38
|
+
</li>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Renders the agent's task checklist. `compact` drops the empty state entirely
|
|
43
|
+
* so the host (the inspector) can let the Tasks section emerge with content. */
|
|
44
|
+
export function TaskList({ tasks, compact = false }: { tasks: TaskItem[]; compact?: boolean }) {
|
|
45
|
+
if (tasks.length === 0) {
|
|
46
|
+
if (compact) return null;
|
|
47
|
+
return <EmptyState icon={ListChecks} title="No tasks yet" description="When the agent plans its work, the checklist appears here." />;
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<ul className="divide-y divide-border/60">
|
|
51
|
+
{tasks.map((t) => (
|
|
52
|
+
<TaskRow key={t.id} task={t} />
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronRight, FileText, Hammer, Pencil, Search, Terminal } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { ToolEvent } from "@/lib/types";
|
|
7
|
+
|
|
8
|
+
function iconFor(name: string) {
|
|
9
|
+
const n = name.toLowerCase();
|
|
10
|
+
if (/bash|shell|exec|terminal|run/.test(n)) return Terminal;
|
|
11
|
+
if (/edit|write|str_replace|create|patch/.test(n)) return Pencil;
|
|
12
|
+
if (/read|cat|open|file|view/.test(n)) return FileText;
|
|
13
|
+
if (/grep|search|find|glob|ls/.test(n)) return Search;
|
|
14
|
+
return Hammer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A short, human summary of the tool's input for the collapsed header. */
|
|
18
|
+
function summarize(input: unknown): string {
|
|
19
|
+
if (input == null) return "";
|
|
20
|
+
if (typeof input === "string") return input;
|
|
21
|
+
if (typeof input === "object") {
|
|
22
|
+
const o = input as Record<string, unknown>;
|
|
23
|
+
for (const k of ["command", "cmd", "file_path", "path", "pattern", "query", "url", "name", "title"]) {
|
|
24
|
+
if (typeof o[k] === "string") return o[k] as string;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(o);
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return String(input);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Status reads as a single semantic dot — quiet machinery, not a loud icon.
|
|
36
|
+
const STATUS: Record<ToolEvent["status"], { dot: string; pulse?: boolean }> = {
|
|
37
|
+
running: { dot: "bg-warning", pulse: true },
|
|
38
|
+
success: { dot: "bg-success" },
|
|
39
|
+
error: { dot: "bg-destructive" },
|
|
40
|
+
aborted: { dot: "bg-faint" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function StatusDot({ status }: { status: ToolEvent["status"] }) {
|
|
44
|
+
const s = STATUS[status];
|
|
45
|
+
return (
|
|
46
|
+
<span className="relative grid size-2 place-items-center">
|
|
47
|
+
{s.pulse && <span className={cn("absolute size-2 rounded-full opacity-60 animate-pulse-ring", s.dot)} />}
|
|
48
|
+
<span className={cn("relative size-2 rounded-full", s.dot)} />
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ToolCall({ tool }: { tool: ToolEvent }) {
|
|
54
|
+
const [open, setOpen] = React.useState(false);
|
|
55
|
+
const Icon = iconFor(tool.name);
|
|
56
|
+
const summary = summarize(tool.input);
|
|
57
|
+
const hasDetail = Boolean(summary) || Boolean(tool.output);
|
|
58
|
+
const hasInputObj = typeof tool.input === "object" && tool.input !== null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="ml-10 overflow-hidden rounded-md border border-border bg-surface-2/40">
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => hasDetail && setOpen((o) => !o)}
|
|
65
|
+
className={cn("flex w-full items-center gap-2 px-2.5 py-1.5 text-left", hasDetail && "transition-colors hover:bg-surface-2")}
|
|
66
|
+
>
|
|
67
|
+
<ChevronRight className={cn("size-3 shrink-0 text-faint transition-transform", open && "rotate-90", !hasDetail && "opacity-0")} />
|
|
68
|
+
<Icon className="size-3 shrink-0 text-faint" />
|
|
69
|
+
<span className="shrink-0 font-mono text-[12px] font-medium text-muted-foreground">{tool.name}</span>
|
|
70
|
+
{summary && <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-faint">{summary}</span>}
|
|
71
|
+
<span className="ml-auto shrink-0 pl-1.5">
|
|
72
|
+
<StatusDot status={tool.status} />
|
|
73
|
+
</span>
|
|
74
|
+
</button>
|
|
75
|
+
{open && (
|
|
76
|
+
<div className="space-y-2 border-t border-border/60 px-2.5 py-2.5">
|
|
77
|
+
{hasInputObj && (
|
|
78
|
+
<pre className="overflow-x-auto rounded-md border border-border bg-background p-2.5 font-mono text-[11.5px] leading-relaxed text-faint">
|
|
79
|
+
{JSON.stringify(tool.input, null, 2)}
|
|
80
|
+
</pre>
|
|
81
|
+
)}
|
|
82
|
+
{tool.output && (
|
|
83
|
+
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-2.5 font-mono text-[11.5px] leading-relaxed text-muted-foreground">
|
|
84
|
+
{tool.output}
|
|
85
|
+
</pre>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ChevronRight, FolderGit2, MessageSquare } from "lucide-react";
|
|
5
|
+
import { baseName, timeAgo, compact } from "@/lib/format";
|
|
6
|
+
import { SessionStatus, EmptyState } from "@/components/shared";
|
|
7
|
+
import { SectionHeading } from "@/components/primitives";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import type { SessionDto } from "@/lib/types";
|
|
10
|
+
|
|
11
|
+
const LANE_CAP = 6;
|
|
12
|
+
const RANK: Record<string, number> = { busy: 0, provisioning: 1, error: 2, idle: 3, destroyed: 4 };
|
|
13
|
+
|
|
14
|
+
function rank(s: SessionDto): number {
|
|
15
|
+
return RANK[s.state] ?? 3;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A single agent session, dense but legible — the unit of the fleet. */
|
|
19
|
+
function SessionRow({ s }: { s: SessionDto }) {
|
|
20
|
+
const tokens = (s.tokens_in ?? 0) + (s.tokens_out ?? 0);
|
|
21
|
+
return (
|
|
22
|
+
<Link
|
|
23
|
+
href={`/sessions/${s.id}`}
|
|
24
|
+
className="group flex items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-surface-2"
|
|
25
|
+
>
|
|
26
|
+
<SessionStatus state={s.state} className="w-[104px] shrink-0" />
|
|
27
|
+
<div className="min-w-0 flex-1">
|
|
28
|
+
<div className="truncate text-[13.5px] text-foreground">{s.title ?? "Untitled session"}</div>
|
|
29
|
+
<div className="truncate text-[11.5px] text-faint">{s.model_label ?? "Default model"}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<span className="tnum hidden w-16 shrink-0 text-right text-[12px] text-muted-foreground sm:block">{compact(tokens)} tok</span>
|
|
32
|
+
<span className="tnum hidden w-12 shrink-0 text-right text-[12px] text-faint md:block">{s.turn_count} {s.turn_count === 1 ? "turn" : "turns"}</span>
|
|
33
|
+
<span className="tnum w-12 shrink-0 text-right text-[12px] text-faint">{timeAgo(s.last_activity)}</span>
|
|
34
|
+
<ChevronRight className="size-4 shrink-0 text-faint/60 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
|
35
|
+
</Link>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One workspace's sessions, headed by the workspace name + a live count. */
|
|
40
|
+
function WorkspaceLane({ name, sessions }: { name: string; sessions: SessionDto[] }) {
|
|
41
|
+
const running = sessions.filter((s) => s.state === "busy" || s.state === "provisioning").length;
|
|
42
|
+
const shown = sessions.slice(0, LANE_CAP);
|
|
43
|
+
const overflow = sessions.length - shown.length;
|
|
44
|
+
return (
|
|
45
|
+
<div className="surface overflow-hidden">
|
|
46
|
+
<div className="flex items-center gap-2 border-b border-border/70 bg-surface-2/40 px-3.5 py-2">
|
|
47
|
+
<FolderGit2 className="size-3.5 text-faint" />
|
|
48
|
+
<span className="text-[12.5px] font-medium text-foreground">{name}</span>
|
|
49
|
+
{running > 0 && (
|
|
50
|
+
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium text-success">
|
|
51
|
+
<span className="size-1.5 rounded-full bg-success" />
|
|
52
|
+
{running} running
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
<span className="ml-auto text-[11.5px] text-faint">{sessions.length} {sessions.length === 1 ? "session" : "sessions"}</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="divide-y divide-border/60">
|
|
58
|
+
{shown.map((s) => (
|
|
59
|
+
<SessionRow key={s.id} s={s} />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
{overflow > 0 && (
|
|
63
|
+
<Link href="/sessions" className="block border-t border-border/60 px-3.5 py-2 text-[12px] text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground">
|
|
64
|
+
+{overflow} more in {name} →
|
|
65
|
+
</Link>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The fleet: active + idle sessions, grouped by workspace, busiest lane first. */
|
|
72
|
+
export function ActiveSessions({ sessions, className }: { sessions: SessionDto[]; className?: string }) {
|
|
73
|
+
const live = sessions.filter((s) => s.state !== "destroyed");
|
|
74
|
+
const lanes = new Map<string, SessionDto[]>();
|
|
75
|
+
for (const s of live) {
|
|
76
|
+
const key = baseName(s.workspace);
|
|
77
|
+
(lanes.get(key) ?? lanes.set(key, []).get(key)!).push(s);
|
|
78
|
+
}
|
|
79
|
+
const ordered = [...lanes.entries()]
|
|
80
|
+
.map(([name, list]) => ({ name, list: [...list].sort((a, b) => rank(a) - rank(b) || b.last_activity.localeCompare(a.last_activity)) }))
|
|
81
|
+
.sort((a, b) => rank(a.list[0]) - rank(b.list[0]) || b.list[0].last_activity.localeCompare(a.list[0].last_activity));
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<section className={cn("animate-slide-up", className)}>
|
|
85
|
+
<SectionHeading
|
|
86
|
+
eyebrow="Fleet"
|
|
87
|
+
title="Active sessions"
|
|
88
|
+
action={
|
|
89
|
+
<Link href="/sessions" className="inline-flex items-center gap-1 text-[12.5px] text-muted-foreground transition-colors hover:text-foreground">
|
|
90
|
+
All sessions <ChevronRight className="size-3.5" />
|
|
91
|
+
</Link>
|
|
92
|
+
}
|
|
93
|
+
/>
|
|
94
|
+
{ordered.length === 0 ? (
|
|
95
|
+
<div className="surface">
|
|
96
|
+
<EmptyState icon={MessageSquare} title="No sessions running" description="Launch a task above and it will appear here, grouped by the workspace it runs in." />
|
|
97
|
+
</div>
|
|
98
|
+
) : (
|
|
99
|
+
<div className="grid gap-3 lg:grid-cols-2">
|
|
100
|
+
{ordered.map((lane) => (
|
|
101
|
+
<WorkspaceLane key={lane.name} name={lane.name} sessions={lane.list} />
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</section>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Cpu, FolderGit2, SendHorizontal } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { launchSession } from "@/lib/launch";
|
|
8
|
+
import { Spinner } from "@/components/shared";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
11
|
+
import type { WorkspaceDto, ProfileDto } from "@/lib/types";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_WS = "__default__";
|
|
14
|
+
const DEFAULT_MODEL = "__default__";
|
|
15
|
+
|
|
16
|
+
/** The hero: describe a task, pick where it runs, launch an agent. */
|
|
17
|
+
export function LaunchComposer({ workspaces, profiles, initialPrompt = "", defaultModelLabel }: { workspaces: WorkspaceDto[]; profiles: ProfileDto[]; initialPrompt?: string; defaultModelLabel?: string | null }) {
|
|
18
|
+
const router = useRouter();
|
|
19
|
+
const [prompt, setPrompt] = useState(initialPrompt);
|
|
20
|
+
const [ws, setWs] = useState(DEFAULT_WS);
|
|
21
|
+
const [profile, setProfile] = useState(DEFAULT_MODEL);
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
|
|
24
|
+
const launch = async () => {
|
|
25
|
+
if (!prompt.trim() || busy) return;
|
|
26
|
+
setBusy(true);
|
|
27
|
+
try {
|
|
28
|
+
const id = await launchSession({
|
|
29
|
+
prompt,
|
|
30
|
+
workspaceId: ws === DEFAULT_WS ? undefined : ws,
|
|
31
|
+
profileId: profile === DEFAULT_MODEL ? undefined : profile,
|
|
32
|
+
});
|
|
33
|
+
router.push(`/sessions/${id}`);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
toast.error(e instanceof Error ? e.message : "Could not start the session");
|
|
36
|
+
setBusy(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<section>
|
|
42
|
+
<h1 className="text-display text-center">What should Glorp build?</h1>
|
|
43
|
+
<p className="mx-auto mt-2.5 max-w-md text-center text-[13.5px] leading-relaxed text-muted-foreground">
|
|
44
|
+
Describe a task to launch an agent in a sandboxed workspace, or jump back into a running session below.
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<div className="group mt-7 rounded-xl border border-border bg-card p-2.5 shadow-card transition-shadow focus-within:border-brand/40 focus-within:shadow-glow">
|
|
48
|
+
<textarea
|
|
49
|
+
value={prompt}
|
|
50
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
51
|
+
onKeyDown={(e) => {
|
|
52
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) launch();
|
|
53
|
+
}}
|
|
54
|
+
placeholder="e.g. Add rate limiting to the API routes and write a test for it."
|
|
55
|
+
className="min-h-[88px] w-full resize-none bg-transparent px-2.5 py-2 text-[14px] leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/60"
|
|
56
|
+
/>
|
|
57
|
+
<div className="flex items-center justify-between gap-2 border-t border-border/70 pt-2.5">
|
|
58
|
+
<div className="flex items-center gap-2">
|
|
59
|
+
<Select value={ws} onValueChange={setWs}>
|
|
60
|
+
<SelectTrigger className="h-8 w-[188px] text-[13px]">
|
|
61
|
+
<FolderGit2 className="size-3.5 shrink-0 text-faint" />
|
|
62
|
+
<SelectValue />
|
|
63
|
+
</SelectTrigger>
|
|
64
|
+
<SelectContent>
|
|
65
|
+
<SelectItem value={DEFAULT_WS}>Default workspace</SelectItem>
|
|
66
|
+
{workspaces.map((w) => (
|
|
67
|
+
<SelectItem key={w.id} value={w.id}>
|
|
68
|
+
{w.name}
|
|
69
|
+
</SelectItem>
|
|
70
|
+
))}
|
|
71
|
+
</SelectContent>
|
|
72
|
+
</Select>
|
|
73
|
+
<Select value={profile} onValueChange={setProfile}>
|
|
74
|
+
<SelectTrigger className="h-8 w-[168px] text-[13px]">
|
|
75
|
+
<Cpu className="size-3.5 shrink-0 text-faint" />
|
|
76
|
+
<SelectValue />
|
|
77
|
+
</SelectTrigger>
|
|
78
|
+
<SelectContent>
|
|
79
|
+
<SelectItem value={DEFAULT_MODEL}>{defaultModelLabel ? `Default — ${defaultModelLabel}` : "Default model"}</SelectItem>
|
|
80
|
+
{profiles.map((p) => (
|
|
81
|
+
<SelectItem key={p.id} value={p.id}>
|
|
82
|
+
{p.label}
|
|
83
|
+
</SelectItem>
|
|
84
|
+
))}
|
|
85
|
+
</SelectContent>
|
|
86
|
+
</Select>
|
|
87
|
+
</div>
|
|
88
|
+
<Button onClick={launch} disabled={busy || !prompt.trim()}>
|
|
89
|
+
{busy ? <Spinner /> : <SendHorizontal />} Launch
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<p className="mt-2.5 text-center text-[11.5px] text-faint">
|
|
94
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[10px]">⌘</kbd>{" "}
|
|
95
|
+
<kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[10px]">↵</kbd> to launch
|
|
96
|
+
</p>
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|