@cloudagentfleet/ui 0.1.1

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.
@@ -0,0 +1,997 @@
1
+ (() => {
2
+ "use strict";
3
+
4
+ const {
5
+ createElement: h,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } = React;
12
+
13
+ const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped"]);
14
+ const STATUS_LABELS = {
15
+ starting: "Starting",
16
+ working: "Working",
17
+ waiting: "Needs input",
18
+ idle: "Idle",
19
+ completed: "Completed",
20
+ failed: "Failed",
21
+ stopped: "Stopped",
22
+ };
23
+
24
+ const TERMINAL_THEME = {
25
+ background: "#090c10",
26
+ foreground: "#d7dee7",
27
+ cursor: "#a7f3d0",
28
+ cursorAccent: "#090c10",
29
+ selectionBackground: "#334155",
30
+ black: "#20262e",
31
+ red: "#ff6b6b",
32
+ green: "#4ade80",
33
+ yellow: "#f5c451",
34
+ blue: "#63a4ff",
35
+ magenta: "#d084ff",
36
+ cyan: "#2dd4bf",
37
+ white: "#d7dee7",
38
+ brightBlack: "#64748b",
39
+ brightRed: "#ff8585",
40
+ brightGreen: "#75f0a8",
41
+ brightYellow: "#f8d477",
42
+ brightBlue: "#85b8ff",
43
+ brightMagenta: "#e0a3ff",
44
+ brightCyan: "#65e8db",
45
+ brightWhite: "#ffffff",
46
+ };
47
+
48
+ function csrfToken() {
49
+ const match = document.cookie.match(/(?:^|;\s*)hosted_agents_csrf=([^;]+)/);
50
+ return match ? decodeURIComponent(match[1]) : "";
51
+ }
52
+
53
+ function api(path, options) {
54
+ const request = { ...(options || {}) };
55
+ const method = String(request.method || "GET").toUpperCase();
56
+ if (request.body && typeof request.body !== "string") {
57
+ request.body = JSON.stringify(request.body);
58
+ }
59
+ const csrf = csrfToken();
60
+ return fetch(path, {
61
+ ...request,
62
+ credentials: "same-origin",
63
+ headers: {
64
+ accept: "application/json",
65
+ ...(request.body ? { "content-type": "application/json" } : {}),
66
+ ...(csrf && method !== "GET" && method !== "HEAD" ? { "x-csrf-token": csrf } : {}),
67
+ ...(options && options.headers ? options.headers : {}),
68
+ },
69
+ }).then(async (response) => {
70
+ if (response.status === 401) {
71
+ location.reload();
72
+ throw new Error("Authentication required — sign in again");
73
+ }
74
+ const body = response.status === 204 ? {} : await response.json().catch(() => ({}));
75
+ if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`);
76
+ return body;
77
+ });
78
+ }
79
+
80
+ function formatTime(value) {
81
+ if (!value) return "Never";
82
+ const date = new Date(value);
83
+ if (Number.isNaN(date.getTime())) return "Unknown";
84
+ return new Intl.DateTimeFormat(undefined, {
85
+ month: "short",
86
+ day: "numeric",
87
+ hour: "numeric",
88
+ minute: "2-digit",
89
+ }).format(date);
90
+ }
91
+
92
+ function normalizeTerminalText(value) {
93
+ if (!value) return "";
94
+ return cleanLegacyTerminalBuffer(String(value))
95
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
96
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
97
+ .replace(/\u001b[()][0-2A-Za-z]/g, "")
98
+ .replace(/\u001b[ -~]/g, "")
99
+ .replace(/\u001b[@-_]/g, "")
100
+ .replace(/\r\n/g, "\n")
101
+ .replace(/\r/g, "\n")
102
+ .replace(/\u0008/g, "")
103
+ .replace(/\n{3,}/g, "\n\n");
104
+ }
105
+
106
+ function cleanLegacyTerminalBuffer(value) {
107
+ const deviceReplyCount = (value.match(/0;276;0c1;2c/g) || []).length;
108
+ if (deviceReplyCount < 3) return value;
109
+ // Older Hub sessions persisted xterm's device replies as visible shell
110
+ // input. Their saved screen is not recoverable; new sessions are protected
111
+ // at the parser and PTY boundaries.
112
+ return "";
113
+ }
114
+
115
+ function suppressTerminalColorQueries(terminal) {
116
+ const parser = terminal.parser;
117
+ if (!parser || typeof parser.registerOscHandler !== "function") return;
118
+ for (const selector of [10, 11]) {
119
+ parser.registerOscHandler(selector, () => true);
120
+ }
121
+ }
122
+
123
+ function stripTerminalResponses(value) {
124
+ return value
125
+ // xterm.js emits these replies through its input event. They are
126
+ // terminal protocol, not operator input, and must not be written into
127
+ // Codeman's PTY where the shell would echo them as visible text.
128
+ .replace(/\u001b\](?:10|11);[^\u0007]*(?:\u0007|\u001b\\)/g, "")
129
+ .replace(/\u001b\[[?>=!]?[0-9;]*[cnt]/g, "");
130
+ }
131
+
132
+ function shortId(value) {
133
+ return String(value || "").slice(0, 8);
134
+ }
135
+
136
+ function StatusDot({ status }) {
137
+ return h("span", {
138
+ className: `status-dot status-dot--${status}`,
139
+ title: status,
140
+ "aria-label": status,
141
+ });
142
+ }
143
+
144
+ function StatusBadge({ status }) {
145
+ return h(
146
+ "span",
147
+ { className: `status-badge status-badge--${status}` },
148
+ h(StatusDot, { status }),
149
+ STATUS_LABELS[status] || status,
150
+ );
151
+ }
152
+
153
+ function EmptyState({ title, children, action }) {
154
+ return h(
155
+ "div",
156
+ { className: "empty-state" },
157
+ h("div", { className: "empty-state__icon", "aria-hidden": "true" }, "◇"),
158
+ h("h2", null, title),
159
+ children && h("p", null, children),
160
+ action,
161
+ );
162
+ }
163
+
164
+ function App() {
165
+ const [instances, setInstances] = useState([]);
166
+ const [sessions, setSessions] = useState([]);
167
+ const [terminalEvent, setTerminalEvent] = useState(null);
168
+ const [selectedInstanceId, setSelectedInstanceId] = useState(null);
169
+ const [selectedSessionKey, setSelectedSessionKey] = useState(null);
170
+ const [loading, setLoading] = useState(true);
171
+ const [error, setError] = useState("");
172
+ const [streamStatus, setStreamStatus] = useState("connecting");
173
+ const [showCreate, setShowCreate] = useState(false);
174
+ const [refreshing, setRefreshing] = useState(false);
175
+
176
+ const refresh = useCallback(async (quiet = false) => {
177
+ if (!quiet) setLoading(true);
178
+ else setRefreshing(true);
179
+ try {
180
+ const instanceData = await api("/api/instances");
181
+ const nextInstances = instanceData.instances || [];
182
+ const sessionResponses = await Promise.all(
183
+ nextInstances.map((instance) =>
184
+ api(`/api/instances/${encodeURIComponent(instance.id)}/sessions`),
185
+ ),
186
+ );
187
+ const nextSessions = sessionResponses.flatMap((result) => result.sessions || []);
188
+ setInstances(nextInstances);
189
+ setSessions(nextSessions);
190
+ setSelectedInstanceId((current) =>
191
+ current && nextInstances.some((instance) => instance.id === current)
192
+ ? current
193
+ : nextInstances[0]?.id || null,
194
+ );
195
+ setError("");
196
+ } catch (cause) {
197
+ setError(cause instanceof Error ? cause.message : "Unable to load fleet");
198
+ } finally {
199
+ setLoading(false);
200
+ setRefreshing(false);
201
+ }
202
+ }, []);
203
+
204
+ useEffect(() => {
205
+ void refresh();
206
+ }, [refresh]);
207
+
208
+ useEffect(() => {
209
+ const timer = setInterval(() => void refresh(true), 2000);
210
+ return () => clearInterval(timer);
211
+ }, [refresh]);
212
+
213
+ useEffect(() => {
214
+ const events = new EventSource("/api/events");
215
+ let refreshTimer = null;
216
+ const reload = () => {
217
+ if (refreshTimer !== null) return;
218
+ refreshTimer = setTimeout(() => {
219
+ refreshTimer = null;
220
+ void refresh(true);
221
+ }, 120);
222
+ };
223
+ events.addEventListener("open", () => setStreamStatus("live"));
224
+ events.addEventListener("snapshot", reload);
225
+ events.addEventListener("session", (message) => {
226
+ try {
227
+ const event = JSON.parse(message.data);
228
+ if (event.event === "output" && typeof event.data === "string") {
229
+ setTerminalEvent((current) => ({
230
+ ...event,
231
+ version: (current?.version || 0) + 1,
232
+ }));
233
+ }
234
+ } catch {
235
+ // The periodic refresh remains the recovery path for malformed events.
236
+ }
237
+ reload();
238
+ });
239
+ events.onerror = () => setStreamStatus("reconnecting");
240
+ return () => {
241
+ events.close();
242
+ if (refreshTimer !== null) clearTimeout(refreshTimer);
243
+ };
244
+ }, [refresh]);
245
+
246
+ const selectedInstance = instances.find((instance) => instance.id === selectedInstanceId) || null;
247
+ const selectedSession = sessions.find(
248
+ (session) => `${session.instanceId}/${session.id}` === selectedSessionKey,
249
+ ) || null;
250
+
251
+ useEffect(() => {
252
+ if (!selectedSessionKey && sessions[0]) {
253
+ setSelectedSessionKey(`${sessions[0].instanceId}/${sessions[0].id}`);
254
+ }
255
+ if (selectedSessionKey && !sessions.some(
256
+ (session) => `${session.instanceId}/${session.id}` === selectedSessionKey,
257
+ )) {
258
+ setSelectedSessionKey(null);
259
+ }
260
+ }, [sessions, selectedSessionKey]);
261
+
262
+ const sessionsByInstance = useMemo(() => {
263
+ const groups = new Map();
264
+ for (const instance of instances) groups.set(instance.id, []);
265
+ for (const session of sessions) {
266
+ if (!groups.has(session.instanceId)) groups.set(session.instanceId, []);
267
+ groups.get(session.instanceId).push(session);
268
+ }
269
+ return groups;
270
+ }, [instances, sessions]);
271
+
272
+ async function stopSession() {
273
+ if (!selectedSession || !selectedInstance) return;
274
+ try {
275
+ await api(
276
+ `/api/instances/${encodeURIComponent(selectedInstance.id)}/sessions/${encodeURIComponent(selectedSession.id)}/stop`,
277
+ { method: "POST" },
278
+ );
279
+ await refresh(true);
280
+ } catch (cause) {
281
+ setError(cause instanceof Error ? cause.message : "Unable to stop session");
282
+ }
283
+ }
284
+
285
+ async function removeSession() {
286
+ if (!selectedSession || !selectedInstance) return;
287
+ if (!TERMINAL_STATUSES.has(selectedSession.status)) return;
288
+ try {
289
+ await api(
290
+ `/api/instances/${encodeURIComponent(selectedInstance.id)}/sessions/${encodeURIComponent(selectedSession.id)}`,
291
+ { method: "DELETE" },
292
+ );
293
+ setSelectedSessionKey(null);
294
+ await refresh(true);
295
+ } catch (cause) {
296
+ setError(cause instanceof Error ? cause.message : "Unable to remove session");
297
+ }
298
+ }
299
+
300
+ return h(
301
+ "div",
302
+ { className: "app-shell" },
303
+ h(
304
+ "header",
305
+ { className: "topbar" },
306
+ h(
307
+ "div",
308
+ { className: "brand" },
309
+ h("span", { className: "brand__mark", "aria-hidden": "true" }, "✦"),
310
+ h("div", null, h("strong", null, "Codeman Fleet"), h("span", null, "Multi-host Hub")),
311
+ ),
312
+ h(
313
+ "div",
314
+ { className: "connection-state", "aria-live": "polite" },
315
+ h("span", { className: `connection-state__dot connection-state__dot--${streamStatus}` }),
316
+ streamStatus === "live" ? "Live updates" : "Reconnecting…",
317
+ refreshing && h("span", { className: "refresh-label" }, " · Refreshing"),
318
+ ),
319
+ ),
320
+ error && h(
321
+ "div",
322
+ { className: "alert", role: "alert" },
323
+ h("strong", null, "Couldn’t update the fleet"),
324
+ h("span", null, error),
325
+ h("button", { className: "button button--quiet", onClick: () => void refresh() }, "Try again"),
326
+ ),
327
+ h(
328
+ "div",
329
+ { className: "workspace-layout" },
330
+ h(
331
+ "aside",
332
+ { className: "sidebar", "aria-label": "Fleet instances and sessions" },
333
+ h(
334
+ "div",
335
+ { className: "sidebar__heading" },
336
+ h("div", null, h("span", { className: "eyebrow" }, "Fleet"), h("h1", null, "Instances")),
337
+ h(
338
+ "span",
339
+ { className: "count-pill", "aria-label": `${instances.length} instances` },
340
+ instances.length,
341
+ ),
342
+ ),
343
+ loading
344
+ ? h("div", { className: "skeleton-list", "aria-label": "Loading instances" }, h("span"), h("span"), h("span"))
345
+ : instances.length === 0
346
+ ? h(EmptyState, { title: "No instances yet" }, "Connect a Codeman host to see it here.")
347
+ : h(
348
+ "nav",
349
+ { className: "instance-list" },
350
+ instances.map((instance) => h(
351
+ "div",
352
+ { className: "instance-group", key: instance.id },
353
+ h(
354
+ "button",
355
+ {
356
+ className: `instance-card ${selectedInstanceId === instance.id ? "instance-card--selected" : ""}`,
357
+ onClick: () => setSelectedInstanceId(instance.id),
358
+ "aria-pressed": selectedInstanceId === instance.id,
359
+ },
360
+ h(StatusDot, { status: instance.status }),
361
+ h(
362
+ "span",
363
+ { className: "instance-card__copy" },
364
+ h("strong", null, instance.label),
365
+ h("small", null, `${(sessionsByInstance.get(instance.id) || []).length} sessions`),
366
+ ),
367
+ h("span", { className: "instance-card__chevron", "aria-hidden": "true" }, "›"),
368
+ ),
369
+ (sessionsByInstance.get(instance.id) || []).map((session) => h(
370
+ "button",
371
+ {
372
+ className: `session-row ${selectedSessionKey === `${session.instanceId}/${session.id}` ? "session-row--selected" : ""}`,
373
+ key: session.id,
374
+ onClick: () => {
375
+ setSelectedInstanceId(instance.id);
376
+ setSelectedSessionKey(`${session.instanceId}/${session.id}`);
377
+ },
378
+ },
379
+ h("span", { className: `session-row__indicator session-row__indicator--${session.status}` }),
380
+ h(
381
+ "span",
382
+ { className: "session-row__copy" },
383
+ h("strong", null, session.title),
384
+ h("small", null, `${session.agent.name} · ${shortId(session.id)} · ${STATUS_LABELS[session.status] || session.status}`),
385
+ ),
386
+ session.needsInput && h("span", { className: "needs-input-dot", title: "Needs input" }),
387
+ )),
388
+ )),
389
+ ),
390
+ h(
391
+ "div",
392
+ { className: "sidebar__footer" },
393
+ h("span", null, `${instances.filter((instance) => instance.status === "online").length} online`),
394
+ h("span", null, `${sessions.length} total sessions`),
395
+ ),
396
+ ),
397
+ h(
398
+ "main",
399
+ { className: "main-content" },
400
+ selectedSession
401
+ ? h(
402
+ SessionDetail,
403
+ {
404
+ key: selectedSessionKey,
405
+ instance: instances.find((instance) => instance.id === selectedSession.instanceId),
406
+ session: selectedSession,
407
+ terminalEvent,
408
+ onStop: stopSession,
409
+ onRemove: removeSession,
410
+ onNewSession: () => setShowCreate(true),
411
+ setError,
412
+ },
413
+ )
414
+ : h(
415
+ "section",
416
+ { className: "welcome-panel" },
417
+ h(
418
+ "div",
419
+ { className: "welcome-panel__content" },
420
+ h("span", { className: "eyebrow" }, "Control plane"),
421
+ h("h2", null, "Operate every Codeman host from one place."),
422
+ h("p", null, "Choose an instance to inspect its work, or start a new session in a ready workspace."),
423
+ h(
424
+ "button",
425
+ {
426
+ className: "button button--primary",
427
+ onClick: () => setShowCreate(true),
428
+ disabled: !selectedInstance,
429
+ },
430
+ "Start a session",
431
+ ),
432
+ ),
433
+ h("div", { className: "welcome-panel__orb", "aria-hidden": "true" }, "✦"),
434
+ ),
435
+ selectedInstance && !selectedSession && h(
436
+ "section",
437
+ { className: "instance-overview" },
438
+ h("span", { className: "eyebrow" }, "Selected instance"),
439
+ h("div", { className: "instance-overview__title" }, h("h2", null, selectedInstance.label), h(StatusBadge, { status: selectedInstance.status })),
440
+ h("p", null, selectedInstance.endpoint || "Connected through worker connector"),
441
+ h(
442
+ "div",
443
+ { className: "overview-grid" },
444
+ h("div", null, h("span", null, "Workspaces"), h("strong", null, selectedInstance.workspaces.length)),
445
+ h("div", null, h("span", null, "Agents ready"), h("strong", null, selectedInstance.agents.filter((agent) => agent.ready).length)),
446
+ h("div", null, h("span", null, "Last seen"), h("strong", null, formatTime(selectedInstance.lastSeenAt))),
447
+ ),
448
+ h(
449
+ "div",
450
+ { className: "overview-actions" },
451
+ h(
452
+ "button",
453
+ {
454
+ className: "button button--quiet",
455
+ onClick: () => {
456
+ if (selectedInstance.connectionMode === "tailscale-url") {
457
+ window.location.assign(
458
+ `/api/fleet/select/${encodeURIComponent(selectedInstance.id)}`,
459
+ );
460
+ }
461
+ },
462
+ disabled: selectedInstance.connectionMode !== "tailscale-url",
463
+ title: selectedInstance.connectionMode === "tailscale-url"
464
+ ? "Open Codeman's native UI for this host. Use Fleet dashboard in the top bar to return."
465
+ : "Native Codeman UI requires a Tailscale Codeman host",
466
+ },
467
+ "Open Codeman UI",
468
+ ),
469
+ h(
470
+ "button",
471
+ { className: "button button--quiet", onClick: async () => {
472
+ try {
473
+ await api(`/api/instances/${encodeURIComponent(selectedInstance.id)}/health`, { method: "POST" });
474
+ await refresh(true);
475
+ } catch (cause) {
476
+ setError(cause instanceof Error ? cause.message : "Unable to check instance health");
477
+ }
478
+ } },
479
+ "Check health",
480
+ ),
481
+ h(
482
+ "button",
483
+ { className: "button button--primary", onClick: () => setShowCreate(true), disabled: selectedInstance.status !== "online" },
484
+ selectedInstance.status === "online" ? "Start a session" : "Instance unavailable",
485
+ ),
486
+ ),
487
+ ),
488
+ ),
489
+ ),
490
+ showCreate && h(
491
+ CreateSessionDialog,
492
+ {
493
+ instances,
494
+ preferredInstanceId: selectedInstanceId,
495
+ onClose: () => setShowCreate(false),
496
+ onCreated: async (session) => {
497
+ setShowCreate(false);
498
+ setSelectedInstanceId(session.instanceId);
499
+ await refresh(true);
500
+ setSelectedSessionKey(`${session.instanceId}/${session.id}`);
501
+ },
502
+ setError,
503
+ },
504
+ ),
505
+ );
506
+ }
507
+
508
+ function SessionDetail({ instance, session, terminalEvent, onStop, onRemove, onNewSession, setError }) {
509
+ const terminalHostRef = useRef(null);
510
+ const terminalRef = useRef(null);
511
+ const inputQueueRef = useRef(Promise.resolve());
512
+ const pendingInputRef = useRef("");
513
+ const inputFlushTimerRef = useRef(null);
514
+ const resizeQueueRef = useRef(Promise.resolve());
515
+ const terminalSizeRef = useRef("");
516
+ const lastWrittenEventVersionRef = useRef(0);
517
+ const renderedTerminalBufferRef = useRef("");
518
+ const canInteract = instance && !TERMINAL_STATUSES.has(session.status);
519
+ const workspace = instance?.workspaces.find((item) => item.id === session.workspaceId);
520
+
521
+ const localEchoRef = useRef("");
522
+
523
+ const flushTerminalInput = useCallback(() => {
524
+ if (inputFlushTimerRef.current !== null) {
525
+ clearTimeout(inputFlushTimerRef.current);
526
+ inputFlushTimerRef.current = null;
527
+ }
528
+ const value = pendingInputRef.current;
529
+ pendingInputRef.current = "";
530
+ if (!value || !instance || !canInteract) return;
531
+ inputQueueRef.current = inputQueueRef.current
532
+ .catch(() => undefined)
533
+ .then(async () => {
534
+ const raw = /[^\u0020-\u007e\r]/.test(value) || !value.endsWith("\r");
535
+ await api(
536
+ `/api/instances/${encodeURIComponent(instance.id)}/sessions/${encodeURIComponent(session.id)}/input`,
537
+ { method: "POST", body: { input: value, raw } },
538
+ );
539
+ })
540
+ .catch((cause) => setError(cause instanceof Error ? cause.message : "Unable to send terminal input"));
541
+ }, [canInteract, instance?.id, session.id, setError]);
542
+
543
+ const writeLocalEcho = useCallback((value) => {
544
+ const terminal = terminalRef.current;
545
+ if (!terminal || !value) return;
546
+ // Echo printable text immediately so the UI tracks keystrokes without
547
+ // waiting on the network. Control keys are left to the remote PTY/TUI.
548
+ if (!/^[\u0020-\u007e]+$/.test(value)) return;
549
+ terminal.write(value);
550
+ localEchoRef.current += value;
551
+ if (localEchoRef.current.length > 4000) {
552
+ localEchoRef.current = localEchoRef.current.slice(-2000);
553
+ }
554
+ }, []);
555
+
556
+ const consumeRemoteEcho = useCallback((chunk) => {
557
+ if (!chunk || !localEchoRef.current) return chunk;
558
+ let remaining = chunk;
559
+ let pending = localEchoRef.current;
560
+ while (pending && remaining) {
561
+ if (remaining.startsWith(pending)) {
562
+ remaining = remaining.slice(pending.length);
563
+ pending = "";
564
+ break;
565
+ }
566
+ if (pending.startsWith(remaining)) {
567
+ pending = pending.slice(remaining.length);
568
+ remaining = "";
569
+ break;
570
+ }
571
+ // Divergence: drop one local-echo character and keep reconciling.
572
+ pending = pending.slice(1);
573
+ }
574
+ localEchoRef.current = pending;
575
+ return remaining;
576
+ }, []);
577
+
578
+ const sendTerminalInput = useCallback((value) => {
579
+ if (!value || !instance || !canInteract) return;
580
+ writeLocalEcho(value);
581
+ pendingInputRef.current += value;
582
+ // Printable typing is coalesced into one request. Control sequences
583
+ // must remain immediate so Ctrl-C, Enter, arrows, and escape-driven
584
+ // TUIs retain Codeman-like responsiveness.
585
+ if (/[\u0000-\u001f\u007f\u001b]/.test(value)) {
586
+ flushTerminalInput();
587
+ return;
588
+ }
589
+ if (inputFlushTimerRef.current === null) {
590
+ inputFlushTimerRef.current = setTimeout(flushTerminalInput, 32);
591
+ }
592
+ }, [canInteract, flushTerminalInput, instance?.id, writeLocalEcho]);
593
+
594
+ const sendTerminalResize = useCallback((cols, rows) => {
595
+ if (!instance || !canInteract || !Number.isInteger(cols) || !Number.isInteger(rows)) return;
596
+ const size = `${cols}x${rows}`;
597
+ if (terminalSizeRef.current === size) return;
598
+ terminalSizeRef.current = size;
599
+ resizeQueueRef.current = resizeQueueRef.current
600
+ .catch(() => undefined)
601
+ .then(async () => {
602
+ await api(
603
+ `/api/instances/${encodeURIComponent(instance.id)}/sessions/${encodeURIComponent(session.id)}/resize`,
604
+ { method: "POST", body: { cols, rows } },
605
+ );
606
+ })
607
+ .catch((cause) => setError(cause instanceof Error ? cause.message : "Unable to resize terminal"));
608
+ }, [canInteract, instance?.id, session.id, setError]);
609
+
610
+ useEffect(() => {
611
+ const TerminalConstructor = globalThis.Terminal;
612
+ if (!terminalHostRef.current || typeof TerminalConstructor !== "function") return undefined;
613
+ const terminal = new TerminalConstructor({
614
+ allowProposedApi: true,
615
+ convertEol: true,
616
+ disableStdin: !canInteract,
617
+ cursorBlink: false,
618
+ fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
619
+ fontSize: 13,
620
+ lineHeight: 1.35,
621
+ scrollback: 5000,
622
+ theme: TERMINAL_THEME,
623
+ });
624
+ suppressTerminalColorQueries(terminal);
625
+ const FitAddonConstructor = globalThis.FitAddon?.FitAddon;
626
+ const fitAddon = typeof FitAddonConstructor === "function"
627
+ ? new FitAddonConstructor()
628
+ : null;
629
+ if (fitAddon) terminal.loadAddon(fitAddon);
630
+ terminal.open(terminalHostRef.current);
631
+ const fitTerminal = () => {
632
+ fitAddon?.fit();
633
+ sendTerminalResize(terminal.cols, terminal.rows);
634
+ terminal.scrollToBottom();
635
+ };
636
+ fitTerminal();
637
+ const resizeObserver = typeof ResizeObserver === "function"
638
+ ? new ResizeObserver(fitTerminal)
639
+ : null;
640
+ resizeObserver?.observe(terminalHostRef.current);
641
+ if (canInteract) {
642
+ terminal.onData((data) => sendTerminalInput(stripTerminalResponses(data)));
643
+ }
644
+ terminalRef.current = terminal;
645
+ return () => {
646
+ resizeObserver?.disconnect();
647
+ if (inputFlushTimerRef.current !== null) {
648
+ clearTimeout(inputFlushTimerRef.current);
649
+ inputFlushTimerRef.current = null;
650
+ }
651
+ pendingInputRef.current = "";
652
+ localEchoRef.current = "";
653
+ terminal.dispose();
654
+ terminalRef.current = null;
655
+ };
656
+ }, [canInteract, sendTerminalInput, sendTerminalResize]);
657
+
658
+ useEffect(() => {
659
+ const terminal = terminalRef.current;
660
+ if (!terminal) return;
661
+ const terminalBuffer = cleanLegacyTerminalBuffer(session.terminalBuffer);
662
+ const renderedBuffer = renderedTerminalBufferRef.current;
663
+ if (terminalBuffer === renderedBuffer) return;
664
+ if (terminalBuffer && renderedBuffer && terminalBuffer.startsWith(renderedBuffer)) {
665
+ const delta = consumeRemoteEcho(terminalBuffer.slice(renderedBuffer.length));
666
+ if (delta) terminal.write(delta);
667
+ } else {
668
+ localEchoRef.current = "";
669
+ terminal.reset();
670
+ if (terminalBuffer) terminal.write(terminalBuffer);
671
+ else if (session.terminalBuffer) terminal.writeln("This legacy session has corrupted terminal output. Start a new session.");
672
+ else terminal.writeln("Waiting for terminal output…");
673
+ }
674
+ renderedTerminalBufferRef.current = terminalBuffer;
675
+ lastWrittenEventVersionRef.current = terminalEvent?.version || 0;
676
+ terminal.scrollToBottom();
677
+ }, [canInteract, consumeRemoteEcho, session.id, session.terminalBuffer]);
678
+
679
+ useEffect(() => {
680
+ const terminal = terminalRef.current;
681
+ if (
682
+ !terminal
683
+ || !terminalEvent
684
+ || terminalEvent.instanceId !== session.instanceId
685
+ || terminalEvent.sessionId !== session.id
686
+ || terminalEvent.version <= lastWrittenEventVersionRef.current
687
+ ) {
688
+ return;
689
+ }
690
+ const delta = consumeRemoteEcho(terminalEvent.data);
691
+ if (delta) terminal.write(delta);
692
+ renderedTerminalBufferRef.current += terminalEvent.data;
693
+ terminal.scrollToBottom();
694
+ lastWrittenEventVersionRef.current = terminalEvent.version;
695
+ }, [consumeRemoteEcho, session.id, session.instanceId, terminalEvent]);
696
+
697
+ return h(
698
+ "section",
699
+ { className: "session-detail" },
700
+ h(
701
+ "div",
702
+ { className: "session-detail__header" },
703
+ h(
704
+ "div",
705
+ null,
706
+ h("span", { className: "breadcrumb" }, instance?.label || "Unknown instance", " / ", workspace?.name || session.workspaceId),
707
+ h("h2", null, session.title),
708
+ h("div", { className: "session-meta" },
709
+ h(StatusBadge, { status: session.status }),
710
+ h("span", null, session.agent.name),
711
+ h("span", { className: "session-id", title: session.id }, `Session ${shortId(session.id)}`),
712
+ h("span", null, `Updated ${formatTime(session.lastEventAt)}`),
713
+ ),
714
+ ),
715
+ h("div", { className: "session-detail__actions" },
716
+ h(
717
+ "button",
718
+ { className: "button button--quiet", onClick: onNewSession, disabled: !instance || instance.status !== "online" },
719
+ "New session",
720
+ ),
721
+ h(
722
+ "button",
723
+ {
724
+ className: `button ${canInteract ? "button--danger" : "button--quiet"}`,
725
+ onClick: canInteract ? onStop : onRemove,
726
+ disabled: !canInteract && !TERMINAL_STATUSES.has(session.status),
727
+ },
728
+ canInteract ? "Stop session" : "Remove session",
729
+ ),
730
+ ),
731
+ ),
732
+ h(
733
+ "div",
734
+ { className: "terminal", "aria-label": "Session terminal output" },
735
+ h("div", { className: "terminal__bar" }, h("span", null, "OUTPUT"), h("span", null, session.id)),
736
+ h("div", {
737
+ ref: terminalHostRef,
738
+ className: "terminal__output",
739
+ onMouseDown: () => terminalRef.current?.focus(),
740
+ },
741
+ !globalThis.Terminal && (
742
+ normalizeTerminalText(session.terminalBuffer)
743
+ || h("span", { className: "terminal__placeholder" }, "Waiting for terminal output…")
744
+ ),
745
+ ),
746
+ ),
747
+ );
748
+ }
749
+
750
+ function CreateSessionDialog({ instances, preferredInstanceId, onClose, onCreated, setError }) {
751
+ const [instanceId, setInstanceId] = useState(preferredInstanceId || instances[0]?.id || "");
752
+ const instance = instances.find((item) => item.id === instanceId);
753
+ const readyWorkspaces = (instance?.workspaces || []).filter((workspace) => workspace.health === "ready");
754
+ const allAgents = instance?.agents || [];
755
+ const readyAgents = allAgents.filter((agent) => agent.ready);
756
+ const [workspaceSource, setWorkspaceSource] = useState("workspace");
757
+ const [workspaceId, setWorkspaceId] = useState(readyWorkspaces[0]?.id || "");
758
+ const [workspacePath, setWorkspacePath] = useState("");
759
+ const [pathSuggestions, setPathSuggestions] = useState([]);
760
+ const [activeSuggestion, setActiveSuggestion] = useState(-1);
761
+ const [suggesting, setSuggesting] = useState(false);
762
+ const [suggestionError, setSuggestionError] = useState("");
763
+ const [agentId, setAgentId] = useState(readyAgents[0]?.id || "");
764
+ const [title, setTitle] = useState("");
765
+ const [creating, setCreating] = useState(false);
766
+
767
+ useEffect(() => {
768
+ const nextWorkspaces = (instance?.workspaces || []).filter((workspace) => workspace.health === "ready");
769
+ const nextAgents = (instance?.agents || []).filter((agent) => agent.ready);
770
+ setWorkspaceSource("workspace");
771
+ setWorkspaceId(nextWorkspaces[0]?.id || "");
772
+ setWorkspacePath("");
773
+ setPathSuggestions([]);
774
+ setActiveSuggestion(-1);
775
+ setSuggestionError("");
776
+ setAgentId(nextAgents[0]?.id || "");
777
+ }, [instanceId]);
778
+
779
+ useEffect(() => {
780
+ if (workspaceSource !== "path" || !instance?.workspacePolicy) {
781
+ setPathSuggestions([]);
782
+ setSuggesting(false);
783
+ setSuggestionError("");
784
+ return undefined;
785
+ }
786
+ const controller = new AbortController();
787
+ const timer = setTimeout(async () => {
788
+ setSuggesting(true);
789
+ setSuggestionError("");
790
+ try {
791
+ const result = await api(
792
+ `/api/instances/${encodeURIComponent(instance.id)}/workspaces/suggestions?path=${encodeURIComponent(workspacePath)}`,
793
+ { signal: controller.signal },
794
+ );
795
+ setPathSuggestions(Array.isArray(result.suggestions) ? result.suggestions : []);
796
+ setActiveSuggestion(-1);
797
+ } catch (cause) {
798
+ if (cause?.name !== "AbortError") {
799
+ setPathSuggestions([]);
800
+ setSuggestionError(cause instanceof Error ? cause.message : "Unable to suggest paths");
801
+ }
802
+ } finally {
803
+ if (!controller.signal.aborted) setSuggesting(false);
804
+ }
805
+ }, 180);
806
+ return () => {
807
+ controller.abort();
808
+ clearTimeout(timer);
809
+ };
810
+ }, [instance?.id, workspacePath, workspaceSource]);
811
+
812
+ function choosePath(path) {
813
+ setWorkspacePath(path);
814
+ setActiveSuggestion(-1);
815
+ setSuggestionError("");
816
+ }
817
+
818
+ function handlePathKeyDown(event) {
819
+ if (!pathSuggestions.length) return;
820
+ if (event.key === "ArrowDown") {
821
+ event.preventDefault();
822
+ setActiveSuggestion((current) => (current + 1) % pathSuggestions.length);
823
+ } else if (event.key === "ArrowUp") {
824
+ event.preventDefault();
825
+ setActiveSuggestion((current) => (current <= 0 ? pathSuggestions.length - 1 : current - 1));
826
+ } else if (event.key === "Enter" && activeSuggestion >= 0) {
827
+ event.preventDefault();
828
+ choosePath(pathSuggestions[activeSuggestion].path);
829
+ } else if (event.key === "Escape") {
830
+ setActiveSuggestion(-1);
831
+ }
832
+ }
833
+
834
+ async function createSession(event) {
835
+ event.preventDefault();
836
+ const path = workspacePath.trim();
837
+ if (!instance || (!workspaceId && workspaceSource === "workspace") || (!path && workspaceSource === "path") || !agentId) return;
838
+ setCreating(true);
839
+ try {
840
+ const result = await api(`/api/instances/${encodeURIComponent(instance.id)}/sessions`, {
841
+ method: "POST",
842
+ body: {
843
+ agentId,
844
+ ...(workspaceSource === "path" ? { workspacePath: path } : { workspaceId }),
845
+ ...(title.trim() ? { title: title.trim() } : {}),
846
+ },
847
+ });
848
+ onCreated(result.session);
849
+ } catch (cause) {
850
+ setError(cause instanceof Error ? cause.message : "Unable to create session");
851
+ } finally {
852
+ setCreating(false);
853
+ }
854
+ }
855
+
856
+ return h(
857
+ "div",
858
+ { className: "dialog-backdrop", role: "presentation", onMouseDown: (event) => event.target === event.currentTarget && onClose() },
859
+ h(
860
+ "section",
861
+ { className: "dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "create-session-title" },
862
+ h(
863
+ "div",
864
+ { className: "dialog__header" },
865
+ h("div", null, h("span", { className: "eyebrow" }, "New work"), h("h2", { id: "create-session-title" }, "Start a session")),
866
+ h("button", { className: "icon-button", onClick: onClose, "aria-label": "Close dialog" }, "×"),
867
+ ),
868
+ h(
869
+ "form",
870
+ { onSubmit: createSession },
871
+ h(
872
+ "label",
873
+ { className: "field" },
874
+ h("span", null, "Instance"),
875
+ h(
876
+ "select",
877
+ { value: instanceId, onChange: (event) => setInstanceId(event.target.value), required: true },
878
+ instances.map((item) => h("option", { value: item.id, key: item.id }, item.label)),
879
+ ),
880
+ instance && h("small", null, h(StatusBadge, { status: instance.status })),
881
+ ),
882
+ h(
883
+ "label",
884
+ { className: "field" },
885
+ h("span", null, "Workspace source"),
886
+ h(
887
+ "select",
888
+ { value: workspaceSource, onChange: (event) => setWorkspaceSource(event.target.value), required: true },
889
+ h("option", { value: "workspace" }, "Known workspace"),
890
+ h("option", { value: "path", disabled: !instance?.workspacePolicy }, "Enter a path (policy required)"),
891
+ ),
892
+ ),
893
+ workspaceSource === "workspace"
894
+ ? h(
895
+ "label",
896
+ { className: "field" },
897
+ h("span", null, "Workspace"),
898
+ h(
899
+ "select",
900
+ { value: workspaceId, onChange: (event) => setWorkspaceId(event.target.value), required: true, disabled: readyWorkspaces.length === 0 },
901
+ readyWorkspaces.length
902
+ ? readyWorkspaces.map((workspace) => h("option", { value: workspace.id, key: `${workspace.id}:${workspace.canonicalPath}` }, workspace.name))
903
+ : h("option", { value: "" }, "No ready workspaces"),
904
+ ),
905
+ workspaceId && h("small", null, readyWorkspaces.find((workspace) => workspace.id === workspaceId)?.canonicalPath),
906
+ )
907
+ : h(
908
+ "label",
909
+ { className: "field" },
910
+ h("span", null, "Workspace path"),
911
+ h(
912
+ "div",
913
+ { className: "path-picker" },
914
+ h("input", {
915
+ value: workspacePath,
916
+ onChange: (event) => {
917
+ setWorkspacePath(event.target.value);
918
+ setActiveSuggestion(-1);
919
+ },
920
+ onKeyDown: handlePathKeyDown,
921
+ placeholder: instance?.workspacePolicy?.mode === "system" ? "/home/user/project" : "/approved/root/project",
922
+ required: true,
923
+ role: "combobox",
924
+ "aria-autocomplete": "list",
925
+ "aria-expanded": pathSuggestions.length > 0,
926
+ "aria-controls": "workspace-path-suggestions",
927
+ }),
928
+ suggesting && h("small", { className: "path-picker__status" }, "Finding folders…"),
929
+ suggestionError && h("small", { className: "path-picker__error" }, suggestionError),
930
+ pathSuggestions.length > 0 && h(
931
+ "div",
932
+ { id: "workspace-path-suggestions", className: "path-picker__suggestions", role: "listbox" },
933
+ pathSuggestions.map((suggestion, index) => h(
934
+ "button",
935
+ {
936
+ type: "button",
937
+ className: `path-picker__suggestion${index === activeSuggestion ? " path-picker__suggestion--active" : ""}`,
938
+ role: "option",
939
+ "aria-selected": index === activeSuggestion,
940
+ key: suggestion.path,
941
+ onMouseDown: (event) => event.preventDefault(),
942
+ onClick: () => choosePath(suggestion.path),
943
+ },
944
+ h("strong", null, suggestion.name),
945
+ h("small", null, suggestion.path),
946
+ )),
947
+ ),
948
+ ),
949
+ h("small", null, instance?.workspacePolicy?.mode === "system"
950
+ ? "System access is enabled for this instance."
951
+ : `Must exist on that host and stay inside: ${(instance?.workspacePolicy?.roots || []).join(", ") || "approved folders"}.`),
952
+ ),
953
+ h(
954
+ "label",
955
+ { className: "field" },
956
+ h("span", null, "Agent"),
957
+ h(
958
+ "select",
959
+ { value: agentId, onChange: (event) => setAgentId(event.target.value), required: true, disabled: readyAgents.length === 0 },
960
+ readyAgents.length
961
+ ? allAgents.map((agent) => h(
962
+ "option",
963
+ { value: agent.id, key: agent.id, disabled: !agent.ready },
964
+ agent.ready ? agent.name : `${agent.name} (not ready)`,
965
+ ))
966
+ : h("option", { value: "" }, "No ready agents"),
967
+ ),
968
+ ),
969
+ !readyAgents.length && allAgents.length > 0
970
+ && h("small", null, "Install and authenticate a provider CLI on this Worker before starting a session."),
971
+ h(
972
+ "label",
973
+ { className: "field" },
974
+ h("span", null, "Session name <em>optional</em>"),
975
+ h("input", { value: title, onChange: (event) => setTitle(event.target.value), placeholder: "e.g. Fix the auth flow", maxLength: 120 }),
976
+ ),
977
+ h(
978
+ "div",
979
+ { className: "dialog__actions" },
980
+ h("button", { className: "button button--quiet", type: "button", onClick: onClose }, "Cancel"),
981
+ h("button", {
982
+ className: "button button--primary",
983
+ type: "submit",
984
+ disabled: creating
985
+ || !instance
986
+ || instance.status !== "online"
987
+ || (workspaceSource === "workspace" ? !workspaceId : !workspacePath.trim())
988
+ || !agentId,
989
+ }, creating ? "Starting…" : "Start session"),
990
+ ),
991
+ ),
992
+ ),
993
+ );
994
+ }
995
+
996
+ ReactDOM.createRoot(document.getElementById("root")).render(h(App));
997
+ })();
@@ -0,0 +1,76 @@
1
+ (() => {
2
+ "use strict";
3
+
4
+ const bar = document.createElement("div");
5
+ bar.id = "hosted-agents-fleet-bar";
6
+ bar.style.cssText = [
7
+ "position:fixed",
8
+ "top:0",
9
+ "left:0",
10
+ "right:0",
11
+ "z-index:2147483647",
12
+ "display:flex",
13
+ "align-items:center",
14
+ "gap:10px",
15
+ "height:36px",
16
+ "padding:0 12px",
17
+ "box-sizing:border-box",
18
+ "background:#101722",
19
+ "border-bottom:1px solid #2b384b",
20
+ "color:#d7dee7",
21
+ "font:13px system-ui,sans-serif",
22
+ ].join(";");
23
+ const label = document.createElement("strong");
24
+ label.textContent = "Hosted Agents";
25
+ const select = document.createElement("select");
26
+ select.setAttribute("aria-label", "Codeman host");
27
+ select.style.cssText = [
28
+ "min-width:190px",
29
+ "padding:4px 8px",
30
+ "border:1px solid #405069",
31
+ "border-radius:6px",
32
+ "background:#090c10",
33
+ "color:#d7dee7",
34
+ ].join(";");
35
+ select.addEventListener("change", () => {
36
+ if (select.value) window.location.assign(`/api/fleet/select/${encodeURIComponent(select.value)}`);
37
+ });
38
+ const back = document.createElement("a");
39
+ back.href = "/api/fleet/clear";
40
+ back.textContent = "Fleet dashboard";
41
+ back.style.cssText = [
42
+ "margin-left:auto",
43
+ "color:#9ec1ff",
44
+ "text-decoration:none",
45
+ "white-space:nowrap",
46
+ ].join(";");
47
+ bar.append(label, select, back);
48
+ document.body.prepend(bar);
49
+ document.documentElement.style.paddingTop = "36px";
50
+
51
+ fetch("/api/instances", { headers: { accept: "application/json" } })
52
+ .then((response) => response.ok ? response.json() : Promise.reject(new Error("Unable to load hosts")))
53
+ .then((payload) => {
54
+ const instances = Array.isArray(payload.instances) ? payload.instances : [];
55
+ return fetch("/api/fleet/current")
56
+ .then((response) => response.json())
57
+ .then((current) => ({ instances, current }));
58
+ })
59
+ .then(({ instances, current }) => {
60
+ for (const instance of instances) {
61
+ const option = document.createElement("option");
62
+ option.value = String(instance.id);
63
+ option.textContent = `${instance.label} · ${instance.status}`;
64
+ option.selected = instance.id === current.instanceId;
65
+ select.append(option);
66
+ }
67
+ if (instances.length === 0) {
68
+ select.disabled = true;
69
+ select.title = "No Codeman hosts are registered";
70
+ }
71
+ })
72
+ .catch(() => {
73
+ select.disabled = true;
74
+ select.title = "Unable to load Codeman hosts";
75
+ });
76
+ })();
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#10141c">
7
+ <title>Codeman Fleet</title>
8
+ <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
9
+ <link rel="stylesheet" href="/styles.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
14
+ <script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
15
+ <script crossorigin src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
16
+ <script crossorigin src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
17
+ <script src="/app.js"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,190 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0c0f14;
4
+ --panel: #121720;
5
+ --panel-raised: #171e2a;
6
+ --panel-hover: #1b2533;
7
+ --line: #273140;
8
+ --text: #eef2f7;
9
+ --muted: #8d9aad;
10
+ --subtle: #5f6c7d;
11
+ --accent: #a7f3d0;
12
+ --accent-strong: #39d98a;
13
+ --danger: #ff8b8b;
14
+ --warning: #f5c76b;
15
+ --shadow: 0 20px 60px rgb(0 0 0 / 25%);
16
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
17
+ background: var(--bg);
18
+ color: var(--text);
19
+ }
20
+
21
+ * { box-sizing: border-box; }
22
+ body { margin: 0; min-width: 320px; background: var(--bg); }
23
+ button, input, select { font: inherit; }
24
+ button { cursor: pointer; }
25
+ button:disabled, input:disabled, select:disabled { cursor: not-allowed; opacity: .5; }
26
+
27
+ .app-shell { min-height: 100vh; }
28
+ .topbar {
29
+ display: flex; align-items: center; justify-content: space-between; gap: 24px;
30
+ height: 72px; padding: 0 28px; border-bottom: 1px solid var(--line);
31
+ background: rgb(12 15 20 / 88%); backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 3;
32
+ }
33
+ .brand { display: flex; align-items: center; gap: 12px; letter-spacing: -.02em; }
34
+ .brand__mark {
35
+ display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #32634d;
36
+ border-radius: 10px; color: var(--accent); background: #123126; font-size: 17px;
37
+ }
38
+ .brand strong, .brand span { display: block; }
39
+ .brand strong { font-size: 15px; }
40
+ .brand span { margin-top: 3px; color: var(--muted); font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
41
+ .connection-state { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 7px; }
42
+ .connection-state__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warning); }
43
+ .connection-state__dot--live { background: var(--accent-strong); box-shadow: 0 0 0 4px #39d98a20; }
44
+ .connection-state__dot--reconnecting { animation: pulse 1.5s infinite; }
45
+ .refresh-label { color: var(--subtle); }
46
+ .alert {
47
+ display: flex; align-items: center; gap: 14px; padding: 11px 28px; border-bottom: 1px solid #683d46;
48
+ background: #351c25; color: #ffd9dc; font-size: 13px;
49
+ }
50
+ .alert strong { white-space: nowrap; }
51
+ .alert span { color: #eaaeb5; overflow: hidden; text-overflow: ellipsis; }
52
+ .alert .button { margin-left: auto; }
53
+
54
+ .workspace-layout { display: grid; grid-template-columns: 304px minmax(0, 1fr); min-height: calc(100vh - 72px); }
55
+ .sidebar { display: flex; flex-direction: column; border-right: 1px solid var(--line); background: #0f131a; min-width: 0; }
56
+ .sidebar__heading { display: flex; align-items: end; justify-content: space-between; padding: 28px 20px 15px; }
57
+ .eyebrow { display: block; color: var(--accent-strong); font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
58
+ h1, h2, p { margin: 0; }
59
+ .sidebar h1 { margin-top: 6px; font-size: 21px; letter-spacing: -.04em; }
60
+ .count-pill { display: grid; place-items: center; min-width: 25px; height: 25px; padding: 0 7px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); font-size: 12px; }
61
+ .instance-list { padding: 0 10px; overflow: auto; }
62
+ .instance-group { margin-bottom: 7px; }
63
+ .instance-card, .session-row {
64
+ width: 100%; border: 0; color: var(--text); text-align: left; background: transparent;
65
+ }
66
+ .instance-card {
67
+ display: flex; align-items: center; gap: 10px; padding: 11px 10px; border-radius: 9px;
68
+ }
69
+ .instance-card:hover, .instance-card--selected { background: var(--panel-raised); }
70
+ .instance-card--selected { box-shadow: inset 2px 0 var(--accent-strong); }
71
+ .instance-card__copy, .session-row__copy { min-width: 0; flex: 1; }
72
+ .instance-card strong, .session-row strong { display: block; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
73
+ .instance-card small, .session-row small { display: block; margin-top: 4px; color: var(--subtle); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
74
+ .instance-card__chevron { color: var(--subtle); font-size: 21px; line-height: 1; }
75
+ .status-dot { display: inline-block; flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
76
+ .status-dot--online, .status-dot--working { background: var(--accent-strong); }
77
+ .status-dot--offline, .status-dot--stopped { background: var(--subtle); }
78
+ .status-dot--unhealthy, .status-dot--waiting { background: var(--warning); }
79
+ .status-dot--revoked, .status-dot--failed { background: var(--danger); }
80
+ .session-row {
81
+ display: flex; align-items: center; gap: 10px; padding: 9px 10px 9px 29px; color: var(--muted); border-radius: 7px;
82
+ }
83
+ .session-row:hover, .session-row--selected { color: var(--text); background: #18202c; }
84
+ .session-row__indicator { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; background: var(--subtle); }
85
+ .session-row__indicator--working { background: var(--accent-strong); }
86
+ .session-row__indicator--waiting { background: var(--warning); }
87
+ .session-row__indicator--failed { background: var(--danger); }
88
+ .needs-input-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 0 4px #f5c76b18; }
89
+ .sidebar__footer { display: flex; justify-content: space-between; gap: 10px; padding: 15px 20px; margin-top: auto; border-top: 1px solid var(--line); color: var(--subtle); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
90
+ .skeleton-list { padding: 5px 20px; }
91
+ .skeleton-list span { display: block; height: 51px; margin-bottom: 7px; border-radius: 9px; background: linear-gradient(90deg, #151b24, #202936, #151b24); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
92
+ .empty-state { display: grid; justify-items: center; gap: 7px; padding: 36px 20px; text-align: center; }
93
+ .empty-state__icon { color: var(--accent); font-size: 23px; }
94
+ .empty-state h2 { font-size: 14px; }
95
+ .empty-state p { max-width: 210px; color: var(--muted); font-size: 12px; line-height: 1.5; }
96
+
97
+ .main-content { min-width: 0; padding: 40px clamp(22px, 5vw, 80px); background: radial-gradient(circle at 85% 15%, #17262a55, transparent 34%), var(--bg); }
98
+ .welcome-panel, .instance-overview { max-width: 900px; margin: 8vh auto 0; }
99
+ .welcome-panel { display: flex; align-items: center; justify-content: space-between; gap: 40px; min-height: 320px; padding: clamp(28px, 6vw, 68px); border: 1px solid var(--line); border-radius: 20px; background: linear-gradient(130deg, #151c27, #10151d); box-shadow: var(--shadow); overflow: hidden; }
100
+ .welcome-panel__content { max-width: 540px; position: relative; z-index: 1; }
101
+ .welcome-panel h2 { margin: 12px 0 14px; max-width: 480px; font-size: clamp(28px, 4vw, 46px); line-height: 1.03; letter-spacing: -.06em; }
102
+ .welcome-panel p { max-width: 490px; color: var(--muted); font-size: 15px; line-height: 1.65; }
103
+ .welcome-panel .button { margin-top: 28px; }
104
+ .welcome-panel__orb { display: grid; place-items: center; width: 200px; height: 200px; border: 1px solid #3e7f61; border-radius: 50%; color: var(--accent); background: radial-gradient(circle, #27533f, #15291f 57%, transparent 58%); box-shadow: 0 0 80px #42d28d22; font-size: 38px; opacity: .8; }
105
+ .instance-overview { padding: 30px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
106
+ .instance-overview__title { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
107
+ .instance-overview h2 { font-size: 25px; letter-spacing: -.04em; }
108
+ .instance-overview > p { margin-top: 10px; color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
109
+ .overview-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 28px 0; }
110
+ .overview-grid div { padding: 14px; border: 1px solid var(--line); border-radius: 9px; background: #0e131a; }
111
+ .overview-grid span, .overview-grid strong { display: block; }
112
+ .overview-grid span { color: var(--subtle); font-size: 11px; }
113
+ .overview-grid strong { margin-top: 5px; font-size: 16px; }
114
+ .overview-actions { display: flex; flex-wrap: wrap; gap: 9px; }
115
+
116
+ .session-detail { max-width: 1100px; margin: 0 auto; }
117
+ .session-detail__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
118
+ .session-detail__actions { display: flex; flex-wrap: wrap; gap: 9px; }
119
+ .breadcrumb { display: block; color: var(--subtle); font-size: 12px; }
120
+ .session-detail h2 { margin-top: 9px; font-size: clamp(23px, 3vw, 32px); letter-spacing: -.05em; }
121
+ .session-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 11px; margin-top: 12px; color: var(--muted); font-size: 12px; }
122
+ .session-id { color: var(--subtle); font-family: ui-monospace, monospace; }
123
+ .status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); font-size: 11px; }
124
+ .status-badge--online, .status-badge--working { border-color: #32634d; color: var(--accent); }
125
+ .status-badge--waiting { border-color: #6e5c31; color: var(--warning); }
126
+ .status-badge--failed { border-color: #6e3943; color: var(--danger); }
127
+ .terminal { height: min(68vh, 720px); min-height: 420px; display: flex; flex-direction: column; border: 1px solid #303b4a; border-radius: 12px; background: #090c10; box-shadow: var(--shadow); overflow: hidden; }
128
+ .terminal__bar { display: flex; justify-content: space-between; padding: 12px 15px; border-bottom: 1px solid #202a36; color: var(--subtle); font-family: ui-monospace, monospace; font-size: 10px; letter-spacing: .08em; }
129
+ .terminal__output { flex: 1; min-height: 0; margin: 0; padding: 14px; color: #c7f9de; font: 13px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-y: auto; overflow-x: hidden; overflow-wrap: anywhere; }
130
+ .terminal__output .xterm { height: 100%; padding: 10px 12px 0; }
131
+ .terminal__output .xterm-viewport { overflow-y: auto !important; }
132
+ .terminal__placeholder { color: var(--subtle); }
133
+
134
+ .button { border: 1px solid transparent; border-radius: 8px; padding: 9px 13px; color: var(--text); font-size: 12px; font-weight: 600; transition: .15s ease; }
135
+ .button:hover:not(:disabled) { transform: translateY(-1px); }
136
+ .button--primary { border-color: #75d8aa; color: #07140e; background: var(--accent); }
137
+ .button--quiet { border-color: var(--line); color: var(--muted); background: transparent; }
138
+ .button--quiet:hover:not(:disabled) { color: var(--text); background: var(--panel-raised); }
139
+ .button--danger { border-color: #713b46; color: var(--danger); background: #301a21; }
140
+ .button--terminal { padding: 7px 10px; border-color: #32634d; color: var(--accent); background: #153126; }
141
+ .icon-button { border: 0; color: var(--muted); background: transparent; font-size: 25px; line-height: 1; }
142
+ .icon-button:hover { color: var(--text); }
143
+
144
+ .dialog-backdrop { position: fixed; inset: 0; z-index: 5; display: grid; place-items: center; padding: 20px; background: rgb(3 5 8 / 75%); backdrop-filter: blur(6px); }
145
+ .dialog { width: min(100%, 480px); border: 1px solid var(--line); border-radius: 15px; background: var(--panel-raised); box-shadow: var(--shadow); }
146
+ .dialog__header { display: flex; align-items: flex-start; justify-content: space-between; padding: 24px 24px 10px; }
147
+ .dialog h2 { margin-top: 6px; font-size: 23px; letter-spacing: -.04em; }
148
+ .dialog form { padding: 12px 24px 24px; }
149
+ .field { display: block; margin-top: 15px; }
150
+ .field > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 12px; font-weight: 600; }
151
+ .field em { color: var(--subtle); font-size: 10px; font-style: normal; font-weight: 400; }
152
+ .field input, .field select { width: 100%; border: 1px solid var(--line); border-radius: 7px; outline: 0; padding: 10px; color: var(--text); background: #0e131a; font-size: 13px; }
153
+ .field input:focus, .field select:focus { border-color: var(--accent-strong); box-shadow: 0 0 0 3px #39d98a20; }
154
+ .field small { display: block; margin-top: 6px; color: var(--subtle); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
155
+ .field small .status-badge { padding: 2px 5px; font-size: 10px; }
156
+ .path-picker { position: relative; }
157
+ .path-picker > input { position: relative; z-index: 1; }
158
+ .path-picker__status, .path-picker__error { white-space: normal; }
159
+ .path-picker__error { color: var(--danger) !important; }
160
+ .path-picker__suggestions { position: absolute; inset: calc(100% - 5px) 0 auto; z-index: 2; max-height: 190px; overflow: auto; padding: 5px; border: 1px solid var(--line); border-radius: 0 0 8px 8px; background: #101720; box-shadow: var(--shadow); }
161
+ .path-picker__suggestion { display: block; width: 100%; border: 0; border-radius: 5px; padding: 8px 9px; color: var(--text); text-align: left; background: transparent; }
162
+ .path-picker__suggestion:hover, .path-picker__suggestion--active { background: var(--panel-hover); }
163
+ .path-picker__suggestion strong, .path-picker__suggestion small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
164
+ .path-picker__suggestion strong { font-size: 12px; font-weight: 600; }
165
+ .path-picker__suggestion small { margin-top: 2px; color: var(--subtle); font-family: ui-monospace, monospace; font-size: 10px; }
166
+ .dialog__actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 27px; }
167
+
168
+ @keyframes shimmer { to { background-position: -200% 0; } }
169
+ @keyframes pulse { 50% { opacity: .35; } }
170
+ @media (max-width: 720px) {
171
+ .topbar { height: 62px; padding: 0 16px; }
172
+ .workspace-layout { grid-template-columns: 1fr; min-height: calc(100vh - 62px); }
173
+ .sidebar { max-height: 43vh; border-right: 0; border-bottom: 1px solid var(--line); }
174
+ .sidebar__heading { padding: 18px 16px 10px; }
175
+ .sidebar__footer { padding: 11px 16px; }
176
+ .instance-list { max-height: 29vh; }
177
+ .main-content { padding: 25px 16px 40px; }
178
+ .welcome-panel { margin-top: 15px; padding: 30px 23px; }
179
+ .welcome-panel__orb { display: none; }
180
+ .instance-overview { margin-top: 15px; padding: 22px; }
181
+ .overview-grid { grid-template-columns: repeat(3, 1fr); }
182
+ .overview-grid div { padding: 10px; }
183
+ .overview-grid strong { font-size: 14px; }
184
+ .session-detail__header { display: block; }
185
+ .session-detail__header .button { margin-top: 18px; }
186
+ .terminal { height: 58vh; min-height: 360px; }
187
+ .terminal__output { min-height: 0; padding: 8px; }
188
+ .alert { align-items: flex-start; flex-wrap: wrap; padding: 10px 16px; }
189
+ .alert .button { margin-left: 0; }
190
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@cloudagentfleet/ui",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "Cloud Agent Fleet Hub UI assets.",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ "./package.json": "./package.json",
11
+ "./public/*": "./dist/public/*"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "registry": "https://registry.npmjs.org/"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.6.0"
19
+ },
20
+ "scripts": {
21
+ "build": "node ../../scripts/build-release.mjs",
22
+ "dev": "node --watch --check public/app.js",
23
+ "test": "node --test",
24
+ "typecheck": "node --check public/app.js"
25
+ }
26
+ }