@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,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Bot, Plus } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Badge } from "@/components/ui/badge";
|
|
9
|
+
import { EmptyState, ConfirmButton } from "@/components/shared";
|
|
10
|
+
import type { AgentInfo } from "@/lib/types";
|
|
11
|
+
|
|
12
|
+
/** Inline composer to enlist a specialist — shared by the collapsed + full views. */
|
|
13
|
+
function AddSpecialist({ onAdd }: { onAdd: (role: string) => void }) {
|
|
14
|
+
const [role, setRole] = React.useState("");
|
|
15
|
+
const add = () => {
|
|
16
|
+
if (!role.trim()) return;
|
|
17
|
+
onAdd(role.trim());
|
|
18
|
+
setRole("");
|
|
19
|
+
};
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-2">
|
|
22
|
+
<Input
|
|
23
|
+
autoFocus
|
|
24
|
+
value={role}
|
|
25
|
+
onChange={(e) => setRole(e.target.value)}
|
|
26
|
+
onKeyDown={(e) => e.key === "Enter" && add()}
|
|
27
|
+
placeholder="Add a specialist…"
|
|
28
|
+
className="h-8 flex-1 text-[12.5px]"
|
|
29
|
+
/>
|
|
30
|
+
<Button variant="secondary" size="sm" onClick={add} disabled={!role.trim()} className="shrink-0">
|
|
31
|
+
<Plus /> Add
|
|
32
|
+
</Button>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Avatar + name + role/turns + active/working badges. */
|
|
38
|
+
function AgentRow({ a, active, onSwitch, onRemove }: { a: AgentInfo; active: boolean; onSwitch: () => void; onRemove: () => void }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn("flex items-center gap-2.5 px-3 py-2.5", active && "bg-surface-2/60")}>
|
|
41
|
+
<span className="grid size-7 shrink-0 place-items-center rounded-full border border-border bg-surface-2 text-faint shadow-sheen">
|
|
42
|
+
<Bot className="size-3.5" />
|
|
43
|
+
</span>
|
|
44
|
+
<div className="min-w-0 flex-1">
|
|
45
|
+
<div className="flex items-center gap-1.5">
|
|
46
|
+
<span className="truncate text-[12.5px] font-medium text-foreground">{a.label}</span>
|
|
47
|
+
{active && <Badge variant="brand">active</Badge>}
|
|
48
|
+
{a.busy && <Badge variant="warning">working</Badge>}
|
|
49
|
+
</div>
|
|
50
|
+
<div className="truncate text-[11.5px] text-faint">
|
|
51
|
+
{a.role} · {a.turnCount} turn{a.turnCount === 1 ? "" : "s"}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{!active && (
|
|
55
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
56
|
+
<Button variant="ghost" size="sm" onClick={onSwitch}>
|
|
57
|
+
Make active
|
|
58
|
+
</Button>
|
|
59
|
+
<ConfirmButton label="Remove" onConfirm={onRemove} />
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The multi-agent roster for a session — add specialists, switch, retire.
|
|
67
|
+
* A lone default agent collapses to one quiet row + a "+ Add specialist"
|
|
68
|
+
* reveal; the full roster emerges with 2+ agents or once the user opts in. */
|
|
69
|
+
export function AgentRoster({
|
|
70
|
+
agents,
|
|
71
|
+
activeId,
|
|
72
|
+
onSwitch,
|
|
73
|
+
onAdd,
|
|
74
|
+
onRemove,
|
|
75
|
+
}: {
|
|
76
|
+
agents: AgentInfo[];
|
|
77
|
+
activeId: string | null;
|
|
78
|
+
onSwitch: (id: string) => void;
|
|
79
|
+
onAdd: (role: string) => void;
|
|
80
|
+
onRemove: (id: string) => void;
|
|
81
|
+
}) {
|
|
82
|
+
const [opened, setOpened] = React.useState(false);
|
|
83
|
+
|
|
84
|
+
if (agents.length === 0) {
|
|
85
|
+
return <EmptyState icon={Bot} title="No agents yet" description="A session starts with a single agent; add specialists to delegate work." className="py-8" />;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Lone agent: stay compact until the user reaches for a specialist.
|
|
89
|
+
if (agents.length === 1 && !opened) {
|
|
90
|
+
const a = agents[0];
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex items-center justify-between gap-3">
|
|
93
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
94
|
+
<span className="relative grid size-2 shrink-0 place-items-center">
|
|
95
|
+
<span className="absolute size-2 rounded-full bg-success opacity-60 animate-pulse-ring" />
|
|
96
|
+
<span className="relative size-2 rounded-full bg-success" />
|
|
97
|
+
</span>
|
|
98
|
+
<span className="truncate text-[12.5px] font-medium text-foreground">{a.label}</span>
|
|
99
|
+
</div>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => setOpened(true)}
|
|
103
|
+
className="shrink-0 text-[11.5px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
104
|
+
>
|
|
105
|
+
+ Add specialist
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-3">
|
|
113
|
+
<div className="surface divide-y divide-border/60 overflow-hidden">
|
|
114
|
+
{agents.map((a) => (
|
|
115
|
+
<AgentRow key={a.id} a={a} active={a.id === activeId} onSwitch={() => onSwitch(a.id)} onRemove={() => onRemove(a.id)} />
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
<AddSpecialist onAdd={onAdd} />
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Download, FileUp, Paperclip, Trash2 } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { api, API_BASE, getNamespace, getToken } from "@/lib/api";
|
|
7
|
+
import { useQuery } from "@/lib/hooks";
|
|
8
|
+
import { compact, timeAgo } from "@/lib/format";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
import { FilesRemote } from "./files-remote";
|
|
11
|
+
import type { FilesRemoteStatus } from "@/lib/types";
|
|
12
|
+
|
|
13
|
+
interface FileEntry {
|
|
14
|
+
path: string;
|
|
15
|
+
size: number;
|
|
16
|
+
modified_at: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FileListResponse {
|
|
20
|
+
files: FileEntry[];
|
|
21
|
+
remote?: FilesRemoteStatus;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Authenticated direct-download URL (bearer rides as a query param). */
|
|
25
|
+
function downloadUrl(sessionId: string, rel: string): string {
|
|
26
|
+
const u = new URL(`${API_BASE}/sessions/${sessionId}/files/${rel.split("/").map(encodeURIComponent).join("/")}`);
|
|
27
|
+
const token = getToken();
|
|
28
|
+
if (token) u.searchParams.set("api_key", token);
|
|
29
|
+
const ns = getNamespace();
|
|
30
|
+
if (ns) u.searchParams.set("ns", ns);
|
|
31
|
+
return u.toString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The session's file exchange — a per-session `uploads/` folder shared with
|
|
36
|
+
* the agent. Drop documents in as inputs; download whatever the agent leaves
|
|
37
|
+
* there as deliverables. Refreshes when a turn completes.
|
|
38
|
+
*/
|
|
39
|
+
export function FilesPanel({ sessionId, refresh }: { sessionId: string; refresh?: boolean }) {
|
|
40
|
+
const files = useQuery<FileListResponse>(`/sessions/${sessionId}/files`, [refresh]);
|
|
41
|
+
const [dragging, setDragging] = React.useState(false);
|
|
42
|
+
const [busy, setBusy] = React.useState(false);
|
|
43
|
+
const [pulling, setPulling] = React.useState(false);
|
|
44
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
45
|
+
|
|
46
|
+
// Force a remote rehydrate, then refresh the list with the pulled files.
|
|
47
|
+
const pull = async () => {
|
|
48
|
+
setPulling(true);
|
|
49
|
+
try {
|
|
50
|
+
await api(`/sessions/${sessionId}/files?pull=1`);
|
|
51
|
+
files.reload();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
toast.error(e instanceof Error ? e.message : "Pull failed");
|
|
54
|
+
} finally {
|
|
55
|
+
setPulling(false);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const upload = async (list: Iterable<File>) => {
|
|
60
|
+
const form = new FormData();
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const f of list) {
|
|
63
|
+
form.append(`file_${n++}`, f, f.name);
|
|
64
|
+
}
|
|
65
|
+
if (n === 0) return;
|
|
66
|
+
setBusy(true);
|
|
67
|
+
try {
|
|
68
|
+
const headers: Record<string, string> = {};
|
|
69
|
+
const token = getToken();
|
|
70
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
71
|
+
const ns = getNamespace();
|
|
72
|
+
if (ns) headers["x-glorp-namespace"] = ns;
|
|
73
|
+
const res = await fetch(`${API_BASE}/sessions/${sessionId}/files`, { method: "POST", headers, body: form });
|
|
74
|
+
if (!res.ok) throw new Error((await res.json().catch(() => null))?.message ?? `Upload failed (${res.status})`);
|
|
75
|
+
toast.success(n === 1 ? "File uploaded" : `${n} files uploaded`);
|
|
76
|
+
files.reload();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
toast.error(e instanceof Error ? e.message : "Upload failed");
|
|
79
|
+
} finally {
|
|
80
|
+
setBusy(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const remove = async (rel: string) => {
|
|
85
|
+
try {
|
|
86
|
+
await api(`/sessions/${sessionId}/files/${rel.split("/").map(encodeURIComponent).join("/")}`, { method: "DELETE" });
|
|
87
|
+
files.reload();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
toast.error(e instanceof Error ? e.message : "Delete failed");
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const list = files.data?.files ?? [];
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
className={cn("border-t border-border/60 px-4 py-3 transition-colors", dragging && "bg-brand/[0.06]")}
|
|
98
|
+
onDragOver={(e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
setDragging(true);
|
|
101
|
+
}}
|
|
102
|
+
onDragLeave={() => setDragging(false)}
|
|
103
|
+
onDrop={(e) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
setDragging(false);
|
|
106
|
+
void upload(e.dataTransfer.files);
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<div className="mb-2 flex items-center justify-between">
|
|
110
|
+
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-faint">Files</span>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => inputRef.current?.click()}
|
|
114
|
+
disabled={busy}
|
|
115
|
+
className="inline-flex items-center gap-1 text-[12px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
116
|
+
>
|
|
117
|
+
<FileUp className="size-3.5" /> Upload
|
|
118
|
+
</button>
|
|
119
|
+
<input
|
|
120
|
+
ref={inputRef}
|
|
121
|
+
type="file"
|
|
122
|
+
multiple
|
|
123
|
+
className="hidden"
|
|
124
|
+
onChange={(e) => {
|
|
125
|
+
if (e.target.files) void upload(e.target.files);
|
|
126
|
+
e.target.value = "";
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{files.data?.remote && <FilesRemote remote={files.data.remote} pulling={pulling} onPull={() => void pull()} />}
|
|
132
|
+
|
|
133
|
+
{list.length === 0 ? (
|
|
134
|
+
<p className="rounded-md border border-dashed border-border/70 px-3 py-2.5 text-[11.5px] leading-relaxed text-faint">
|
|
135
|
+
<Paperclip className="mr-1 inline size-3 align-[-2px]" />
|
|
136
|
+
Drop files here for the agent ("check uploads/…"), and download what it leaves behind.
|
|
137
|
+
</p>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="space-y-0.5">
|
|
140
|
+
{list.map((f) => (
|
|
141
|
+
<div key={f.path} className="group flex items-center gap-2 rounded-md px-1.5 py-1 transition-colors hover:bg-surface-2">
|
|
142
|
+
<div className="min-w-0 flex-1">
|
|
143
|
+
<div className="truncate font-mono text-[12px] text-foreground">{f.path}</div>
|
|
144
|
+
<div className="text-[10.5px] text-faint">
|
|
145
|
+
<span className="tnum">{compact(f.size)}B</span> · {timeAgo(f.modified_at)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<a
|
|
149
|
+
href={downloadUrl(sessionId, f.path)}
|
|
150
|
+
download
|
|
151
|
+
className="grid size-6 shrink-0 place-items-center rounded text-faint opacity-0 transition-all hover:text-foreground group-hover:opacity-100"
|
|
152
|
+
title="Download"
|
|
153
|
+
>
|
|
154
|
+
<Download className="size-3.5" />
|
|
155
|
+
</a>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => void remove(f.path)}
|
|
159
|
+
className="grid size-6 shrink-0 place-items-center rounded text-faint opacity-0 transition-all hover:text-destructive group-hover:opacity-100"
|
|
160
|
+
title="Delete"
|
|
161
|
+
>
|
|
162
|
+
<Trash2 className="size-3.5" />
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Cloud, CloudOff, DownloadCloud } from "lucide-react";
|
|
5
|
+
import { timeAgo } from "@/lib/format";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
|
|
8
|
+
import type { FilesRemoteStatus } from "@/lib/types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The quiet remote-mirror status line under the Files header: "R2 · synced 2m
|
|
12
|
+
* ago", with an error rendered in destructive tone (full text in a tooltip),
|
|
13
|
+
* and a Pull affordance that rehydrates remote-only files. Kept subtle — it's a
|
|
14
|
+
* side channel, not the main event.
|
|
15
|
+
*/
|
|
16
|
+
export function FilesRemote({
|
|
17
|
+
remote,
|
|
18
|
+
pulling,
|
|
19
|
+
onPull,
|
|
20
|
+
}: {
|
|
21
|
+
remote: FilesRemoteStatus;
|
|
22
|
+
pulling: boolean;
|
|
23
|
+
onPull: () => void;
|
|
24
|
+
}) {
|
|
25
|
+
if (!remote.enabled) return null;
|
|
26
|
+
return (
|
|
27
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
28
|
+
<Status remote={remote} />
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onPull}
|
|
32
|
+
disabled={pulling}
|
|
33
|
+
className="inline-flex items-center gap-1 text-[11.5px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
34
|
+
title="Download remote files missing locally"
|
|
35
|
+
>
|
|
36
|
+
<DownloadCloud className={cn("size-3.5", pulling && "animate-pulse")} /> Pull
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Status({ remote }: { remote: FilesRemoteStatus }) {
|
|
43
|
+
if (remote.error) {
|
|
44
|
+
return (
|
|
45
|
+
<TooltipProvider>
|
|
46
|
+
<Tooltip>
|
|
47
|
+
<TooltipTrigger asChild>
|
|
48
|
+
<span className="inline-flex items-center gap-1 truncate text-[11.5px] text-destructive">
|
|
49
|
+
<CloudOff className="size-3.5 shrink-0" /> Sync failed
|
|
50
|
+
</span>
|
|
51
|
+
</TooltipTrigger>
|
|
52
|
+
<TooltipContent className="max-w-[280px] break-words">{remote.error}</TooltipContent>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
</TooltipProvider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return (
|
|
58
|
+
<span className="inline-flex items-center gap-1 text-[11.5px] text-faint">
|
|
59
|
+
<Cloud className="size-3.5 shrink-0" />
|
|
60
|
+
{remote.last_sync_at ? <>R2 · synced {timeAgo(remote.last_sync_at)}</> : <>R2 · not synced yet</>}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { FolderGit2 } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { compact, timeAgo, baseName } from "@/lib/format";
|
|
7
|
+
import { CopyButton } from "@/components/shared";
|
|
8
|
+
import { TaskList } from "@/components/chat/task-list";
|
|
9
|
+
import { AgentRoster } from "@/components/session/agent-roster";
|
|
10
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
11
|
+
import type { SessionDto, SessionStats, TaskItem, AgentInfo } from "@/lib/types";
|
|
12
|
+
import { FilesPanel } from "./files-panel";
|
|
13
|
+
|
|
14
|
+
const MODE_LABEL: Record<string, string> = {
|
|
15
|
+
normal: "Normal — prompt for risky tools",
|
|
16
|
+
auto: "Auto — auto-approve",
|
|
17
|
+
bypass: "Bypass — no prompts",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A labeled section opened by the eyebrow idiom (11px uppercase tracking-wider).
|
|
21
|
+
* `emerge` plays the slide-up entrance for sections that appear with content. */
|
|
22
|
+
function Section({
|
|
23
|
+
eyebrow,
|
|
24
|
+
count,
|
|
25
|
+
emerge,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
eyebrow: string;
|
|
29
|
+
count?: React.ReactNode;
|
|
30
|
+
emerge?: boolean;
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<section className={cn("border-b border-border/60 px-4 py-4", emerge && "animate-slide-up")}>
|
|
35
|
+
<div className="mb-3 flex items-center justify-between">
|
|
36
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider text-faint">{eyebrow}</h3>
|
|
37
|
+
{count != null && <span className="tnum text-[11.5px] text-faint">{count}</span>}
|
|
38
|
+
</div>
|
|
39
|
+
{children}
|
|
40
|
+
</section>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A details row: quiet label left, tabular value right. */
|
|
45
|
+
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-baseline justify-between gap-3 py-1.5 text-[12.5px]">
|
|
48
|
+
<span className="shrink-0 text-muted-foreground">{label}</span>
|
|
49
|
+
<span className="tnum min-w-0 text-right text-foreground">{children}</span>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Thin context-usage meter — surface-2 track, brand fill, warning past ~80%. */
|
|
55
|
+
function ContextMeter({ pct }: { pct: number }) {
|
|
56
|
+
const v = Math.max(0, Math.min(100, pct));
|
|
57
|
+
const hot = v > 80;
|
|
58
|
+
return (
|
|
59
|
+
<div className="py-1.5">
|
|
60
|
+
<div className="flex items-baseline justify-between gap-3 text-[12.5px]">
|
|
61
|
+
<span className="text-muted-foreground">Context</span>
|
|
62
|
+
<span className={cn("tnum", hot ? "text-warning" : "text-foreground")}>{Math.round(v)}%</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full bg-surface-2">
|
|
65
|
+
<div className={cn("h-full rounded-full transition-all", hot ? "bg-warning" : "bg-brand")} style={{ width: `${v}%` }} />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Inspector({
|
|
72
|
+
session,
|
|
73
|
+
stats,
|
|
74
|
+
tasks,
|
|
75
|
+
agents,
|
|
76
|
+
activeAgentId,
|
|
77
|
+
mode,
|
|
78
|
+
busyRefresh,
|
|
79
|
+
onMode,
|
|
80
|
+
onSwitchAgent,
|
|
81
|
+
onAddAgent,
|
|
82
|
+
onRemoveAgent,
|
|
83
|
+
}: {
|
|
84
|
+
session: SessionDto;
|
|
85
|
+
stats: SessionStats | null;
|
|
86
|
+
tasks: TaskItem[];
|
|
87
|
+
agents: AgentInfo[];
|
|
88
|
+
activeAgentId: string | null;
|
|
89
|
+
mode: string;
|
|
90
|
+
/** Toggles when the agent's busy state flips — the files panel refetches on
|
|
91
|
+
* it so agent-written deliverables appear as each turn completes. */
|
|
92
|
+
busyRefresh?: boolean;
|
|
93
|
+
onMode: (m: string) => void;
|
|
94
|
+
onSwitchAgent: (id: string) => void;
|
|
95
|
+
onAddAgent: (role: string) => void;
|
|
96
|
+
onRemoveAgent: (id: string) => void;
|
|
97
|
+
}) {
|
|
98
|
+
const done = tasks.filter((t) => t.status === "completed").length;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="h-full overflow-y-auto">
|
|
102
|
+
{/* Tasks emerge with the first tracked item — never empty scaffolding. */}
|
|
103
|
+
{tasks.length > 0 && (
|
|
104
|
+
<Section eyebrow="Tasks" count={`${done}/${tasks.length}`} emerge>
|
|
105
|
+
<TaskList tasks={tasks} compact />
|
|
106
|
+
</Section>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{/* Count only once the full roster is in play; a lone agent stays quiet. */}
|
|
110
|
+
<Section eyebrow="Agents" count={agents.length > 1 ? agents.length : null}>
|
|
111
|
+
<AgentRoster agents={agents} activeId={activeAgentId} onSwitch={onSwitchAgent} onAdd={onAddAgent} onRemove={onRemoveAgent} />
|
|
112
|
+
</Section>
|
|
113
|
+
|
|
114
|
+
<Section eyebrow="Details">
|
|
115
|
+
<Row label="Workspace">
|
|
116
|
+
<span className="inline-flex items-center gap-1 font-mono text-[12px] text-muted-foreground" title={session.workspace}>
|
|
117
|
+
<FolderGit2 className="size-3 shrink-0" />
|
|
118
|
+
{baseName(session.workspace)}
|
|
119
|
+
</span>
|
|
120
|
+
</Row>
|
|
121
|
+
<div className="py-1.5">
|
|
122
|
+
<div className="mb-1.5 text-[12.5px] text-muted-foreground">Permission mode</div>
|
|
123
|
+
<Select value={mode} onValueChange={onMode}>
|
|
124
|
+
<SelectTrigger className="h-8 text-[12.5px]">
|
|
125
|
+
<SelectValue />
|
|
126
|
+
</SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
{Object.entries(MODE_LABEL).map(([v, l]) => (
|
|
129
|
+
<SelectItem key={v} value={v}>
|
|
130
|
+
{l}
|
|
131
|
+
</SelectItem>
|
|
132
|
+
))}
|
|
133
|
+
</SelectContent>
|
|
134
|
+
</Select>
|
|
135
|
+
</div>
|
|
136
|
+
<Row label="Turns">{stats ? stats.turns : session.turn_count}</Row>
|
|
137
|
+
<Row label="Tokens">{stats ? `${compact(stats.tokens_in)} in · ${compact(stats.tokens_out)} out` : "—"}</Row>
|
|
138
|
+
{stats && <ContextMeter pct={stats.contextPct} />}
|
|
139
|
+
<Row label="Last activity">{timeAgo(session.last_activity)}</Row>
|
|
140
|
+
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 shadow-sheen">
|
|
141
|
+
<code className="min-w-0 truncate font-mono text-[11.5px] text-faint">{session.id}</code>
|
|
142
|
+
<CopyButton value={session.id} />
|
|
143
|
+
</div>
|
|
144
|
+
</Section>
|
|
145
|
+
<FilesPanel sessionId={session.id} refresh={busyRefresh} />
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ChevronsUpDown, Cpu, Settings2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuLabel,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
} from "@/components/ui/dropdown-menu";
|
|
15
|
+
import type { ProfileWire } from "@/lib/types";
|
|
16
|
+
|
|
17
|
+
/** Inline model switcher for the composer — swaps the session's model live
|
|
18
|
+
* (the web equivalent of the TUI's model picker). */
|
|
19
|
+
export function ModelSwitcher({
|
|
20
|
+
profiles,
|
|
21
|
+
current,
|
|
22
|
+
onSwap,
|
|
23
|
+
}: {
|
|
24
|
+
profiles: ProfileWire[];
|
|
25
|
+
current: string | null;
|
|
26
|
+
onSwap: (profileId: string, label: string) => void;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<DropdownMenu>
|
|
30
|
+
<DropdownMenuTrigger asChild>
|
|
31
|
+
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2 text-muted-foreground hover:text-foreground">
|
|
32
|
+
<Cpu className="size-3.5" />
|
|
33
|
+
<span className="max-w-[200px] truncate">{current ?? "Default model"}</span>
|
|
34
|
+
<ChevronsUpDown className="size-3.5 opacity-70" />
|
|
35
|
+
</Button>
|
|
36
|
+
</DropdownMenuTrigger>
|
|
37
|
+
<DropdownMenuContent align="start" className="min-w-[240px]">
|
|
38
|
+
<DropdownMenuLabel>Model for this session</DropdownMenuLabel>
|
|
39
|
+
{profiles.length === 0 && <div className="px-2.5 py-1.5 text-[12.5px] text-muted-foreground">No profiles configured.</div>}
|
|
40
|
+
{profiles.map((p) => (
|
|
41
|
+
<DropdownMenuItem key={p.id} onClick={() => onSwap(p.id, p.label)} className="justify-between gap-3">
|
|
42
|
+
<span className="truncate">{p.label}</span>
|
|
43
|
+
{p.reasoning_label && p.reasoning_label !== "off" && (
|
|
44
|
+
<Badge variant="outline" className="shrink-0">
|
|
45
|
+
{p.reasoning_label}
|
|
46
|
+
</Badge>
|
|
47
|
+
)}
|
|
48
|
+
</DropdownMenuItem>
|
|
49
|
+
))}
|
|
50
|
+
<DropdownMenuSeparator />
|
|
51
|
+
<DropdownMenuItem asChild>
|
|
52
|
+
<Link href="/credentials">
|
|
53
|
+
<Settings2 /> Manage models…
|
|
54
|
+
</Link>
|
|
55
|
+
</DropdownMenuItem>
|
|
56
|
+
</DropdownMenuContent>
|
|
57
|
+
</DropdownMenu>
|
|
58
|
+
);
|
|
59
|
+
}
|