@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
package/lib/types.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** Lean DTO mirror of the Garage wire contract (src/garage/contract.ts). */
|
|
2
|
+
|
|
3
|
+
export type PermissionMode = "normal" | "auto" | "bypass";
|
|
4
|
+
export type SessionLifecycle = "provisioning" | "idle" | "busy" | "error" | "destroyed";
|
|
5
|
+
|
|
6
|
+
export interface NamespaceDto {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
created_at: string;
|
|
11
|
+
is_default: boolean;
|
|
12
|
+
session_count?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WorkspaceDto {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
created_at: string;
|
|
20
|
+
session_count: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionDto {
|
|
24
|
+
id: string;
|
|
25
|
+
state: SessionLifecycle;
|
|
26
|
+
workspace: string;
|
|
27
|
+
workspace_id: string | null;
|
|
28
|
+
title: string | null;
|
|
29
|
+
model_label: string | null;
|
|
30
|
+
permission_mode: PermissionMode;
|
|
31
|
+
created_at: string;
|
|
32
|
+
last_activity: string;
|
|
33
|
+
connected_clients: number;
|
|
34
|
+
busy: boolean;
|
|
35
|
+
loaded: boolean;
|
|
36
|
+
tokens_in: number;
|
|
37
|
+
tokens_out: number;
|
|
38
|
+
turn_count: number;
|
|
39
|
+
error: string | null;
|
|
40
|
+
custom_credentials: { provider: string; last4: string } | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentInfo {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
role: string;
|
|
47
|
+
active: boolean;
|
|
48
|
+
busy: boolean;
|
|
49
|
+
turnCount: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ApiKeyPublic {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
keyPrefix: string;
|
|
56
|
+
scopes: string[];
|
|
57
|
+
createdAt: string;
|
|
58
|
+
lastUsed: string | null;
|
|
59
|
+
expiresAt: string | null;
|
|
60
|
+
revoked: boolean;
|
|
61
|
+
namespace?: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProviderDto {
|
|
65
|
+
id: string;
|
|
66
|
+
type: "known" | "custom";
|
|
67
|
+
label?: string;
|
|
68
|
+
baseURL?: string;
|
|
69
|
+
hasKey?: boolean;
|
|
70
|
+
models?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProfileDto {
|
|
74
|
+
id: string;
|
|
75
|
+
label: string;
|
|
76
|
+
providerId: string;
|
|
77
|
+
model: string;
|
|
78
|
+
reasoning?: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Wire shape of a configured provider (src/garage/routes/models.ts#providerDto). */
|
|
82
|
+
export interface ProviderWire {
|
|
83
|
+
id: string;
|
|
84
|
+
type: "known" | "custom";
|
|
85
|
+
based_on: string | null;
|
|
86
|
+
adapter: string | null;
|
|
87
|
+
base_url: string | null;
|
|
88
|
+
context_limit: number | null;
|
|
89
|
+
has_api_key: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Wire shape of a model profile (src/garage/routes/models.ts#profileDto). */
|
|
93
|
+
export interface ProfileWire {
|
|
94
|
+
id: string;
|
|
95
|
+
label: string;
|
|
96
|
+
provider_id: string;
|
|
97
|
+
model: string;
|
|
98
|
+
reasoning?: unknown;
|
|
99
|
+
reasoning_label?: string;
|
|
100
|
+
context_limit?: number | null;
|
|
101
|
+
/** Input modalities from the catalog ("text", "image", …); null = unknown. */
|
|
102
|
+
input_modalities?: string[] | null;
|
|
103
|
+
last_used_at: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A known provider from GET /models/catalog — drives guided pickers. */
|
|
107
|
+
export interface CatalogProvider {
|
|
108
|
+
id: string;
|
|
109
|
+
label: string;
|
|
110
|
+
description: string;
|
|
111
|
+
env_var: string | null;
|
|
112
|
+
default_models: string[];
|
|
113
|
+
needs_api_key: boolean;
|
|
114
|
+
reasoning_capable: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A custom-endpoint adapter from GET /models/catalog. */
|
|
118
|
+
export interface CatalogAdapter {
|
|
119
|
+
id: string;
|
|
120
|
+
label: string;
|
|
121
|
+
description: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface Catalog {
|
|
125
|
+
providers: CatalogProvider[];
|
|
126
|
+
adapters: CatalogAdapter[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One reasoning/thinking choice from GET /models/reasoning-options. */
|
|
130
|
+
export interface ReasoningOption {
|
|
131
|
+
label: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
value: unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A single provisioning step (src/garage/templates/types.ts). */
|
|
137
|
+
export type TemplateStep =
|
|
138
|
+
| { type: "git-clone"; repo: string; dest?: string; ref?: string }
|
|
139
|
+
| { type: "shell"; command: string }
|
|
140
|
+
| { type: "copy"; from: string; to: string };
|
|
141
|
+
|
|
142
|
+
/** Full template incl. its ordered steps (GET /templates/:name). */
|
|
143
|
+
export interface TemplateFull {
|
|
144
|
+
name: string;
|
|
145
|
+
description?: string;
|
|
146
|
+
steps: TemplateStep[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One declared template parameter (GET /templates), for form rendering. */
|
|
150
|
+
export interface TemplateParamDto {
|
|
151
|
+
name: string;
|
|
152
|
+
description: string | null;
|
|
153
|
+
required: boolean;
|
|
154
|
+
default: string | null;
|
|
155
|
+
secret: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Summary of a setup template from GET /templates (counts + declared params). */
|
|
159
|
+
export interface TemplateSummaryDto {
|
|
160
|
+
name: string;
|
|
161
|
+
description: string | null;
|
|
162
|
+
step_count: number;
|
|
163
|
+
repo_count: number;
|
|
164
|
+
skill_count: number;
|
|
165
|
+
mcp_count: number;
|
|
166
|
+
has_system_prompt: boolean;
|
|
167
|
+
params: TemplateParamDto[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface TemplateDto {
|
|
171
|
+
name: string;
|
|
172
|
+
description?: string;
|
|
173
|
+
step_count?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface Identity {
|
|
177
|
+
authenticated: boolean;
|
|
178
|
+
user?: string;
|
|
179
|
+
scopes?: string[];
|
|
180
|
+
is_admin?: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface EventEnvelope {
|
|
184
|
+
sessionId: string;
|
|
185
|
+
seq: number;
|
|
186
|
+
event: { type: string; [k: string]: unknown };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ── Live session / chat model (mirrors src/shared/events.ts) ───────────── */
|
|
190
|
+
|
|
191
|
+
export interface ToolEvent {
|
|
192
|
+
id: string;
|
|
193
|
+
name: string;
|
|
194
|
+
input: unknown;
|
|
195
|
+
status: "running" | "success" | "error" | "aborted";
|
|
196
|
+
output?: string;
|
|
197
|
+
startedAt: number;
|
|
198
|
+
endedAt?: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ChatTurn {
|
|
202
|
+
id: string;
|
|
203
|
+
kind: "user" | "agent" | "tool" | "system" | "transmission";
|
|
204
|
+
text?: string;
|
|
205
|
+
reasoning?: string;
|
|
206
|
+
tool?: ToolEvent;
|
|
207
|
+
meta?: Record<string, unknown>;
|
|
208
|
+
createdAt: number;
|
|
209
|
+
/** UI-only: mark a system turn as an error. */
|
|
210
|
+
error?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface TaskItem {
|
|
214
|
+
id: string;
|
|
215
|
+
content: string;
|
|
216
|
+
activeForm?: string;
|
|
217
|
+
status: "pending" | "in_progress" | "completed";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface SessionStats {
|
|
221
|
+
turns: number;
|
|
222
|
+
tokens_in: number;
|
|
223
|
+
tokens_out: number;
|
|
224
|
+
contextPct: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface DisplaySlot {
|
|
228
|
+
slotId: string;
|
|
229
|
+
renderer: string;
|
|
230
|
+
input: unknown;
|
|
231
|
+
createdAt: number;
|
|
232
|
+
isPermissionRequest: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ── Remote uploads mirror (R2/S3) — src/garage/contract.ts ─────────────── */
|
|
236
|
+
|
|
237
|
+
/** Secret-free remote-storage settings (GET /storage). */
|
|
238
|
+
export interface StorageConfigDto {
|
|
239
|
+
enabled: boolean;
|
|
240
|
+
endpoint: string | null;
|
|
241
|
+
bucket: string | null;
|
|
242
|
+
prefix: string | null;
|
|
243
|
+
access_key_id: string | null;
|
|
244
|
+
has_secret: boolean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Body for PUT /storage — secret is write-only (omit to keep the stored one). */
|
|
248
|
+
export interface UpdateStorageConfigInput {
|
|
249
|
+
enabled?: boolean;
|
|
250
|
+
endpoint?: string | null;
|
|
251
|
+
bucket?: string | null;
|
|
252
|
+
prefix?: string | null;
|
|
253
|
+
access_key_id?: string | null;
|
|
254
|
+
secret_access_key?: string | null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Per-session remote-mirror sync state, present in the files list response. */
|
|
258
|
+
export interface FilesRemoteStatus {
|
|
259
|
+
enabled: boolean;
|
|
260
|
+
last_sync_at: string | null;
|
|
261
|
+
error: string | null;
|
|
262
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live session model. Subscribes to the Garage session WebSocket, requests a
|
|
5
|
+
* full hydrate on open, and folds the event stream into a render-ready
|
|
6
|
+
* conversation: ordered turns, a streaming buffer, tasks, stats, the agent
|
|
7
|
+
* roster, and any open permission/display slots. Also exposes the inbound
|
|
8
|
+
* commands the chat needs (send, abort, resolve permission, switch agent…).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
|
12
|
+
import { sessionWsUrl } from "./api";
|
|
13
|
+
import type { ChatTurn, ToolEvent, TaskItem, SessionStats, DisplaySlot, AgentInfo } from "./types";
|
|
14
|
+
|
|
15
|
+
interface State {
|
|
16
|
+
items: ChatTurn[];
|
|
17
|
+
streaming: string;
|
|
18
|
+
busy: boolean;
|
|
19
|
+
title: string | null;
|
|
20
|
+
tasks: TaskItem[];
|
|
21
|
+
stats: SessionStats | null;
|
|
22
|
+
agents: AgentInfo[];
|
|
23
|
+
activeAgentId: string | null;
|
|
24
|
+
mode: string | null;
|
|
25
|
+
slots: DisplaySlot[];
|
|
26
|
+
/** Honest status: seconds the model has been silent (null = streaming fine). */
|
|
27
|
+
waitingSec: number | null;
|
|
28
|
+
/** Messages queued behind the running turn. */
|
|
29
|
+
queueDepth: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const INIT: State = {
|
|
33
|
+
items: [],
|
|
34
|
+
streaming: "",
|
|
35
|
+
busy: false,
|
|
36
|
+
title: null,
|
|
37
|
+
tasks: [],
|
|
38
|
+
stats: null,
|
|
39
|
+
agents: [],
|
|
40
|
+
activeAgentId: null,
|
|
41
|
+
mode: null,
|
|
42
|
+
slots: [],
|
|
43
|
+
waitingSec: null,
|
|
44
|
+
queueDepth: 0,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type Ev = { type: string; [k: string]: any };
|
|
48
|
+
|
|
49
|
+
function upsert(items: ChatTurn[], turn: ChatTurn): ChatTurn[] {
|
|
50
|
+
const i = items.findIndex((t) => t.id === turn.id);
|
|
51
|
+
if (i === -1) return [...items, turn];
|
|
52
|
+
const next = items.slice();
|
|
53
|
+
next[i] = { ...next[i], ...turn };
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function reduce(state: State, ev: Ev): State {
|
|
58
|
+
switch (ev.type) {
|
|
59
|
+
case "session_hydrate":
|
|
60
|
+
// Open display slots are replayed as display_slot_pushed events right
|
|
61
|
+
// after the hydrate — reset here so stale slots from before a reconnect
|
|
62
|
+
// don't linger. Error turns arrive again right after (the session
|
|
63
|
+
// replays recent errors on every hydrate), so a plain reset stays
|
|
64
|
+
// duplicate-free.
|
|
65
|
+
return {
|
|
66
|
+
...state,
|
|
67
|
+
items: (ev.turns ?? []) as ChatTurn[],
|
|
68
|
+
title: ev.title ?? state.title,
|
|
69
|
+
tasks: ev.tasks ?? [],
|
|
70
|
+
stats: ev.stats ?? state.stats,
|
|
71
|
+
slots: [],
|
|
72
|
+
};
|
|
73
|
+
case "session_reset":
|
|
74
|
+
return { ...state, items: [], streaming: "", tasks: [] };
|
|
75
|
+
case "turn":
|
|
76
|
+
return {
|
|
77
|
+
...state,
|
|
78
|
+
items: upsert(state.items, ev.turn as ChatTurn),
|
|
79
|
+
streaming: ev.turn?.kind === "agent" ? "" : state.streaming,
|
|
80
|
+
};
|
|
81
|
+
case "turn_update":
|
|
82
|
+
return { ...state, items: state.items.map((t) => (t.id === ev.id ? { ...t, ...(ev.patch ?? {}) } : t)) };
|
|
83
|
+
case "text_delta":
|
|
84
|
+
return { ...state, streaming: state.streaming + (ev.text ?? "") };
|
|
85
|
+
case "text_clear":
|
|
86
|
+
return { ...state, streaming: "" };
|
|
87
|
+
case "tool_started":
|
|
88
|
+
case "tool_finished": {
|
|
89
|
+
const tool = ev.tool as ToolEvent;
|
|
90
|
+
return { ...state, items: upsert(state.items, { id: tool.id, kind: "tool", tool, createdAt: tool.startedAt ?? Date.now() }) };
|
|
91
|
+
}
|
|
92
|
+
case "tasks":
|
|
93
|
+
return { ...state, tasks: ev.tasks ?? [] };
|
|
94
|
+
case "stats":
|
|
95
|
+
return { ...state, stats: ev.stats ?? state.stats };
|
|
96
|
+
case "title":
|
|
97
|
+
return { ...state, title: ev.title ?? null };
|
|
98
|
+
case "busy":
|
|
99
|
+
return { ...state, busy: Boolean(ev.busy), ...(ev.busy ? {} : { waitingSec: null }) };
|
|
100
|
+
case "agent_roster":
|
|
101
|
+
return { ...state, agents: ev.agents ?? [], activeAgentId: ev.activeId ?? null };
|
|
102
|
+
case "permission_mode_changed":
|
|
103
|
+
return { ...state, mode: ev.mode ?? state.mode };
|
|
104
|
+
case "display_slot_pushed": {
|
|
105
|
+
// Upsert: the server replays open slots on hydrate/resync.
|
|
106
|
+
const slot = ev.slot as DisplaySlot;
|
|
107
|
+
return { ...state, slots: [...state.slots.filter((s) => s.slotId !== slot.slotId), slot] };
|
|
108
|
+
}
|
|
109
|
+
case "display_slot_resolved":
|
|
110
|
+
return { ...state, slots: state.slots.filter((s) => s.slotId !== ev.slotId) };
|
|
111
|
+
case "error":
|
|
112
|
+
return {
|
|
113
|
+
...state,
|
|
114
|
+
waitingSec: null,
|
|
115
|
+
items: [
|
|
116
|
+
...state.items,
|
|
117
|
+
{
|
|
118
|
+
id: `err_${Date.now()}`,
|
|
119
|
+
kind: "system",
|
|
120
|
+
text: ev.message,
|
|
121
|
+
error: true,
|
|
122
|
+
createdAt: Date.now(),
|
|
123
|
+
meta: { kind: ev.kind, hint: ev.hint, retryAfterSec: ev.retryAfterSec, detail: ev.detail },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
case "model_status":
|
|
128
|
+
return { ...state, waitingSec: ev.state === "waiting" ? (ev.elapsedSec ?? 0) : null };
|
|
129
|
+
case "queue_depth":
|
|
130
|
+
return { ...state, queueDepth: ev.depth ?? 0 };
|
|
131
|
+
default:
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useSession(id: string) {
|
|
137
|
+
const [state, dispatch] = useReducer(reduce, INIT);
|
|
138
|
+
const [connected, setConnected] = useState(false);
|
|
139
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!id) return;
|
|
143
|
+
const ws = new WebSocket(sessionWsUrl(id));
|
|
144
|
+
wsRef.current = ws;
|
|
145
|
+
ws.onopen = () => {
|
|
146
|
+
setConnected(true);
|
|
147
|
+
ws.send(JSON.stringify({ type: "resync" }));
|
|
148
|
+
};
|
|
149
|
+
ws.onclose = () => setConnected(false);
|
|
150
|
+
let wasBusy = false;
|
|
151
|
+
ws.onmessage = (e) => {
|
|
152
|
+
try {
|
|
153
|
+
const env = JSON.parse(String(e.data));
|
|
154
|
+
const ev = env.event ?? env;
|
|
155
|
+
dispatch(ev);
|
|
156
|
+
// A finished turn may include work from before this client connected
|
|
157
|
+
// (the launch flow posts the first message before navigating) — pull a
|
|
158
|
+
// fresh hydrate so the transcript is always complete. Only on the
|
|
159
|
+
// busy true→false TRANSITION: hydrate itself re-emits the current
|
|
160
|
+
// busy state, so resyncing on every busy:false would loop forever.
|
|
161
|
+
if (ev.type === "busy") {
|
|
162
|
+
if (wasBusy && ev.busy === false) ws.send(JSON.stringify({ type: "resync" }));
|
|
163
|
+
wasBusy = Boolean(ev.busy);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
/* ignore non-JSON frames */
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return () => {
|
|
170
|
+
ws.close();
|
|
171
|
+
wsRef.current = null;
|
|
172
|
+
};
|
|
173
|
+
}, [id]);
|
|
174
|
+
|
|
175
|
+
const raw = useCallback((msg: Record<string, unknown>) => wsRef.current?.send(JSON.stringify(msg)), []);
|
|
176
|
+
const send = useCallback(
|
|
177
|
+
(text: string, images?: Array<{ data: string; media_type: string }>) =>
|
|
178
|
+
(text.trim() || images?.length) &&
|
|
179
|
+
raw({ type: "send_message", text: text.trim(), ...(images?.length ? { images } : {}) }),
|
|
180
|
+
[raw],
|
|
181
|
+
);
|
|
182
|
+
const abort = useCallback(() => raw({ type: "abort" }), [raw]);
|
|
183
|
+
const resolvePermission = useCallback((slotId: string, allow: boolean) => raw({ type: "resolve_permission", slot_id: slotId, allow }), [raw]);
|
|
184
|
+
const setMode = useCallback((mode: string) => raw({ type: "set_permission_mode", mode }), [raw]);
|
|
185
|
+
const swapProfile = useCallback((profileId: string) => raw({ type: "swap_profile", profile_id: profileId }), [raw]);
|
|
186
|
+
const switchAgent = useCallback((agentId: string) => raw({ type: "switch_agent", agent_id: agentId }), [raw]);
|
|
187
|
+
const addAgent = useCallback((role: string, label?: string) => raw({ type: "add_agent", role, label }), [raw]);
|
|
188
|
+
const removeAgent = useCallback((agentId: string) => raw({ type: "remove_agent", agent_id: agentId }), [raw]);
|
|
189
|
+
|
|
190
|
+
return { ...state, connected, send, abort, resolvePermission, setMode, swapProfile, switchAgent, addAgent, removeAgent };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type SessionLive = ReturnType<typeof useSession>;
|
package/lib/utils.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify a saved provider by asking the Garage to list its models live
|
|
5
|
+
* (GET /models/providers/:id/models — the key never reaches the browser).
|
|
6
|
+
* A model list means the key + base URL actually work; failures map to a
|
|
7
|
+
* human verdict the UI can act on. Shared by first-run onboarding and the
|
|
8
|
+
* add-provider flow.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { api, ApiError } from "./api";
|
|
12
|
+
|
|
13
|
+
export type VerifyOutcome =
|
|
14
|
+
| { ok: true; models: string[] }
|
|
15
|
+
| { ok: false; reason: "auth" | "network" | "upstream"; message: string };
|
|
16
|
+
|
|
17
|
+
export async function verifyProvider(providerId: string): Promise<VerifyOutcome> {
|
|
18
|
+
try {
|
|
19
|
+
const res = await api<{ models: string[] }>(`/models/providers/${providerId}/models`);
|
|
20
|
+
return { ok: true, models: res.models ?? [] };
|
|
21
|
+
} catch (e) {
|
|
22
|
+
const raw = e instanceof ApiError ? e.message : String(e);
|
|
23
|
+
if (/401|403|unauthorized|invalid api key|invalid authentication/i.test(raw)) {
|
|
24
|
+
return { ok: false, reason: "auth", message: "The provider rejected the API key. Check the key and try again." };
|
|
25
|
+
}
|
|
26
|
+
if (/unreachable|timed? ?out|econnrefused|enotfound|fetch failed/i.test(raw)) {
|
|
27
|
+
return { ok: false, reason: "network", message: "Could not reach the endpoint. Check the base URL." };
|
|
28
|
+
}
|
|
29
|
+
return { ok: false, reason: "upstream", message: raw };
|
|
30
|
+
}
|
|
31
|
+
}
|
package/next.config.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
|
|
6
|
+
/** @type {import('next').NextConfig} */
|
|
7
|
+
const nextConfig = {
|
|
8
|
+
reactStrictMode: true,
|
|
9
|
+
// The dashboard is a pure client of the Garage REST/WS API; no server-side
|
|
10
|
+
// secrets live here. The API base is read at build time from NEXT_PUBLIC_GARAGE_URL.
|
|
11
|
+
// Pin the file-tracing root to this app so the repo's root lockfile doesn't
|
|
12
|
+
// confuse Next's workspace inference.
|
|
13
|
+
outputFileTracingRoot: here,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default nextConfig;
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@porkytheblack/garage-dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Garage dashboard — a clean console for the Glorp orchestration layer (namespaces, workspaces, sessions, credentials, profiles, agents, messages, provisioning).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev -p 3270",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start -p 3270",
|
|
10
|
+
"lint": "next lint",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@radix-ui/react-avatar": "^1.1.12",
|
|
15
|
+
"@radix-ui/react-dialog": "^1.1.16",
|
|
16
|
+
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
|
17
|
+
"@radix-ui/react-label": "^2.1.9",
|
|
18
|
+
"@radix-ui/react-popover": "^1.1.16",
|
|
19
|
+
"@radix-ui/react-scroll-area": "^1.2.11",
|
|
20
|
+
"@radix-ui/react-select": "^2.3.0",
|
|
21
|
+
"@radix-ui/react-separator": "^1.1.9",
|
|
22
|
+
"@radix-ui/react-slot": "^1.2.5",
|
|
23
|
+
"@radix-ui/react-tabs": "^1.1.14",
|
|
24
|
+
"@radix-ui/react-tooltip": "^1.2.9",
|
|
25
|
+
"@radix-ui/react-visually-hidden": "^1.2.5",
|
|
26
|
+
"class-variance-authority": "^0.7.1",
|
|
27
|
+
"clsx": "^2.1.1",
|
|
28
|
+
"cmdk": "^1.1.1",
|
|
29
|
+
"geist": "^1.7.2",
|
|
30
|
+
"lucide-react": "^1.17.0",
|
|
31
|
+
"next": "^15.5.0",
|
|
32
|
+
"react": "^19.0.0",
|
|
33
|
+
"react-dom": "^19.0.0",
|
|
34
|
+
"react-markdown": "^10.1.0",
|
|
35
|
+
"remark-gfm": "^4.0.1",
|
|
36
|
+
"sonner": "^2.0.7",
|
|
37
|
+
"tailwind-merge": "^3.6.0",
|
|
38
|
+
"tailwindcss-animate": "^1.0.7"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"@types/react": "^19.0.0",
|
|
43
|
+
"@types/react-dom": "^19.0.0",
|
|
44
|
+
"autoprefixer": "^10.5.0",
|
|
45
|
+
"postcss": "^8.5.15",
|
|
46
|
+
"tailwindcss": "^3.4.17",
|
|
47
|
+
"typescript": "^5.6.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Config } from "tailwindcss";
|
|
2
|
+
import animate from "tailwindcss-animate";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Garage design language — "sap & sunlight", light + dark.
|
|
6
|
+
*
|
|
7
|
+
* Warm near-neutral surfaces arranged in a deliberate elevation ladder
|
|
8
|
+
* (background → card → surface-2 → elevated), a single sap-green brand
|
|
9
|
+
* accent, and a depth model built from hairline borders + a top "sheen"
|
|
10
|
+
* highlight rather than heavy shadows. Every value is wired through the HSL
|
|
11
|
+
* CSS variables in globals.css so each mode retunes from one place.
|
|
12
|
+
*/
|
|
13
|
+
const config: Config = {
|
|
14
|
+
darkMode: "class",
|
|
15
|
+
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
|
|
16
|
+
theme: {
|
|
17
|
+
extend: {
|
|
18
|
+
fontFamily: {
|
|
19
|
+
sans: ["var(--font-geist-sans)", "ui-sans-serif", "system-ui", "sans-serif"],
|
|
20
|
+
mono: ["var(--font-geist-mono)", "ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
|
|
21
|
+
},
|
|
22
|
+
colors: {
|
|
23
|
+
border: "hsl(var(--border))",
|
|
24
|
+
"border-strong": "hsl(var(--border-strong))",
|
|
25
|
+
input: "hsl(var(--input))",
|
|
26
|
+
ring: "hsl(var(--ring))",
|
|
27
|
+
background: "hsl(var(--background))",
|
|
28
|
+
foreground: "hsl(var(--foreground))",
|
|
29
|
+
faint: "hsl(var(--faint))",
|
|
30
|
+
elevated: "hsl(var(--elevated))",
|
|
31
|
+
brand: {
|
|
32
|
+
DEFAULT: "hsl(var(--brand))",
|
|
33
|
+
foreground: "hsl(var(--brand-foreground))",
|
|
34
|
+
strong: "hsl(var(--brand-strong))",
|
|
35
|
+
},
|
|
36
|
+
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
|
|
37
|
+
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
|
|
38
|
+
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
|
|
39
|
+
success: { DEFAULT: "hsl(var(--success))", foreground: "hsl(var(--success-foreground))" },
|
|
40
|
+
warning: { DEFAULT: "hsl(var(--warning))", foreground: "hsl(var(--warning-foreground))" },
|
|
41
|
+
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
|
42
|
+
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
|
|
43
|
+
popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
|
|
44
|
+
card: "hsl(var(--card))",
|
|
45
|
+
surface: { DEFAULT: "hsl(var(--card))", 2: "hsl(var(--surface-2))" },
|
|
46
|
+
sidebar: {
|
|
47
|
+
DEFAULT: "hsl(var(--sidebar))",
|
|
48
|
+
foreground: "hsl(var(--sidebar-foreground))",
|
|
49
|
+
accent: "hsl(var(--sidebar-accent))",
|
|
50
|
+
border: "hsl(var(--sidebar-border))",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
borderRadius: {
|
|
54
|
+
xl: "calc(var(--radius) + 4px)",
|
|
55
|
+
lg: "var(--radius)",
|
|
56
|
+
md: "calc(var(--radius) - 3px)",
|
|
57
|
+
sm: "calc(var(--radius) - 6px)",
|
|
58
|
+
},
|
|
59
|
+
boxShadow: {
|
|
60
|
+
// Depth recipe lives in globals.css so each mode tunes its own tint.
|
|
61
|
+
sheen: "inset 0 1px 0 0 hsl(var(--sheen))",
|
|
62
|
+
card: "var(--shadow-card)",
|
|
63
|
+
elevated: "var(--shadow-elevated)",
|
|
64
|
+
glow: "0 0 0 1px hsl(var(--brand) / 0.45), 0 0 28px -6px hsl(var(--brand) / 0.5)",
|
|
65
|
+
},
|
|
66
|
+
keyframes: {
|
|
67
|
+
"accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" } },
|
|
68
|
+
"accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" } },
|
|
69
|
+
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
|
|
70
|
+
"slide-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
|
71
|
+
"pop-in": { from: { opacity: "0", transform: "scale(0.96)" }, to: { opacity: "1", transform: "scale(1)" } },
|
|
72
|
+
"caret-blink": { "0%,70%,100%": { opacity: "1" }, "20%,50%": { opacity: "0" } },
|
|
73
|
+
"pulse-ring": {
|
|
74
|
+
"0%": { transform: "scale(0.6)", opacity: "0.7" },
|
|
75
|
+
"80%,100%": { transform: "scale(2.2)", opacity: "0" },
|
|
76
|
+
},
|
|
77
|
+
shimmer: { "100%": { transform: "translateX(100%)" } },
|
|
78
|
+
},
|
|
79
|
+
animation: {
|
|
80
|
+
"accordion-down": "accordion-down 0.2s ease-out",
|
|
81
|
+
"accordion-up": "accordion-up 0.2s ease-out",
|
|
82
|
+
"fade-in": "fade-in 0.25s ease-out",
|
|
83
|
+
"slide-up": "slide-up 0.32s cubic-bezier(0.16,1,0.3,1)",
|
|
84
|
+
"pop-in": "pop-in 0.2s cubic-bezier(0.16,1,0.3,1)",
|
|
85
|
+
"caret-blink": "caret-blink 1.2s ease-out infinite",
|
|
86
|
+
"pulse-ring": "pulse-ring 2s cubic-bezier(0.16,1,0.3,1) infinite",
|
|
87
|
+
shimmer: "shimmer 1.6s infinite",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
plugins: [animate],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default config;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "ES2022"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|