breakglass UI v2: attachable sessions (tmux model) + mobile-first redesign
Full audit-driven rework. Keeps the proven SSE-translation + verb logic; everything else upgraded for phone-primary use.
Backend — server owns the session, clients attach (Viktor's tmux idea):
- session.py: SessionManager + Session with an event log, subscriber pub/sub, and turns that run DETACHED (keep going if the client disconnects).
- GET /api/session/{id}/stream = attach (SSE): replays the transcript then tails live; per-event id: lines so an EventSource auto-reconnect resumes from Last-Event-ID (free re-attach). POST /{id}/prompt starts a detached turn; POST /{id}/cancel = Stop. Replaces the old one-shot /api/chat.
- agent_session trimmed to the argv + translate_event helpers; 21 new/updated tests (replay, Last-Event-ID resume, broadcast, detached turn, resume, cancel, routes) — 53 green.
Frontend — mobile-first via the frontend-design skill (emergency-console aesthetic):
- EventSource attach (native auto-reconnect, zero client reconnect logic); transcript.js folds events->messages with id-dedupe so replays never double-render (30 unit assertions).
- Installable PWA: manifest + icons (wrench/break-glass mark) + apple-mobile-web-app meta + theme-color; viewport-fit=cover + safe-area; 100dvh; 16px composer (no iOS zoom).
- One-tap diagnosis presets (Triage / Memory-OOM / Disk / Services / QEMU-wedged) mapped to the devvm's real failure modes; Stop button while a turn runs.
- Foldable VM-control sheet, cycle the dominant recovery action w/ confirm, output capped 46vh.
- a11y: fixed --ink-faint contrast 3.6:1 -> 6.1:1 (WCAG AA); >=44px tap targets. Deleted the obsolete fetch-reader sse.js (EventSource replaces it).
Verified: 53 backend tests + 30 transcript assertions; Playwright @390x844 (input on-screen y=721-821, presets/sheet/fold/cap); local integration smoke vs the real backend (attach->caught-up, 404, verbs, PWA served).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9d8afdd884
commit
5b5daa4bea
30 changed files with 1961 additions and 968 deletions
|
|
@ -2,9 +2,28 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- viewport-fit=cover so the app paints edge-to-edge and we can honour the
|
||||
notch/home-indicator via env(safe-area-inset-*). maximum-scale + no
|
||||
user-scaling keeps the cockpit layout stable under stress on mobile. -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
<!-- PWA / installable. theme-color tints the mobile status bar to the dark
|
||||
theme; black-translucent lets the app draw under the iOS status bar. -->
|
||||
<meta name="theme-color" content="#06080b" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="breakglass" />
|
||||
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="./icon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
||||
|
||||
<title>devvm breakglass</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
64
frontend/public/icon.svg
Normal file
64
frontend/public/icon.svg
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="devvm breakglass">
|
||||
<defs>
|
||||
<!-- layered near-black surface, matching the app theme -->
|
||||
<radialGradient id="bg" cx="68%" cy="22%" r="92%">
|
||||
<stop offset="0%" stop-color="#12303a"/>
|
||||
<stop offset="42%" stop-color="#0b0f14"/>
|
||||
<stop offset="100%" stop-color="#06080b"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="steel" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7df0f3"/>
|
||||
<stop offset="55%" stop-color="#3dd1d6"/>
|
||||
<stop offset="100%" stop-color="#1f6f72"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- rounded-square field (safe for maskable: art kept within central ~80%) -->
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<rect x="6" y="6" width="500" height="500" rx="108" fill="none" stroke="#1c2530" stroke-width="3"/>
|
||||
<!-- faint scanline texture -->
|
||||
<g opacity="0.05" stroke="#ffffff" stroke-width="2">
|
||||
<line x1="0" y1="148" x2="512" y2="148"/>
|
||||
<line x1="0" y1="220" x2="512" y2="220"/>
|
||||
<line x1="0" y1="292" x2="512" y2="292"/>
|
||||
<line x1="0" y1="364" x2="512" y2="364"/>
|
||||
</g>
|
||||
|
||||
<!-- fracture burst (amber): the "break the glass" radiating cracks -->
|
||||
<g stroke="#f5b657" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"
|
||||
fill="none" opacity="0.92" filter="url(#glow)">
|
||||
<path d="M256 256 L142 132"/>
|
||||
<path d="M256 256 L120 250"/>
|
||||
<path d="M256 256 L150 372"/>
|
||||
<path d="M256 256 L372 380"/>
|
||||
<path d="M256 256 L392 246"/>
|
||||
<path d="M256 256 L360 138"/>
|
||||
<!-- cross-cracks -->
|
||||
<path d="M186 196 L150 250"/>
|
||||
<path d="M210 320 L172 318" opacity="0.7"/>
|
||||
<path d="M326 318 L356 350" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- wrench, struck across the burst (cyan steel) -->
|
||||
<g filter="url(#glow)">
|
||||
<path fill="url(#steel)" stroke="#0e3133" stroke-width="6" stroke-linejoin="round"
|
||||
d="M344 150
|
||||
a62 62 0 0 0 -82 76
|
||||
L150 338
|
||||
a26 26 0 0 0 0 37
|
||||
l11 11
|
||||
a26 26 0 0 0 37 0
|
||||
l112 -112
|
||||
a62 62 0 0 0 76 -82
|
||||
l-41 41
|
||||
l-40 -11
|
||||
l-11 -40
|
||||
z"/>
|
||||
<!-- handle highlight -->
|
||||
<path d="M171 350 l128 -128" stroke="#bdf6f8" stroke-width="7" stroke-linecap="round" opacity="0.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
31
frontend/public/manifest.webmanifest
Normal file
31
frontend/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "devvm breakglass",
|
||||
"short_name": "breakglass",
|
||||
"description": "Emergency recovery console for the devvm — chat with a repair agent or power-cycle the VM directly.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#06080b",
|
||||
"theme_color": "#06080b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icon.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "./icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "./icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,100 +1,294 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { openSession } from './lib/api.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
openSession,
|
||||
attachStream,
|
||||
sendPrompt,
|
||||
cancelTurn,
|
||||
loadSessionId,
|
||||
saveSessionId,
|
||||
clearSessionId,
|
||||
} from './lib/api.js';
|
||||
import { createTranscript, reduceEvent } from './lib/transcript.js';
|
||||
import Chat from './Chat.svelte';
|
||||
import VmControls from './VmControls.svelte';
|
||||
|
||||
// ── session lifecycle ────────────────────────────────────────────────────
|
||||
// ── lifecycle state ───────────────────────────────────────────────────────
|
||||
// link: connecting | attached | error (the EventSource to the session)
|
||||
let link = $state('connecting');
|
||||
let linkError = $state('');
|
||||
let sessionId = $state('');
|
||||
let sessionState = $state('connecting'); // connecting | ready | error
|
||||
let sessionError = $state('');
|
||||
let streaming = $state(false);
|
||||
let caughtUp = $state(false); // replay drained → live tailing
|
||||
let turnActive = $state(false); // a turn is running (Stop shown, Send off)
|
||||
let sending = $state(false); // a prompt POST is in flight
|
||||
|
||||
// Mobile: the VM controls live in a slide-up sheet. Desktop: a side column
|
||||
// (CSS hides the toggle and pins the sheet open as a column ≥900px).
|
||||
// The transcript is folded with a plain mutable object; we bump `rev` to
|
||||
// notify the view of in-place mutations (cheaper than cloning the whole
|
||||
// message list on every streamed token). `tx` is $state too, so REASSIGNING
|
||||
// it (reset / new session) also propagates to the Chat prop. $state.raw keeps
|
||||
// the object un-proxied so the hot per-token path stays a plain mutation.
|
||||
let tx = $state.raw(createTranscript());
|
||||
let rev = $state(0);
|
||||
|
||||
let es = null; // the live EventSource
|
||||
|
||||
// Mobile: VM controls live in a slide-up sheet. Desktop (≥900px): a column.
|
||||
let showControls = $state(false);
|
||||
|
||||
async function newSession() {
|
||||
sessionState = 'connecting';
|
||||
sessionError = '';
|
||||
try {
|
||||
sessionId = await openSession();
|
||||
sessionState = 'ready';
|
||||
} catch (err) {
|
||||
sessionState = 'error';
|
||||
sessionError = err instanceof Error ? err.message : String(err);
|
||||
function resetTranscript() {
|
||||
tx = createTranscript();
|
||||
rev++;
|
||||
}
|
||||
|
||||
function onEvent(ev) {
|
||||
if (reduceEvent(tx, ev)) {
|
||||
// turn liveness tracks the folder's view of the stream, so a turn started
|
||||
// in ANOTHER tab (or before a reload) still flips us into "active".
|
||||
turnActive = tx.activeUserSeen;
|
||||
rev++;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(newSession);
|
||||
|
||||
function onLiveSession(id) {
|
||||
if (id) sessionId = id;
|
||||
function closeStream() {
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
}
|
||||
|
||||
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '────────');
|
||||
const dotState = $derived(
|
||||
sessionState === 'error' ? 'error' : streaming ? 'busy' : sessionState === 'ready' ? 'ready' : 'idle'
|
||||
function attach(id) {
|
||||
closeStream();
|
||||
sessionId = id;
|
||||
caughtUp = false;
|
||||
link = 'connecting';
|
||||
linkError = '';
|
||||
es = attachStream(id, {
|
||||
onOpen: () => {
|
||||
// a successful (re)connection clears any prior transient error
|
||||
if (link !== 'attached') link = 'attached';
|
||||
linkError = '';
|
||||
},
|
||||
onCaughtUp: () => {
|
||||
caughtUp = true;
|
||||
link = 'attached';
|
||||
},
|
||||
onEvent,
|
||||
onError: () => {
|
||||
// EventSource auto-reconnects on a transient drop (readyState
|
||||
// CONNECTING). Only a terminal CLOSED state is a hard failure. The
|
||||
// server keeps the turn running regardless, so we surface a soft note
|
||||
// and let the browser retry.
|
||||
if (es && es.readyState === EventSource.CLOSED) {
|
||||
link = 'error';
|
||||
linkError = 'lost the connection to the session — retrying…';
|
||||
// a closed source won't retry itself; re-attach to the same id.
|
||||
setTimeout(() => {
|
||||
if (sessionId === id) attach(id);
|
||||
}, 1500);
|
||||
} else {
|
||||
link = 'connecting';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
link = 'connecting';
|
||||
linkError = '';
|
||||
resetTranscript();
|
||||
const existing = loadSessionId();
|
||||
if (existing) {
|
||||
// Reuse the persisted id and attach. If it's gone (pod restart → 404 on
|
||||
// the stream), the EventSource errors; we detect the 404-shaped close and
|
||||
// mint a fresh session below.
|
||||
attach(existing);
|
||||
// Probe liveness: if the attach can't open within a grace window AND the
|
||||
// id is stale, create a new one. We rely on onError(CLOSED) for the 404.
|
||||
return;
|
||||
}
|
||||
await createFresh();
|
||||
}
|
||||
|
||||
async function createFresh() {
|
||||
try {
|
||||
link = 'connecting';
|
||||
const id = await openSession();
|
||||
saveSessionId(id);
|
||||
attach(id);
|
||||
} catch (err) {
|
||||
link = 'error';
|
||||
linkError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
// "New session": archive the local id, mint a new one, re-attach.
|
||||
async function newSession() {
|
||||
if (turnActive || sending) return;
|
||||
closeStream();
|
||||
clearSessionId();
|
||||
resetTranscript();
|
||||
turnActive = false;
|
||||
await createFresh();
|
||||
}
|
||||
|
||||
// Send a prompt (typed or a preset). Output arrives via the attach stream.
|
||||
async function submitPrompt(prompt) {
|
||||
const text = (prompt || '').trim();
|
||||
if (!text || turnActive || sending) return;
|
||||
if (!sessionId) {
|
||||
await createFresh();
|
||||
if (!sessionId) return;
|
||||
}
|
||||
sending = true;
|
||||
turnActive = true; // optimistic: the working indicator shows immediately
|
||||
try {
|
||||
const res = await sendPrompt({ session_id: sessionId, prompt: text });
|
||||
if (res.status === 'busy') {
|
||||
flash = 'A turn is already running.';
|
||||
// turn really is active; keep the indicator, the stream will end it.
|
||||
} else if (res.status === 'gone') {
|
||||
// session evaporated (pod restart). Re-create and resend once.
|
||||
clearSessionId();
|
||||
await createFresh();
|
||||
if (sessionId) await sendPrompt({ session_id: sessionId, prompt: text });
|
||||
}
|
||||
} catch (err) {
|
||||
flash = err instanceof Error ? err.message : String(err);
|
||||
turnActive = tx.activeUserSeen; // back off the optimistic flag on failure
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopTurn() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await cancelTurn(sessionId);
|
||||
// turn_end / cancelled events arrive via the stream and flip turnActive.
|
||||
} catch (err) {
|
||||
flash = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
// a transient toast (409 / network blips), auto-cleared
|
||||
let flash = $state('');
|
||||
let flashTimer;
|
||||
$effect(() => {
|
||||
if (flash) {
|
||||
clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => (flash = ''), 4200);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(bootstrap);
|
||||
onDestroy(closeStream);
|
||||
|
||||
// ── header status lamp ──────────────────────────────────────────────────
|
||||
// One quietly-living "system pulse": idle/connecting (cyan breathe),
|
||||
// working (amber pulse), error (steady red — the ONLY non-power red, used
|
||||
// sparingly for the lamp because connection loss IS the emergency here).
|
||||
const lamp = $derived(
|
||||
link === 'error'
|
||||
? 'error'
|
||||
: turnActive
|
||||
? 'working'
|
||||
: link === 'attached'
|
||||
? 'live'
|
||||
: 'connecting'
|
||||
);
|
||||
const lampLabel = $derived(
|
||||
{
|
||||
error: 'link down',
|
||||
working: 'agent working',
|
||||
live: 'attached',
|
||||
connecting: 'connecting',
|
||||
}[lamp]
|
||||
);
|
||||
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '········');
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<header class="rail">
|
||||
<header class="rail rise-in" style="--d:0ms">
|
||||
<div class="rail-title">
|
||||
<span class="glyph" aria-hidden="true">🔧</span>
|
||||
<h1>devvm <span class="accent">breakglass</span></h1>
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<!-- breakglass glyph: a wrench struck through a fracture line -->
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15.5 5.5a3.6 3.6 0 0 0-4.7 4.4L4 16.7 7.3 20l6.8-6.8a3.6 3.6 0 0 0 4.4-4.7l-2.2 2.2-2.2-.6-.6-2.2 2-2.6Z" />
|
||||
<path class="frac" d="M3 3l3.2 4.1L4.4 8.6 7 12" stroke-dasharray="2 2.4" />
|
||||
</svg>
|
||||
</span>
|
||||
<h1>devvm<span class="accent"> breakglass</span></h1>
|
||||
</div>
|
||||
|
||||
<div class="rail-right">
|
||||
<span class="rail-status">
|
||||
<span class="dot dot--{dotState}" aria-hidden="true"></span>
|
||||
{#if sessionState === 'error'}
|
||||
<span class="session-bad">offline</span>
|
||||
{:else if sessionState === 'connecting'}
|
||||
<span class="session-meta">connecting…</span>
|
||||
{:else}
|
||||
<code class="session-id" title={sessionId}>{shortId}</code>
|
||||
{/if}
|
||||
<span class="lamp-wrap" title={lampLabel}>
|
||||
<span class="lamp lamp--{lamp}" aria-hidden="true"></span>
|
||||
<span class="lamp-text lamp-text--{lamp}">
|
||||
{#if lamp === 'error'}
|
||||
link down
|
||||
{:else if lamp === 'working'}
|
||||
working
|
||||
{:else if lamp === 'live'}
|
||||
<code class="sid">{shortId}</code>
|
||||
{:else}
|
||||
connecting
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Mobile-only: open the VM control sheet. Hidden on desktop (column). -->
|
||||
<button
|
||||
class="controls-toggle"
|
||||
class="rail-btn rail-btn--vm"
|
||||
onclick={() => (showControls = true)}
|
||||
aria-label="Open direct VM controls"
|
||||
>
|
||||
⚡ <span class="controls-toggle-label">VM</span>
|
||||
<span class="bolt" aria-hidden="true">⚡</span><span class="rail-btn-label">VM</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="new-session"
|
||||
class="rail-btn"
|
||||
onclick={newSession}
|
||||
disabled={streaming || sessionState === 'connecting'}
|
||||
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
|
||||
disabled={turnActive || sending || link === 'connecting'}
|
||||
title={turnActive ? 'wait for the current turn to finish' : 'archive this session and start fresh'}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if sessionState === 'error'}
|
||||
<div class="rail-error" role="alert">
|
||||
Can't reach the breakglass backend — {sessionError}. The cluster or network
|
||||
may be down. The <strong>⚡ VM</strong> power controls still work without the chat.
|
||||
{#if link === 'error'}
|
||||
<div class="rail-note" role="alert">
|
||||
<span>{linkError || "Can't reach the breakglass backend."}</span>
|
||||
<span class="rail-note-aside">The <strong>⚡ VM</strong> power controls still work without the chat.</span>
|
||||
<button class="rail-note-retry" onclick={bootstrap}>Reconnect</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flash}
|
||||
<div class="toast" role="status">{flash}</div>
|
||||
{/if}
|
||||
|
||||
<main class="stage">
|
||||
<section class="chat-pane" aria-label="Recovery chat">
|
||||
<section class="chat-pane rise-in" style="--d:80ms" aria-label="Recovery chat">
|
||||
<Chat
|
||||
{sessionId}
|
||||
sessionReady={sessionState === 'ready'}
|
||||
{onLiveSession}
|
||||
onStreamingChange={(v) => (streaming = v)}
|
||||
{tx}
|
||||
{rev}
|
||||
{caughtUp}
|
||||
{turnActive}
|
||||
sending={sending}
|
||||
linkState={link}
|
||||
onSubmit={submitPrompt}
|
||||
onStop={stopTurn}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside class="controls-pane" class:open={showControls} aria-label="Direct VM control">
|
||||
<aside
|
||||
class="controls-pane rise-in"
|
||||
class:open={showControls}
|
||||
style="--d:160ms"
|
||||
aria-label="Direct VM control"
|
||||
>
|
||||
<div class="sheet-grip" aria-hidden="true"></div>
|
||||
<div class="controls-head">
|
||||
<span class="controls-head-title">Direct VM control</span>
|
||||
|
|
@ -104,7 +298,6 @@
|
|||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- backdrop behind the mobile sheet -->
|
||||
<button
|
||||
class="sheet-backdrop"
|
||||
class:show={showControls}
|
||||
|
|
@ -119,43 +312,51 @@
|
|||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1500px;
|
||||
max-width: 1520px;
|
||||
margin: 0 auto;
|
||||
/* honour the notch on landscape / edge-to-edge */
|
||||
padding-left: var(--safe-left);
|
||||
padding-right: var(--safe-right);
|
||||
}
|
||||
|
||||
/* ── status rail (compact, single row on mobile) ─────────────────────── */
|
||||
/* ── status rail ───────────────────────────────────────────────────────── */
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
padding: max(10px, var(--safe-top)) 14px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(61, 209, 214, 0.03), transparent 60%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
|
||||
flex: none;
|
||||
}
|
||||
.rail-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.glyph {
|
||||
font-size: 17px;
|
||||
transform: translateY(2px);
|
||||
filter: saturate(0.85);
|
||||
.brand-mark {
|
||||
color: var(--cyan);
|
||||
display: inline-flex;
|
||||
filter: drop-shadow(0 0 10px rgba(61, 209, 214, 0.35));
|
||||
flex: none;
|
||||
}
|
||||
.brand-mark .frac { color: var(--amber); stroke: var(--amber); opacity: 0.85; }
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.accent {
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
|
||||
text-shadow: 0 0 18px rgba(61, 209, 214, 0.4);
|
||||
}
|
||||
|
||||
.rail-right {
|
||||
|
|
@ -164,90 +365,158 @@
|
|||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.rail-status {
|
||||
|
||||
/* the living system-pulse lamp */
|
||||
.lamp-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-id {
|
||||
color: var(--cyan);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.session-meta {
|
||||
color: var(--amber);
|
||||
}
|
||||
.session-bad {
|
||||
color: var(--danger-bright);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
.lamp {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
.dot--ready {
|
||||
/* a soft halo ring that pulses outward — the "instrument is powered" tell */
|
||||
.lamp::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0;
|
||||
}
|
||||
.lamp--live {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.6);
|
||||
animation: breathe 3.4s ease-in-out infinite;
|
||||
color: var(--cyan);
|
||||
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.65);
|
||||
animation: lamp-breathe 3.6s ease-in-out infinite;
|
||||
}
|
||||
.dot--busy {
|
||||
.lamp--live::after { animation: lamp-ring 3.6s ease-out infinite; }
|
||||
.lamp--connecting {
|
||||
background: var(--cyan-dim);
|
||||
color: var(--cyan);
|
||||
animation: lamp-blink 1.4s ease-in-out infinite;
|
||||
}
|
||||
.lamp--working {
|
||||
background: var(--amber);
|
||||
color: var(--amber);
|
||||
box-shadow: 0 0 10px 1px rgba(245, 182, 87, 0.7);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
animation: lamp-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.dot--error {
|
||||
.lamp--working::after { animation: lamp-ring 1s ease-out infinite; }
|
||||
.lamp--error {
|
||||
background: var(--danger);
|
||||
color: var(--danger);
|
||||
box-shadow: 0 0 10px 1px var(--danger-glow);
|
||||
animation: lamp-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(0.82); opacity: 0.7; }
|
||||
50% { transform: scale(1.15); opacity: 1; }
|
||||
@keyframes lamp-breathe { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
|
||||
@keyframes lamp-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.9; } }
|
||||
@keyframes lamp-pulse {
|
||||
0%, 100% { transform: scale(0.82); opacity: 0.75; }
|
||||
50% { transform: scale(1.12); opacity: 1; }
|
||||
}
|
||||
@keyframes lamp-ring {
|
||||
0% { opacity: 0.5; transform: scale(0.6); }
|
||||
70% { opacity: 0; transform: scale(1.8); }
|
||||
100% { opacity: 0; transform: scale(1.8); }
|
||||
}
|
||||
.lamp-text {
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-dim);
|
||||
max-width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lamp-text--live .sid { color: var(--cyan); letter-spacing: 0.06em; }
|
||||
.lamp-text--working { color: var(--amber); }
|
||||
.lamp-text--error { color: var(--danger-bright); }
|
||||
.lamp-text--connecting { color: var(--ink-faint); }
|
||||
.sid { font-family: var(--mono); }
|
||||
/* On the tightest phones the title + lamp text + two buttons crowd; keep the
|
||||
living dot (the system pulse) and drop the text label until there's room. */
|
||||
@media (max-width: 439px) {
|
||||
.lamp-text { display: none; }
|
||||
.lamp-wrap { padding: 0; }
|
||||
}
|
||||
|
||||
/* touch-friendly buttons */
|
||||
.controls-toggle,
|
||||
.new-session {
|
||||
min-height: 40px;
|
||||
padding: 0 13px;
|
||||
/* rail buttons — touch-first (≥44px tall via padding + line height) */
|
||||
.rail-btn {
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-dim);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.03em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
.controls-toggle {
|
||||
border-color: #5a4a2a;
|
||||
.rail-btn:hover:not(:disabled) { border-color: var(--line-bright); color: var(--ink); }
|
||||
.rail-btn:active:not(:disabled) { background: var(--bg-3); }
|
||||
.rail-btn:disabled { opacity: 0.42; }
|
||||
.rail-btn--vm {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--amber);
|
||||
}
|
||||
.controls-toggle:active,
|
||||
.new-session:active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.new-session:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.rail-btn--vm:hover:not(:disabled) { border-color: var(--amber); color: var(--amber); }
|
||||
.bolt { font-size: 13px; line-height: 1; }
|
||||
|
||||
.rail-error {
|
||||
.rail-note {
|
||||
margin: 10px 12px 0;
|
||||
padding: 11px 14px;
|
||||
padding: 10px 13px;
|
||||
border: 1px solid var(--danger-deep);
|
||||
border-left-width: 3px;
|
||||
background: rgba(255, 77, 77, 0.07);
|
||||
color: #ffd5d5;
|
||||
color: #ffd9d9;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 12px;
|
||||
flex: none;
|
||||
}
|
||||
.rail-note-aside { color: #f0b8b8; }
|
||||
.rail-note-aside strong { color: #fff; font-family: var(--mono); }
|
||||
.rail-note-retry {
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--danger-deep);
|
||||
background: transparent;
|
||||
color: var(--danger-bright);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.rail-note-retry:hover { background: rgba(255, 77, 77, 0.12); }
|
||||
|
||||
.toast {
|
||||
margin: 10px 12px 0;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-left: 3px solid var(--amber);
|
||||
background: var(--bg-2);
|
||||
color: var(--amber);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
flex: none;
|
||||
animation: rise-in 0.28s ease-out both;
|
||||
}
|
||||
|
||||
/* ── stage ───────────────────────────────────────────────────────────── */
|
||||
.stage {
|
||||
|
|
@ -271,31 +540,37 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
max-height: 86dvh;
|
||||
overflow-y: auto;
|
||||
max-height: 88dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
border-top: 1px solid var(--line-strong);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.55);
|
||||
padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
|
||||
transform: translateY(101%);
|
||||
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: var(--shadow-sheet);
|
||||
padding: 8px 14px calc(14px + var(--safe-bottom));
|
||||
transform: translateY(102%);
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
/* the rise-in entrance is for the desktop column; the sheet is transform-
|
||||
controlled, so cancel the shared keyframe here. */
|
||||
animation: none !important;
|
||||
}
|
||||
.controls-pane.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.sheet-grip {
|
||||
width: 38px;
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--line-strong);
|
||||
background: var(--line-bright);
|
||||
margin: 4px auto 10px;
|
||||
flex: none;
|
||||
}
|
||||
.controls-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.controls-head-title {
|
||||
font-family: var(--mono);
|
||||
|
|
@ -305,14 +580,15 @@
|
|||
color: var(--amber);
|
||||
}
|
||||
.sheet-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
.sheet-close:active { background: var(--bg-3); }
|
||||
|
||||
.sheet-backdrop {
|
||||
position: fixed;
|
||||
|
|
@ -320,40 +596,40 @@
|
|||
z-index: 30;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: rgba(2, 4, 7, 0.62);
|
||||
backdrop-filter: blur(1.5px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s;
|
||||
transition: opacity 0.24s;
|
||||
}
|
||||
.sheet-backdrop.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── desktop: controls become a static side column, sheet chrome gone ── */
|
||||
/* ── desktop: controls become a static side column ─────────────────────── */
|
||||
@media (min-width: 900px) {
|
||||
.rail {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.rail { padding: 14px 18px; }
|
||||
h1 { font-size: 19px; }
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 372px;
|
||||
grid-template-columns: minmax(0, 1fr) 384px;
|
||||
gap: 16px;
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.chat-pane { display: flex; }
|
||||
.controls-toggle { display: none; }
|
||||
.rail-btn--vm { display: none; }
|
||||
.controls-pane {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
z-index: auto;
|
||||
animation: rise-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both !important;
|
||||
animation-delay: var(--d, 0ms) !important;
|
||||
}
|
||||
.sheet-grip,
|
||||
.controls-head,
|
||||
|
|
|
|||
|
|
@ -1,128 +1,105 @@
|
|||
<script>
|
||||
import { tick } from 'svelte';
|
||||
import { streamChat } from './lib/api.js';
|
||||
import ToolChip from './ToolChip.svelte';
|
||||
|
||||
let {
|
||||
sessionId = '',
|
||||
sessionReady = false,
|
||||
onLiveSession = (/** @type {string} */ _id) => {},
|
||||
onStreamingChange = (/** @type {boolean} */ _v) => {},
|
||||
tx, // the folded transcript state (plain object, see lib/transcript.js)
|
||||
rev = 0, // bumped on every in-place mutation to retrigger reactivity
|
||||
caughtUp = false, // replay drained → staggered reveal may run
|
||||
turnActive = false, // a turn is running: show Stop, hide Send
|
||||
sending = false, // a prompt POST is in flight (brief)
|
||||
linkState = 'connecting', // connecting | attached | error
|
||||
onSubmit = (/** @type {string} */ _p) => {},
|
||||
onStop = () => {},
|
||||
} = $props();
|
||||
|
||||
/**
|
||||
* Message model. A user message is plain text. An assistant message is an
|
||||
* ordered list of parts so streamed prose and tool chips interleave in the
|
||||
* exact order the agent emitted them:
|
||||
* { role:'assistant', parts:[{type:'text',text}|{type:'tool',name,command}],
|
||||
* result?: {is_error, text, duration_ms}, error?: string }
|
||||
* @type {Array<any>}
|
||||
*/
|
||||
let messages = $state([]);
|
||||
// The five quick-action presets — the mobile win: one tap, no typing.
|
||||
const PRESETS = [
|
||||
{
|
||||
label: 'Triage',
|
||||
icon: '◑',
|
||||
prompt:
|
||||
'Triage the devvm: uptime, load, memory, swap, disk usage, failed systemd units, and the last 30 lines of dmesg. Summarize what\'s wrong.',
|
||||
},
|
||||
{
|
||||
label: 'Memory / OOM',
|
||||
icon: '▦',
|
||||
prompt:
|
||||
'Check devvm memory pressure: free -h, top memory consumers, any recent OOM-kills in dmesg/journal, and swap usage. Is it OOMing?',
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
icon: '▤',
|
||||
prompt:
|
||||
'What\'s filling the devvm disk? df -h, then the biggest directories/files under the fullest mount. Anything safe to clear?',
|
||||
},
|
||||
{
|
||||
label: 'Services',
|
||||
icon: '⚙',
|
||||
prompt:
|
||||
'List failed or stuck systemd units on the devvm (systemctl --failed) and show the status + recent journal lines for any that are down.',
|
||||
},
|
||||
{
|
||||
label: 'QEMU wedged?',
|
||||
icon: '◫',
|
||||
prompt:
|
||||
'Is the devvm\'s QEMU wedged (I/O stall)? Check guest responsiveness over SSH, then ssh pve forensics for VM 102\'s qm status/QMP/guest-agent. Tell me if a cycle is needed.',
|
||||
},
|
||||
];
|
||||
|
||||
let draft = $state('');
|
||||
let streaming = $state(false);
|
||||
let scroller; // the scroll viewport
|
||||
let scroller;
|
||||
let inputEl;
|
||||
let pinnedToBottom = true; // auto-scroll only while the user is at the bottom
|
||||
let pinnedToBottom = true;
|
||||
|
||||
const canSend = $derived(sessionReady && !streaming && draft.trim().length > 0);
|
||||
// re-derive the message list whenever the folder mutates (rev bump). The
|
||||
// transcript is folded with in-place mutation on a $state.raw object, so no
|
||||
// reference changes on its own — we depend on `rev` explicitly and rebuild
|
||||
// fresh objects (message + its parts array) so Svelte's keyed {#each} re-
|
||||
// renders streamed prose/chips on every token. Transcripts are small; the
|
||||
// per-token copy is cheap and keeps the hot streaming path bug-free.
|
||||
const messages = $derived(
|
||||
rev >= 0 && tx
|
||||
? tx.messages.map((m) =>
|
||||
m.role === 'assistant' ? { ...m, parts: m.parts.slice() } : { ...m }
|
||||
)
|
||||
: []
|
||||
);
|
||||
const isEmpty = $derived(messages.length === 0);
|
||||
const canSend = $derived(linkState !== 'error' && !turnActive && draft.trim().length > 0);
|
||||
const inputReady = $derived(!turnActive);
|
||||
|
||||
// ── scrolling ─────────────────────────────────────────────────────────────
|
||||
// ── auto-scroll (only while pinned to the bottom) ─────────────────────────
|
||||
function onScroll() {
|
||||
if (!scroller) return;
|
||||
const gap = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
pinnedToBottom = gap < 60;
|
||||
pinnedToBottom = gap < 64;
|
||||
}
|
||||
async function scrollToBottom(force = false) {
|
||||
if (!force && !pinnedToBottom) return;
|
||||
await tick();
|
||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||
}
|
||||
|
||||
// ── streaming a turn ────────────────────────────────────────────────────────
|
||||
function lastAssistant() {
|
||||
return messages[messages.length - 1];
|
||||
}
|
||||
|
||||
function appendText(text) {
|
||||
const msg = lastAssistant();
|
||||
const parts = msg.parts;
|
||||
const tail = parts[parts.length - 1];
|
||||
if (tail && tail.type === 'text') {
|
||||
tail.text += text;
|
||||
} else {
|
||||
parts.push({ type: 'text', text });
|
||||
}
|
||||
messages = messages; // notify Svelte of the in-place mutation
|
||||
}
|
||||
|
||||
function handleEvent(ev) {
|
||||
switch (ev?.kind) {
|
||||
case 'session':
|
||||
onLiveSession(ev.session_id);
|
||||
break;
|
||||
case 'text':
|
||||
if (ev.text) appendText(ev.text);
|
||||
break;
|
||||
case 'tool': {
|
||||
// Bash carries a `command`; other tools just show their name.
|
||||
const command =
|
||||
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
|
||||
lastAssistant().parts.push({ type: 'tool', name: ev.name || 'tool', command });
|
||||
messages = messages;
|
||||
break;
|
||||
}
|
||||
case 'result':
|
||||
lastAssistant().result = {
|
||||
is_error: Boolean(ev.is_error),
|
||||
text: typeof ev.result === 'string' ? ev.result : '',
|
||||
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
|
||||
};
|
||||
messages = messages;
|
||||
break;
|
||||
case 'error':
|
||||
lastAssistant().error = ev.error || 'unknown error';
|
||||
messages = messages;
|
||||
break;
|
||||
case 'done':
|
||||
// handled by the stream completing; nothing to render
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// any transcript change → keep the view pinned if the user is at the bottom
|
||||
$effect(() => {
|
||||
rev; // track
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
function fire(prompt) {
|
||||
if (turnActive) return;
|
||||
pinnedToBottom = true;
|
||||
onSubmit(prompt);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const prompt = draft.trim();
|
||||
if (!prompt || streaming || !sessionReady) return;
|
||||
|
||||
messages.push({ role: 'user', text: prompt });
|
||||
messages.push({ role: 'assistant', parts: [] });
|
||||
messages = messages;
|
||||
function send() {
|
||||
const text = draft.trim();
|
||||
if (!text || turnActive) return;
|
||||
draft = '';
|
||||
streaming = true;
|
||||
onStreamingChange(true);
|
||||
pinnedToBottom = true;
|
||||
await scrollToBottom(true);
|
||||
|
||||
try {
|
||||
await streamChat({ session_id: sessionId, prompt }, handleEvent);
|
||||
} catch (err) {
|
||||
// Network/transport failure (backend down, connection dropped mid-stream).
|
||||
const msg = lastAssistant();
|
||||
if (msg && msg.role === 'assistant' && !msg.error) {
|
||||
msg.error =
|
||||
(err instanceof Error ? err.message : String(err)) +
|
||||
' — the connection to the agent failed.';
|
||||
messages = messages;
|
||||
}
|
||||
} finally {
|
||||
streaming = false;
|
||||
onStreamingChange(false);
|
||||
await scrollToBottom();
|
||||
inputEl?.focus();
|
||||
}
|
||||
fire(text);
|
||||
// restore single-row height after clearing
|
||||
tick().then(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
|
|
@ -130,7 +107,7 @@
|
|||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
// Shift+Enter falls through to insert a newline.
|
||||
// Shift+Enter → newline (default behaviour)
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
|
|
@ -139,7 +116,12 @@
|
|||
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
|
||||
}
|
||||
|
||||
const isEmpty = $derived(messages.length === 0);
|
||||
// a freshly-attached transcript reveals with a brief stagger; cap the delay
|
||||
// so a long replay doesn't animate forever.
|
||||
function revealDelay(i) {
|
||||
if (!caughtUp) return 0;
|
||||
return Math.min(i, 6) * 45;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat">
|
||||
|
|
@ -150,41 +132,58 @@
|
|||
|
||||
<div class="stream" bind:this={scroller} onscroll={onScroll}>
|
||||
{#if isEmpty}
|
||||
<div class="empty">
|
||||
<div class="empty-mark">⌁</div>
|
||||
<p class="empty-title">The agent is standing by.</p>
|
||||
<div class="empty" class:dim={linkState === 'connecting'}>
|
||||
<div class="empty-mark" aria-hidden="true">⌁</div>
|
||||
<p class="empty-title">
|
||||
{#if linkState === 'error'}
|
||||
The agent is unreachable.
|
||||
{:else if linkState === 'connecting'}
|
||||
Attaching to the session…
|
||||
{:else}
|
||||
The agent is standing by.
|
||||
{/if}
|
||||
</p>
|
||||
<p class="empty-sub">
|
||||
Describe the symptom — "devvm is unreachable", "disk full", "ssh hangs"
|
||||
— and it will connect over SSH, investigate, and stream its work here.
|
||||
For a hard power action when the agent can't help, use
|
||||
<strong>Direct VM control</strong>.
|
||||
{#if linkState === 'error'}
|
||||
The cluster or network may be down. You can still power-cycle the VM
|
||||
with <strong>⚡ Direct VM control</strong> — it needs no agent.
|
||||
{:else}
|
||||
Tap a preset below or describe the symptom — "devvm unreachable",
|
||||
"disk full", "ssh hangs" — and it will connect over SSH, investigate,
|
||||
and stream its work here. For a hard power action, use
|
||||
<strong>⚡ Direct VM control</strong>.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg, i (i)}
|
||||
{#each messages as msg (msg.key)}
|
||||
{#if msg.role === 'user'}
|
||||
<div class="row row--user">
|
||||
<div class="row row--user rise-in" style="--d:{revealDelay(0)}ms">
|
||||
<div class="bubble bubble--user">{msg.text}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="row row--assistant">
|
||||
<div class="row row--assistant rise-in" style="--d:{revealDelay(0)}ms">
|
||||
<div class="bubble bubble--assistant">
|
||||
{#if msg.parts.length === 0 && !msg.result && !msg.error}
|
||||
{#if msg.parts.length === 0 && !msg.result && !msg.error && !msg.cancelled}
|
||||
<span class="thinking" aria-label="working">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
{/if}
|
||||
{#each msg.parts as part, j (j)}
|
||||
{#if part.type === 'text'}
|
||||
<span class="prose">{part.text}</span>
|
||||
{:else}
|
||||
<ToolChip name={part.name} command={part.command} />
|
||||
{/if}
|
||||
{#if part.type === 'text'}<span class="prose">{part.text}</span>{:else}<ToolChip name={part.name} command={part.command} />{/if}
|
||||
{/each}
|
||||
|
||||
{#if msg.error}
|
||||
<div class="turn-note turn-note--error">⚠ {msg.error}</div>
|
||||
<div class="turn-note turn-note--error">
|
||||
<span class="turn-note-tag">error</span>
|
||||
<span class="turn-note-body">{msg.error}</span>
|
||||
</div>
|
||||
{:else if msg.cancelled}
|
||||
<div class="turn-note turn-note--muted">
|
||||
<span class="turn-note-tag">stopped</span>
|
||||
<span class="turn-note-body">turn cancelled</span>
|
||||
</div>
|
||||
{:else if msg.result}
|
||||
<div class="turn-note {msg.result.is_error ? 'turn-note--error' : 'turn-note--ok'}">
|
||||
<span class="turn-note-tag">{msg.result.is_error ? 'failed' : 'done'}</span>
|
||||
|
|
@ -200,36 +199,61 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="composer"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
{#if streaming}
|
||||
<div class="working-bar" aria-live="polite">
|
||||
<span class="working-dots"><span></span><span></span><span></span></span>
|
||||
agent working — streaming live
|
||||
</div>
|
||||
{/if}
|
||||
<div class="composer-row">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
bind:value={draft}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={sessionReady
|
||||
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
|
||||
: 'Waiting for a session…'}
|
||||
rows="1"
|
||||
disabled={!sessionReady || streaming}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<button type="submit" class="send" disabled={!canSend}>
|
||||
{streaming ? '…' : 'Send'}
|
||||
</button>
|
||||
<div class="dock">
|
||||
<!-- quick-action preset bar: horizontally scrollable, one-tap prompts -->
|
||||
<div class="presets" role="group" aria-label="Quick actions">
|
||||
{#each PRESETS as p (p.label)}
|
||||
<button
|
||||
class="preset"
|
||||
onclick={() => fire(p.prompt)}
|
||||
disabled={turnActive || linkState === 'error'}
|
||||
title={p.prompt}
|
||||
>
|
||||
<span class="preset-icon" aria-hidden="true">{p.icon}</span>
|
||||
<span class="preset-label">{p.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
class="composer"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
{#if turnActive}
|
||||
<div class="working-bar" aria-live="polite">
|
||||
<span class="working-dots"><span></span><span></span><span></span></span>
|
||||
<span>agent working — streaming live</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="composer-row">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
bind:value={draft}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={inputReady
|
||||
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
|
||||
: 'A turn is running — Stop it to type, or wait…'}
|
||||
rows="1"
|
||||
disabled={!inputReady}
|
||||
spellcheck="false"
|
||||
enterkeyhint="send"
|
||||
></textarea>
|
||||
{#if turnActive}
|
||||
<button type="button" class="stop" onclick={onStop} title="Stop the running turn">
|
||||
<span class="stop-glyph" aria-hidden="true"></span>
|
||||
Stop
|
||||
</button>
|
||||
{:else}
|
||||
<button type="submit" class="send" disabled={!canSend}>
|
||||
{sending ? '···' : 'Send'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -249,9 +273,10 @@
|
|||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 13px 18px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent);
|
||||
flex: none;
|
||||
}
|
||||
.chat-head-label {
|
||||
font-family: var(--mono);
|
||||
|
|
@ -263,13 +288,16 @@
|
|||
.chat-head-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stream {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 18px 8px;
|
||||
padding: 20px 16px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
|
@ -279,23 +307,27 @@
|
|||
/* empty state */
|
||||
.empty {
|
||||
margin: auto;
|
||||
max-width: 460px;
|
||||
max-width: 470px;
|
||||
text-align: center;
|
||||
padding: 28px 12px;
|
||||
padding: 24px 14px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.empty.dim { opacity: 0.8; }
|
||||
.empty-mark {
|
||||
font-size: 40px;
|
||||
font-size: 42px;
|
||||
color: var(--cyan-dim);
|
||||
line-height: 1;
|
||||
margin-bottom: 14px;
|
||||
text-shadow: 0 0 24px rgba(61, 209, 214, 0.25);
|
||||
text-shadow: 0 0 26px rgba(61, 209, 214, 0.3);
|
||||
animation: lamp-breathe 3.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes lamp-breathe { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
.empty-title {
|
||||
font-family: var(--mono);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 13px;
|
||||
|
|
@ -303,32 +335,23 @@
|
|||
color: var(--ink-faint);
|
||||
margin: 0;
|
||||
}
|
||||
.empty-sub strong {
|
||||
color: var(--ink-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty-sub strong { color: var(--ink-dim); font-weight: 600; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
.row--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.row--assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.row { display: flex; }
|
||||
.row--user { justify-content: flex-end; }
|
||||
.row--assistant { justify-content: flex-start; }
|
||||
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
max-width: 88%;
|
||||
border-radius: 13px;
|
||||
padding: 11px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.62;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bubble--user {
|
||||
background: linear-gradient(180deg, #15333a, #0f262c);
|
||||
background: linear-gradient(180deg, #123036, #0d2329);
|
||||
border: 1px solid var(--cyan-dim);
|
||||
color: #d8f6f7;
|
||||
border-bottom-right-radius: 4px;
|
||||
|
|
@ -341,12 +364,9 @@
|
|||
border-bottom-left-radius: 4px;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* prose renders inline so text and tool chips share the same flow */
|
||||
.prose {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.prose { white-space: pre-wrap; }
|
||||
|
||||
/* in-flight assistant "thinking" dots */
|
||||
/* in-flight "thinking" dots */
|
||||
.thinking,
|
||||
.working-dots {
|
||||
display: inline-flex;
|
||||
|
|
@ -363,19 +383,15 @@
|
|||
animation: blink 1.2s infinite ease-in-out;
|
||||
}
|
||||
.thinking span:nth-child(2),
|
||||
.working-dots span:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.working-dots span:nth-child(2) { animation-delay: 0.18s; }
|
||||
.thinking span:nth-child(3),
|
||||
.working-dots span:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
.working-dots span:nth-child(3) { animation-delay: 0.36s; }
|
||||
@keyframes blink {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* turn result / error footer inside the assistant bubble */
|
||||
/* turn result / error / stopped footer inside the assistant bubble */
|
||||
.turn-note {
|
||||
margin-top: 10px;
|
||||
padding: 7px 10px;
|
||||
|
|
@ -396,9 +412,16 @@
|
|||
color: #bff5d3;
|
||||
}
|
||||
.turn-note--error {
|
||||
background: rgba(255, 77, 77, 0.08);
|
||||
border: 1px solid var(--danger-deep);
|
||||
color: #ffd5d5;
|
||||
/* the error tint here is amber-leaning text on a faint warm wash, NOT the
|
||||
reserved power-action red — a turn error is not a destructive action. */
|
||||
background: rgba(245, 182, 87, 0.06);
|
||||
border: 1px solid var(--amber-dim);
|
||||
color: #f7d49a;
|
||||
}
|
||||
.turn-note--muted {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--line-strong);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.turn-note-tag {
|
||||
text-transform: uppercase;
|
||||
|
|
@ -409,20 +432,55 @@
|
|||
border: 1px solid currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.turn-note-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.turn-note-time {
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
.turn-note-body { flex: 1; min-width: 0; }
|
||||
.turn-note-time { margin-left: auto; color: var(--ink-faint); }
|
||||
|
||||
/* ── dock: presets + composer, pinned to the bottom ────────────────────── */
|
||||
.dock {
|
||||
flex: none;
|
||||
border-top: 1px solid var(--line);
|
||||
background: linear-gradient(0deg, rgba(255, 255, 255, 0.015), transparent);
|
||||
}
|
||||
|
||||
/* ── composer ─────────────────────────────────────────────────────────── */
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 11px 12px 4px;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* fade the right edge to hint there's more to scroll */
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 18px), transparent 100%);
|
||||
}
|
||||
.presets::-webkit-scrollbar { display: none; }
|
||||
.preset {
|
||||
flex: none;
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s, transform 0.06s;
|
||||
}
|
||||
.preset:hover:not(:disabled) {
|
||||
border-color: var(--cyan-dim);
|
||||
color: var(--ink);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.preset:active:not(:disabled) { transform: translateY(1px); }
|
||||
.preset:disabled { opacity: 0.4; }
|
||||
.preset-icon { color: var(--cyan); font-size: 12px; }
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
background: linear-gradient(0deg, rgba(255, 255, 255, 0.012), transparent);
|
||||
padding: 8px 12px calc(12px + var(--safe-bottom));
|
||||
}
|
||||
.working-bar {
|
||||
display: flex;
|
||||
|
|
@ -431,7 +489,7 @@
|
|||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--amber);
|
||||
padding: 0 4px 9px;
|
||||
padding: 2px 4px 9px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.composer-row {
|
||||
|
|
@ -442,13 +500,13 @@
|
|||
textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
max-height: 168px;
|
||||
max-height: 160px;
|
||||
min-height: 48px;
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 13px;
|
||||
padding: 13px 13px;
|
||||
font-family: var(--sans);
|
||||
/* 16px: anything smaller makes iOS Safari auto-zoom on focus (mobile is the
|
||||
primary client) — the zoom then shifts the composer out of view. */
|
||||
|
|
@ -458,39 +516,60 @@
|
|||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
field-sizing: content; /* progressive: auto-grows where supported */
|
||||
}
|
||||
textarea::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
textarea::placeholder { color: var(--ink-faint); }
|
||||
textarea:focus {
|
||||
border-color: var(--cyan-dim);
|
||||
box-shadow: 0 0 0 3px rgba(61, 209, 214, 0.12);
|
||||
}
|
||||
textarea:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
textarea:disabled { opacity: 0.55; }
|
||||
|
||||
.send {
|
||||
.send,
|
||||
.stop {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
min-width: 78px;
|
||||
min-width: 82px;
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--cyan-dim);
|
||||
background: linear-gradient(180deg, #19474b, #103539);
|
||||
color: #d8f6f7;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
transition: filter 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
letter-spacing: 0.05em;
|
||||
transition: filter 0.15s, border-color 0.15s, opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.send:hover:not(:disabled) {
|
||||
filter: brightness(1.22);
|
||||
border-color: var(--cyan);
|
||||
.send {
|
||||
border: 1px solid var(--cyan-dim);
|
||||
background: linear-gradient(180deg, #16464a, #0e3438);
|
||||
color: #d8f6f7;
|
||||
}
|
||||
.send:hover:not(:disabled) { filter: brightness(1.24); border-color: var(--cyan); }
|
||||
.send:disabled {
|
||||
opacity: 0.4;
|
||||
background: var(--bg-2);
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
/* Stop is NOT red — red is reserved for destructive VM power. Stop is a calm
|
||||
neutral control with a square "halt" glyph. */
|
||||
.stop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line-bright);
|
||||
background: var(--bg-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
.stop:hover { border-color: var(--ink-faint); filter: brightness(1.1); }
|
||||
.stop-glyph {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 8px rgba(245, 182, 87, 0.55);
|
||||
animation: lamp-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes lamp-pulse {
|
||||
0%, 100% { transform: scale(0.85); opacity: 0.8; }
|
||||
50% { transform: scale(1.08); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -293,7 +293,8 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 15px;
|
||||
min-height: 44px; /* touch target */
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
|
@ -408,7 +409,8 @@
|
|||
}
|
||||
.confirm-yes {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--danger-bright);
|
||||
background: var(--danger);
|
||||
|
|
@ -424,7 +426,8 @@
|
|||
}
|
||||
.confirm-no {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
|
|
|
|||
|
|
@ -1,48 +1,70 @@
|
|||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
devvm breakglass — global theme
|
||||
A recovery console: dark, high-contrast, terminal-adjacent. Calm by default;
|
||||
danger is the only loud thing on the screen. No external fonts/CDNs — system
|
||||
monospace carries the identity, system sans carries readable prose.
|
||||
Emergency recovery console / instrument panel. Dark, high-contrast, monospace
|
||||
identity, calm by default. Danger (red) is reserved EXCLUSIVELY for the
|
||||
destructive VM power actions — nothing else on the screen is ever red. No
|
||||
external fonts/CDNs (air-gapped cluster): a refined system-monospace stack
|
||||
carries the identity, system-sans carries readable prose. Distinctiveness is
|
||||
earned through composition, the living "system pulse" lamp, motion, hairlines,
|
||||
and the reserved danger treatment — not through a downloaded typeface.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* Surfaces — a near-black slate with cool undertone, layered for depth. */
|
||||
--bg-0: #07090c; /* page base */
|
||||
--bg-1: #0c1015; /* panel */
|
||||
--bg-2: #11171e; /* raised panel / input */
|
||||
--bg-3: #161d26; /* chips, hover */
|
||||
--bg-term: #06080a; /* command-output panels */
|
||||
/* Surfaces — a near-black slate with a cool undertone, layered for depth. */
|
||||
--bg-0: #06080b; /* page base (darkened from #07090c for crisper AA) */
|
||||
--bg-1: #0b0f14; /* panel */
|
||||
--bg-2: #10161d; /* raised panel / input */
|
||||
--bg-3: #161e27; /* chips, hover */
|
||||
--bg-term: #05070a; /* command-output panels */
|
||||
|
||||
/* Hairlines & text */
|
||||
--line: #1d2630;
|
||||
--line: #1c2530;
|
||||
--line-strong: #2a3744;
|
||||
--ink: #e6edf3; /* primary text */
|
||||
--ink-dim: #9bb0c0; /* secondary text */
|
||||
--ink-faint: #5d7185; /* labels, meta */
|
||||
--line-bright: #3a4a5a;
|
||||
--ink: #e9eff5; /* primary text */
|
||||
--ink-dim: #9bb0c0; /* secondary text — 8.0:1 on bg-2 */
|
||||
/* labels/meta — was #5d7185 (3.6:1, fails AA). Lifted to 6.1:1 on bg-2. */
|
||||
--ink-faint: #8499ab;
|
||||
|
||||
/* Accents */
|
||||
--cyan: #3dd1d6; /* "system alive" — links, focus, session dot */
|
||||
/* Accents — the "alive" cyan is the spine of the calm palette. */
|
||||
--cyan: #3dd1d6; /* "system alive" — links, focus, session pulse */
|
||||
--cyan-bright: #62e3e7;
|
||||
--cyan-dim: #1f6f72;
|
||||
--cyan-deep: #0e3133;
|
||||
--amber: #f5b657; /* working / in-flight */
|
||||
--amber-dim: #6a5226;
|
||||
--green: #5ddb8e; /* healthy exit */
|
||||
--green-dim: #1f5f3d;
|
||||
|
||||
/* Danger — reserved EXCLUSIVELY for mutating actions. Nothing else is red. */
|
||||
/* Danger — reserved EXCLUSIVELY for mutating power actions. Nothing else red. */
|
||||
--danger: #ff4d4d;
|
||||
--danger-bright: #ff6363;
|
||||
--danger-deep: #7a1717;
|
||||
--danger-glow: rgba(255, 77, 77, 0.35);
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--radius: 11px;
|
||||
--radius-sm: 8px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
--mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code",
|
||||
"Fira Code", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
/* A refined, deliberately-ordered monospace stack. We lead with faces that
|
||||
have real character (Berkeley Mono / JetBrains / Cascadia / SF Mono) and
|
||||
fall back gracefully — but ship nothing; whatever the device has carries
|
||||
the cockpit-readout identity. */
|
||||
--mono: "Berkeley Mono", ui-monospace, "JetBrains Mono", "SF Mono",
|
||||
"Cascadia Code", "Fira Code", "Source Code Pro", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.02) inset,
|
||||
0 16px 40px -24px rgba(0, 0, 0, 0.9);
|
||||
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.025) inset,
|
||||
0 18px 44px -26px rgba(0, 0, 0, 0.95);
|
||||
--shadow-sheet: 0 -22px 48px -12px rgba(0, 0, 0, 0.7);
|
||||
|
||||
/* Safe-area shorthands (notch / home-indicator). 0px fallback off-device. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
@ -55,23 +77,24 @@ html,
|
|||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
/* The page itself never scrolls — the chat stream scrolls internally. This
|
||||
keeps the composer pinned and stops iOS rubber-banding the whole UI. */
|
||||
/* The page itself never scrolls — only the chat stream scrolls internally.
|
||||
This keeps the composer pinned and stops iOS rubber-banding the whole UI. */
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-0);
|
||||
/* Atmosphere: a soft cyan corner-glow over a faint scanline weave, so the
|
||||
surface reads like backlit equipment rather than flat #000. */
|
||||
/* Atmosphere: a soft cyan corner-glow + a faint warm counter-glow over a
|
||||
hairline scanline weave, so the surface reads as backlit equipment rather
|
||||
than flat black. Fixed so it doesn't drift when the chat scrolls. */
|
||||
background-image:
|
||||
radial-gradient(120% 80% at 85% -10%, rgba(61, 209, 214, 0.07), transparent 55%),
|
||||
radial-gradient(90% 70% at 10% 110%, rgba(245, 182, 87, 0.04), transparent 50%),
|
||||
radial-gradient(120% 78% at 86% -12%, rgba(61, 209, 214, 0.08), transparent 55%),
|
||||
radial-gradient(90% 70% at 8% 112%, rgba(245, 182, 87, 0.045), transparent 52%),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.012) 0px,
|
||||
rgba(255, 255, 255, 0.012) 1px,
|
||||
rgba(255, 255, 255, 0.013) 0px,
|
||||
rgba(255, 255, 255, 0.013) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
|
|
@ -84,8 +107,8 @@ body {
|
|||
|
||||
#app {
|
||||
/* 100dvh (dynamic viewport height) — NOT 100vh/100% — so the composer at the
|
||||
bottom is never hidden behind a mobile browser's address/tool bar. Mobile is
|
||||
the primary client for this tool. 100vh is the fallback for old engines. */
|
||||
bottom is never hidden behind a mobile browser's address/tool bar. 100vh is
|
||||
the fallback for engines without dvh. Mobile is the primary client. */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
|
@ -94,7 +117,6 @@ button {
|
|||
font-family: var(--mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -119,10 +141,26 @@ button:disabled {
|
|||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a4a5a;
|
||||
background: var(--line-bright);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* ── Shared motion primitives ──────────────────────────────────────────────
|
||||
One well-orchestrated entrance beats scattered micro-interactions: panels
|
||||
and rows rise a few px with a soft fade, staggered via --d on each element. */
|
||||
@keyframes rise-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.rise-in {
|
||||
animation: rise-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,41 @@
|
|||
// Same-origin API client. Auth is handled entirely by the edge proxy
|
||||
// (Authentik / basic-auth / bearer) — this UI never sends or stores a token.
|
||||
import { readEventStream } from './sse.js';
|
||||
// Same-origin API client for the breakglass UI.
|
||||
//
|
||||
// Auth is handled entirely by the edge proxy (Authentik / basic-auth / bearer):
|
||||
// this UI never sends or stores a token, and builds no login screen.
|
||||
//
|
||||
// The chat uses the tmux/attach model. The conversation lives SERVER-SIDE; we
|
||||
// only persist the session_id locally and ATTACH to it over an EventSource. The
|
||||
// browser's native EventSource auto-reconnects and sends Last-Event-ID, and the
|
||||
// server resumes from there — so there is ZERO reconnect logic here. We just
|
||||
// render events idempotently by id (see transcript.js).
|
||||
|
||||
/** Open a fresh chat session. @returns {Promise<string>} session_id */
|
||||
const SESSION_KEY = 'breakglass.session_id';
|
||||
|
||||
/** Read the persisted session id, or '' if none. */
|
||||
export function loadSessionId() {
|
||||
try {
|
||||
return localStorage.getItem(SESSION_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the session id (best-effort; private-mode storage may throw). */
|
||||
export function saveSessionId(id) {
|
||||
try {
|
||||
if (id) localStorage.setItem(SESSION_KEY, id);
|
||||
else localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
/* ignore — storage is a convenience, not a requirement */
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the persisted session id (the "New session" archive step). */
|
||||
export function clearSessionId() {
|
||||
saveSessionId('');
|
||||
}
|
||||
|
||||
/** Open a fresh server-side session. @returns {Promise<string>} session_id */
|
||||
export async function openSession() {
|
||||
const res = await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
|
|
@ -19,30 +52,89 @@ export async function openSession() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Run one chat turn. Streams events to onEvent until the backend sends
|
||||
* {kind:"done"} and the connection closes. Pass an AbortSignal to cancel.
|
||||
* Attach to a session's event stream. Returns the live EventSource so the
|
||||
* caller can close() it. Events arrive as:
|
||||
* - default `message` events: .data is JSON {kind, id, ...}
|
||||
* - a named `caught-up` event once the replay is drained (.data is {})
|
||||
* - native `error` events while reconnecting (EventSource retries itself)
|
||||
*
|
||||
* @param {{session_id: string, prompt: string, model?: string, signal?: AbortSignal}} opts
|
||||
* @param {(event: object) => void} onEvent
|
||||
* @param {string} sessionId
|
||||
* @param {{
|
||||
* onEvent: (e: object) => void,
|
||||
* onCaughtUp?: () => void,
|
||||
* onOpen?: () => void,
|
||||
* onError?: (e: Event) => void,
|
||||
* }} handlers
|
||||
* @returns {EventSource}
|
||||
*/
|
||||
export async function streamChat({ session_id, prompt, model, signal }, onEvent) {
|
||||
const payload = { session_id, prompt };
|
||||
if (model) payload.model = model;
|
||||
export function attachStream(sessionId, { onEvent, onCaughtUp, onOpen, onError }) {
|
||||
const es = new EventSource(`/api/session/${encodeURIComponent(sessionId)}/stream`);
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
await readEventStream(res, onEvent);
|
||||
es.onopen = () => onOpen?.();
|
||||
|
||||
es.onmessage = (e) => {
|
||||
if (!e || typeof e.data !== 'string' || e.data === '') return;
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(e.data);
|
||||
} catch {
|
||||
// A malformed frame must not abort an in-progress recovery stream.
|
||||
return;
|
||||
}
|
||||
// EventSource exposes the SSE `id:` line as e.lastEventId. The server also
|
||||
// embeds id in the JSON; prefer the JSON id, fall back to lastEventId.
|
||||
if ((obj.id == null || obj.id === '') && e.lastEventId) obj.id = e.lastEventId;
|
||||
onEvent(obj);
|
||||
};
|
||||
|
||||
es.addEventListener('caught-up', () => onCaughtUp?.());
|
||||
|
||||
es.onerror = (e) => {
|
||||
// EventSource auto-reconnects on a transient drop (readyState CONNECTING);
|
||||
// we only surface a hard, terminal failure (readyState CLOSED).
|
||||
onError?.(e);
|
||||
};
|
||||
|
||||
return es;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the PVE power verbs and which of them mutate VM state.
|
||||
* Start a turn. Output arrives via the attach stream, NOT this response.
|
||||
* @param {{session_id: string, prompt: string, model?: string}} opts
|
||||
* @returns {Promise<{status:'started'|'busy'|'gone'}>}
|
||||
* started — accepted; busy — 409 (a turn already runs); gone — 404 (re-create).
|
||||
*/
|
||||
export async function sendPrompt({ session_id, prompt, model }) {
|
||||
const payload = { prompt };
|
||||
if (model) payload.model = model;
|
||||
const res = await fetch(`/api/session/${encodeURIComponent(session_id)}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 409) return { status: 'busy' };
|
||||
if (res.status === 404) return { status: 'gone' };
|
||||
if (!res.ok) throw new Error(`could not start the turn (HTTP ${res.status})`);
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the in-flight turn (the Stop button).
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<boolean>} whether a turn was cancelled
|
||||
*/
|
||||
export async function cancelTurn(sessionId) {
|
||||
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`could not stop the turn (HTTP ${res.status})`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return Boolean(body.cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the PVE power verbs and which mutate VM state.
|
||||
* @returns {Promise<{verbs: string[], mutating: string[]}>}
|
||||
*/
|
||||
export async function fetchVerbs() {
|
||||
|
|
@ -58,27 +150,26 @@ export async function fetchVerbs() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Run a PVE power verb directly (no AI in the path). The backend returns 200
|
||||
* on success and 502 when the verb's exit code is non-zero, but the JSON body
|
||||
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases — so we
|
||||
* read the body regardless of HTTP status and let the caller style on
|
||||
* exit_code / rejected.
|
||||
* Run a PVE power verb directly (no AI in the path). The backend returns 200 on
|
||||
* success and 502 when the verb's exit code is non-zero, but the JSON body
|
||||
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases — so we read
|
||||
* the body regardless of HTTP status and let the caller style on exit_code.
|
||||
*
|
||||
* @param {string} verb
|
||||
* @returns {Promise<{verb: string, exit_code: number|null, stdout: string, stderr: string, rejected: boolean}>}
|
||||
* @returns {Promise<{verb:string, exit_code:number|null, stdout:string, stderr:string, rejected:boolean}>}
|
||||
*/
|
||||
export async function runVerb(verb) {
|
||||
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
||||
let body;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
|
||||
}
|
||||
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
||||
if (res.status === 400) {
|
||||
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
// SSE frame parsing — the load-bearing core of the breakglass UI.
|
||||
//
|
||||
// The /api/chat endpoint returns a text/event-stream that we read with
|
||||
// fetch() + response.body.getReader() (NOT EventSource, which cannot POST).
|
||||
// The backend emits one frame per event as:
|
||||
//
|
||||
// data: {json}\n\n
|
||||
//
|
||||
// getReader() hands us bytes at arbitrary boundaries: a single frame can be
|
||||
// split across reads, and one read can contain several frames. So we keep a
|
||||
// rolling text buffer, split it on the blank-line frame delimiter, and only
|
||||
// hand back the JSON payload of *complete* frames. Per the SSE spec a frame may
|
||||
// carry multiple `data:` lines (joined with "\n"); the backend emits single
|
||||
// line JSON today, but we handle the general case so a future multi-line
|
||||
// payload can't silently corrupt the stream.
|
||||
|
||||
/**
|
||||
* Parse a single SSE event block (the text between blank lines) into its data
|
||||
* payload string, or null if the block carries no `data:` field (e.g. a bare
|
||||
* comment or a `:` heartbeat).
|
||||
* @param {string} block
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function dataFromEventBlock(block) {
|
||||
const dataLines = [];
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
if (line.startsWith(':')) continue; // SSE comment / heartbeat
|
||||
if (line === 'data:' || line === 'data') {
|
||||
dataLines.push('');
|
||||
} else if (line.startsWith('data:')) {
|
||||
// Spec: a single leading space after the colon is stripped.
|
||||
let v = line.slice('data:'.length);
|
||||
if (v.startsWith(' ')) v = v.slice(1);
|
||||
dataLines.push(v);
|
||||
}
|
||||
// field lines we don't care about (event:, id:, retry:) are ignored
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return dataLines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateful splitter that turns an arbitrary sequence of decoded text chunks
|
||||
* into a sequence of complete SSE event-block strings. Frames are delimited by
|
||||
* a blank line; we tolerate both "\n\n" and "\r\n\r\n".
|
||||
*/
|
||||
export class SSEFrameSplitter {
|
||||
constructor() {
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a decoded text chunk; returns the event blocks that are now complete.
|
||||
* Any trailing partial frame stays buffered for the next chunk.
|
||||
* @param {string} chunk
|
||||
* @returns {string[]} complete event blocks (text between delimiters)
|
||||
*/
|
||||
push(chunk) {
|
||||
this.buffer += chunk;
|
||||
const blocks = [];
|
||||
// Normalise CRLF delimiters to LF so a single split rule covers both.
|
||||
let idx;
|
||||
// Process every complete frame currently in the buffer.
|
||||
while ((idx = this._nextDelimiter()) !== -1) {
|
||||
const block = this.buffer.slice(0, idx.start);
|
||||
this.buffer = this.buffer.slice(idx.end);
|
||||
if (block.length > 0) blocks.push(block);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* On stream end, return whatever complete-looking content remains. A
|
||||
* well-behaved backend always terminates the last frame with a blank line,
|
||||
* so this is usually empty — but if the connection closed mid-trailing-frame
|
||||
* with a parseable block, surface it rather than dropping data.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
flush() {
|
||||
const rest = this.buffer.trim();
|
||||
this.buffer = '';
|
||||
return rest ? [rest] : [];
|
||||
}
|
||||
|
||||
_nextDelimiter() {
|
||||
// Find the earliest of "\n\n", "\r\n\r\n", "\r\r".
|
||||
const candidates = [
|
||||
{ token: '\r\n\r\n', i: this.buffer.indexOf('\r\n\r\n') },
|
||||
{ token: '\n\n', i: this.buffer.indexOf('\n\n') },
|
||||
{ token: '\r\r', i: this.buffer.indexOf('\r\r') },
|
||||
].filter((c) => c.i !== -1);
|
||||
if (candidates.length === 0) return -1;
|
||||
candidates.sort((a, b) => a.i - b.i);
|
||||
const { token, i } = candidates[0];
|
||||
return { start: i, end: i + token.length };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an SSE Response body to completion, invoking onEvent for every parsed
|
||||
* JSON event object. Resolves when the stream ends. Throws if the response is
|
||||
* not ok or has no readable body (caller shows the error inline).
|
||||
*
|
||||
* @param {Response} response a fetch() Response with a streaming body
|
||||
* @param {(event: object) => void} onEvent called per parsed JSON event
|
||||
*/
|
||||
export async function readEventStream(response, onEvent) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`server returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('response has no readable body (streaming unsupported)');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const splitter = new SSEFrameSplitter();
|
||||
|
||||
const handleBlock = (block) => {
|
||||
const payload = dataFromEventBlock(block);
|
||||
if (payload == null || payload.trim() === '') return;
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(payload);
|
||||
} catch {
|
||||
// A malformed frame must not abort an in-progress recovery stream;
|
||||
// skip it and keep reading.
|
||||
return;
|
||||
}
|
||||
onEvent(obj);
|
||||
};
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
for (const block of splitter.push(text)) handleBlock(block);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock?.();
|
||||
}
|
||||
// Drain any trailing bytes the decoder held, then any final frame.
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
for (const block of splitter.push(tail)) handleBlock(block);
|
||||
}
|
||||
for (const block of splitter.flush()) handleBlock(block);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
// Standalone test of the SSE frame parser — no test framework, just node.
|
||||
// Run: node src/lib/sse.test.mjs (exits non-zero on any failure)
|
||||
//
|
||||
// These pin the protocol described in the API contract: frames are
|
||||
// `data: {json}\n\n`, the event `kind` is one of session/text/tool/result/
|
||||
// error/done, and bytes arrive at arbitrary boundaries via getReader().
|
||||
import { SSEFrameSplitter, dataFromEventBlock, readEventStream } from './sse.js';
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond) {
|
||||
if (cond) {
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.error(`FAIL ${name}`);
|
||||
}
|
||||
}
|
||||
function eq(name, got, want) {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(`${name} (got ${g})`, g === w);
|
||||
}
|
||||
|
||||
// --- dataFromEventBlock ---------------------------------------------------
|
||||
eq(
|
||||
'extracts JSON payload from a data: line',
|
||||
dataFromEventBlock('data: {"kind":"text","text":"hi"}'),
|
||||
'{"kind":"text","text":"hi"}'
|
||||
);
|
||||
eq(
|
||||
'strips exactly one space after the colon',
|
||||
dataFromEventBlock('data: leading-space-kept'),
|
||||
' leading-space-kept'
|
||||
);
|
||||
eq('ignores comment/heartbeat lines', dataFromEventBlock(': keep-alive'), null);
|
||||
eq(
|
||||
'joins multi-line data fields with newline',
|
||||
dataFromEventBlock('data: line1\ndata: line2'),
|
||||
'line1\nline2'
|
||||
);
|
||||
|
||||
// --- SSEFrameSplitter: whole frames --------------------------------------
|
||||
{
|
||||
const s = new SSEFrameSplitter();
|
||||
const blocks = s.push('data: {"kind":"session","session_id":"abc"}\n\n');
|
||||
eq('one complete frame yields one block', blocks, [
|
||||
'data: {"kind":"session","session_id":"abc"}',
|
||||
]);
|
||||
}
|
||||
|
||||
// --- SSEFrameSplitter: multiple frames in one chunk ----------------------
|
||||
{
|
||||
const s = new SSEFrameSplitter();
|
||||
const blocks = s.push(
|
||||
'data: {"kind":"text","text":"a"}\n\ndata: {"kind":"text","text":"b"}\n\n'
|
||||
);
|
||||
eq('two frames in one chunk yield two blocks', blocks.length, 2);
|
||||
eq('first block', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"a"}');
|
||||
eq('second block', dataFromEventBlock(blocks[1]), '{"kind":"text","text":"b"}');
|
||||
}
|
||||
|
||||
// --- SSEFrameSplitter: frame split across chunks -------------------------
|
||||
{
|
||||
const s = new SSEFrameSplitter();
|
||||
let blocks = s.push('data: {"kind":"te');
|
||||
eq('partial frame yields nothing yet', blocks, []);
|
||||
blocks = s.push('xt","text":"split"}\n\n');
|
||||
eq('completing the frame yields it whole', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"split"}');
|
||||
}
|
||||
|
||||
// --- SSEFrameSplitter: delimiter split across chunks ---------------------
|
||||
{
|
||||
const s = new SSEFrameSplitter();
|
||||
let blocks = s.push('data: {"kind":"done"}\n');
|
||||
eq('frame held while delimiter incomplete', blocks, []);
|
||||
blocks = s.push('\n');
|
||||
eq('frame released once blank line completes', dataFromEventBlock(blocks[0]), '{"kind":"done"}');
|
||||
}
|
||||
|
||||
// --- SSEFrameSplitter: CRLF delimiters -----------------------------------
|
||||
{
|
||||
const s = new SSEFrameSplitter();
|
||||
const blocks = s.push('data: {"kind":"text","text":"crlf"}\r\n\r\n');
|
||||
eq('CRLF-delimited frame parses', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"crlf"}');
|
||||
}
|
||||
|
||||
// --- end-to-end via readEventStream over a mock streaming Response --------
|
||||
function mockResponse(chunks) {
|
||||
const enc = new TextEncoder();
|
||||
let i = 0;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
read() {
|
||||
if (i < chunks.length) {
|
||||
return Promise.resolve({ value: enc.encode(chunks[i++]), done: false });
|
||||
}
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await (async () => {
|
||||
// A realistic turn, deliberately chopped at ugly boundaries:
|
||||
// - the session frame split mid-JSON
|
||||
// - two text frames glued together
|
||||
// - a tool frame
|
||||
// - a result frame and the terminal done frame in one chunk
|
||||
const chunks = [
|
||||
'data: {"kind":"sess',
|
||||
'ion","session_id":"S1"}\n\n',
|
||||
'data: {"kind":"text","text":"checking "}\n\ndata: {"kind":"text","text":"disk"}\n\n',
|
||||
'data: {"kind":"tool","name":"Bash","input":{"command":"df -h"}}\n\n',
|
||||
'data: {"kind":"result","is_error":false,"result":"ok","duration_ms":12}\n\ndata: {"kind":"done"}\n\n',
|
||||
];
|
||||
const events = [];
|
||||
await readEventStream(mockResponse(chunks), (e) => events.push(e));
|
||||
|
||||
eq('event count', events.length, 6);
|
||||
eq('1: session id', events[0], { kind: 'session', session_id: 'S1' });
|
||||
eq('2: first text', events[1], { kind: 'text', text: 'checking ' });
|
||||
eq('3: second text', events[2], { kind: 'text', text: 'disk' });
|
||||
eq('4: tool kind+name', { kind: events[3].kind, name: events[3].name }, { kind: 'tool', name: 'Bash' });
|
||||
eq('4: tool command', events[3].input.command, 'df -h');
|
||||
eq('5: result', events[4], { kind: 'result', is_error: false, result: 'ok', duration_ms: 12 });
|
||||
eq('6: done terminal', events[5], { kind: 'done' });
|
||||
})();
|
||||
|
||||
// malformed frame in the middle must be skipped, not abort the stream
|
||||
await (async () => {
|
||||
const chunks = [
|
||||
'data: {"kind":"text","text":"before"}\n\n',
|
||||
'data: {this is not json}\n\n',
|
||||
'data: {"kind":"done"}\n\n',
|
||||
];
|
||||
const events = [];
|
||||
await readEventStream(mockResponse(chunks), (e) => events.push(e));
|
||||
eq('malformed frame skipped, stream continues', events.map((e) => e.kind), ['text', 'done']);
|
||||
})();
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} assertion(s) FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nall SSE parser assertions passed');
|
||||
196
frontend/src/lib/transcript.js
Normal file
196
frontend/src/lib/transcript.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// transcript.js — the load-bearing core of the breakglass UI.
|
||||
//
|
||||
// The attach stream (EventSource) replays the conversation-so-far and then
|
||||
// tails live. Replayed events are byte-identical to live ones, and on a
|
||||
// reconnect the server re-replays from Last-Event-ID — so the SAME event id can
|
||||
// arrive more than once. This module folds a flat, possibly-duplicated event
|
||||
// sequence into an ordered list of render-ready messages, idempotently.
|
||||
//
|
||||
// Contract (every default `message` event's .data is one of these JSON shapes):
|
||||
// {kind:"user", text, id} → opens a USER bubble
|
||||
// {kind:"session", session_id, id} → informational (agent's session id)
|
||||
// {kind:"text", text, id} → assistant prose; concatenated
|
||||
// {kind:"tool", name, input, id} → inline tool chip (Bash → command)
|
||||
// {kind:"result", is_error, result, duration_ms, id} → closes the bubble
|
||||
// {kind:"error", error, id} → error note on the bubble
|
||||
// {kind:"cancelled", id} → muted "stopped" note
|
||||
// {kind:"turn_end", id} → the turn finished
|
||||
//
|
||||
// Grouping: a `user` event opens a user message; the session/text/tool events
|
||||
// that follow build ONE assistant message; result/error/cancelled annotate it;
|
||||
// turn_end ends it. Assistant events with no preceding user (e.g. a session
|
||||
// banner on a fresh attach) still get an assistant message so nothing is lost.
|
||||
//
|
||||
// Idempotency: every event carries a monotonic integer-ish id. We track the
|
||||
// max id folded so far and DROP any event whose id we've already passed — a
|
||||
// reconnect replay therefore never double-renders. Ids are compared
|
||||
// numerically when both parse as numbers, else as strings (defensive).
|
||||
|
||||
/** @typedef {{type:'text',text:string}|{type:'tool',name:string,command:string,raw:any}} Part */
|
||||
/**
|
||||
* @typedef {Object} Message
|
||||
* @property {'user'|'assistant'} role
|
||||
* @property {string} key stable key for keyed {#each}
|
||||
* @property {string} [text] user text
|
||||
* @property {Part[]} [parts] assistant parts, in emit order
|
||||
* @property {{is_error:boolean,text:string,duration_ms:number|null}} [result]
|
||||
* @property {string} [error]
|
||||
* @property {boolean} [cancelled]
|
||||
* @property {boolean} [ended] turn_end seen for this message
|
||||
*/
|
||||
|
||||
/** Compare two ids; numeric when both look numeric, else lexicographic. */
|
||||
export function idGreater(a, b) {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (Number.isFinite(na) && Number.isFinite(nb) && `${a}`.trim() !== '' && `${b}`.trim() !== '') {
|
||||
return na > nb;
|
||||
}
|
||||
return String(a) > String(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty transcript-folding state.
|
||||
* @returns {{messages: Message[], maxId: any, sawId: boolean, openAssistant: Message|null, activeUserSeen: boolean}}
|
||||
*/
|
||||
export function createTranscript() {
|
||||
return {
|
||||
messages: [],
|
||||
maxId: null,
|
||||
sawId: false,
|
||||
openAssistant: null,
|
||||
// a turn is "active" once a user event (or local prompt) has no following
|
||||
// turn_end; the UI reads `active` from reduceEvent's return.
|
||||
activeUserSeen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function bubbleKey(prefix, id, fallbackIndex) {
|
||||
if (id != null && `${id}`.trim() !== '') return `${prefix}:${id}`;
|
||||
return `${prefix}:idx:${fallbackIndex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this event be applied, given the max id folded so far? Updates and
|
||||
* returns the new max. Events WITHOUT an id are always applied (and don't move
|
||||
* the watermark) — the protocol always carries ids, but we never drop data on a
|
||||
* malformed frame.
|
||||
* @returns {{apply:boolean, maxId:any}}
|
||||
*/
|
||||
export function admit(maxId, id) {
|
||||
if (id == null || `${id}`.trim() === '') return { apply: true, maxId };
|
||||
if (maxId == null) return { apply: true, maxId: id };
|
||||
if (idGreater(id, maxId)) return { apply: true, maxId: id };
|
||||
return { apply: false, maxId }; // already seen — dedupe
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event into the transcript state, mutating `state` in place.
|
||||
* Returns true if the state changed (so callers can trigger a re-render).
|
||||
*
|
||||
* @param {ReturnType<typeof createTranscript>} state
|
||||
* @param {any} ev parsed event object ({kind, id, ...})
|
||||
* @returns {boolean} changed
|
||||
*/
|
||||
export function reduceEvent(state, ev) {
|
||||
if (!ev || typeof ev !== 'object') return false;
|
||||
const { apply, maxId } = admit(state.maxId, ev.id);
|
||||
state.maxId = maxId;
|
||||
if (!apply) return false;
|
||||
if (ev.id != null && `${ev.id}`.trim() !== '') state.sawId = true;
|
||||
|
||||
const ensureAssistant = () => {
|
||||
if (!state.openAssistant) {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
key: bubbleKey('a', ev.id, state.messages.length),
|
||||
parts: [],
|
||||
ended: false,
|
||||
};
|
||||
state.messages.push(msg);
|
||||
state.openAssistant = msg;
|
||||
}
|
||||
return state.openAssistant;
|
||||
};
|
||||
|
||||
switch (ev.kind) {
|
||||
case 'user': {
|
||||
// A new user turn. Close any dangling assistant bubble first.
|
||||
state.openAssistant = null;
|
||||
state.messages.push({
|
||||
role: 'user',
|
||||
key: bubbleKey('u', ev.id, state.messages.length),
|
||||
text: typeof ev.text === 'string' ? ev.text : '',
|
||||
});
|
||||
state.activeUserSeen = true;
|
||||
return true;
|
||||
}
|
||||
case 'session': {
|
||||
// Informational — does not itself render a part, but it does open the
|
||||
// assistant bubble for the turn so subsequent text lands in one place.
|
||||
ensureAssistant();
|
||||
return true;
|
||||
}
|
||||
case 'text': {
|
||||
if (typeof ev.text !== 'string' || ev.text === '') return false;
|
||||
const msg = ensureAssistant();
|
||||
const tail = msg.parts[msg.parts.length - 1];
|
||||
if (tail && tail.type === 'text') {
|
||||
tail.text += ev.text; // concatenate consecutive prose
|
||||
} else {
|
||||
msg.parts.push({ type: 'text', text: ev.text });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'tool': {
|
||||
const msg = ensureAssistant();
|
||||
const command =
|
||||
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
|
||||
msg.parts.push({
|
||||
type: 'tool',
|
||||
name: typeof ev.name === 'string' && ev.name ? ev.name : 'tool',
|
||||
command,
|
||||
raw: ev.input ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'result': {
|
||||
const msg = ensureAssistant();
|
||||
msg.result = {
|
||||
is_error: Boolean(ev.is_error),
|
||||
text: typeof ev.result === 'string' ? ev.result : '',
|
||||
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
case 'error': {
|
||||
const msg = ensureAssistant();
|
||||
msg.error = typeof ev.error === 'string' && ev.error ? ev.error : 'unknown error';
|
||||
return true;
|
||||
}
|
||||
case 'cancelled': {
|
||||
const msg = ensureAssistant();
|
||||
msg.cancelled = true;
|
||||
return true;
|
||||
}
|
||||
case 'turn_end': {
|
||||
if (state.openAssistant) state.openAssistant.ended = true;
|
||||
state.openAssistant = null;
|
||||
state.activeUserSeen = false;
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: fold an array of events into a fresh transcript (used by tests
|
||||
* and by a from-scratch render). Returns the final state.
|
||||
* @param {any[]} events
|
||||
*/
|
||||
export function foldAll(events) {
|
||||
const state = createTranscript();
|
||||
for (const ev of events) reduceEvent(state, ev);
|
||||
return state;
|
||||
}
|
||||
162
frontend/src/lib/transcript.test.mjs
Normal file
162
frontend/src/lib/transcript.test.mjs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// Standalone test of the transcript folder — no test framework, just node.
|
||||
// Run: node src/lib/transcript.test.mjs (exits non-zero on any failure)
|
||||
//
|
||||
// These pin the attach-model contract: events carry monotonic ids, a reconnect
|
||||
// re-replays already-seen ids (which MUST be deduped), and events group into
|
||||
// user/assistant messages with consecutive prose concatenated.
|
||||
import {
|
||||
admit,
|
||||
idGreater,
|
||||
reduceEvent,
|
||||
createTranscript,
|
||||
foldAll,
|
||||
} from './transcript.js';
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond) {
|
||||
if (cond) {
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.error(`FAIL ${name}`);
|
||||
}
|
||||
}
|
||||
function eq(name, got, want) {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(`${name} (got ${g})`, g === w);
|
||||
}
|
||||
|
||||
// --- id comparison --------------------------------------------------------
|
||||
ok('idGreater numeric', idGreater(10, 9) === true);
|
||||
ok('idGreater numeric not', idGreater(2, 10) === false); // not string "2" > "10"
|
||||
ok('idGreater string fallback', idGreater('b', 'a') === true);
|
||||
|
||||
// --- admit / dedupe watermark --------------------------------------------
|
||||
{
|
||||
let { apply, maxId } = admit(null, 1);
|
||||
eq('first id admitted', { apply, maxId }, { apply: true, maxId: 1 });
|
||||
({ apply, maxId } = admit(5, 5));
|
||||
ok('equal id rejected (already seen)', apply === false && maxId === 5);
|
||||
({ apply, maxId } = admit(5, 3));
|
||||
ok('lower id rejected', apply === false && maxId === 5);
|
||||
({ apply, maxId } = admit(5, 6));
|
||||
ok('higher id admitted, watermark moves', apply === true && maxId === 6);
|
||||
({ apply, maxId } = admit(5, undefined));
|
||||
ok('id-less event always admitted, watermark held', apply === true && maxId === 5);
|
||||
}
|
||||
|
||||
// --- a full turn groups into user + one assistant bubble ------------------
|
||||
{
|
||||
const events = [
|
||||
{ kind: 'user', text: 'triage it', id: 1 },
|
||||
{ kind: 'session', session_id: 'S1', id: 2 },
|
||||
{ kind: 'text', text: 'Checking ', id: 3 },
|
||||
{ kind: 'text', text: 'disk usage.', id: 4 },
|
||||
{ kind: 'tool', name: 'Bash', input: { command: 'df -h' }, id: 5 },
|
||||
{ kind: 'result', is_error: false, result: 'ok', duration_ms: 1200, id: 6 },
|
||||
{ kind: 'turn_end', id: 7 },
|
||||
];
|
||||
const s = foldAll(events);
|
||||
eq('two messages: user + assistant', s.messages.length, 2);
|
||||
eq('first is user with text', { r: s.messages[0].role, t: s.messages[0].text }, { r: 'user', t: 'triage it' });
|
||||
const a = s.messages[1];
|
||||
eq('assistant role', a.role, 'assistant');
|
||||
// consecutive text concatenated into ONE part; tool is a separate part
|
||||
eq('parts: one concatenated text + one tool', a.parts.map((p) => p.type), ['text', 'tool']);
|
||||
eq('prose concatenated in order', a.parts[0].text, 'Checking disk usage.');
|
||||
eq('tool command captured', a.parts[1].command, 'df -h');
|
||||
eq('result attached', { e: a.result.is_error, ms: a.result.duration_ms }, { e: false, ms: 1200 });
|
||||
ok('turn ended', a.ended === true);
|
||||
ok('no longer active after turn_end', s.activeUserSeen === false);
|
||||
}
|
||||
|
||||
// --- reconnect replay: re-feeding the SAME events must NOT double-render --
|
||||
{
|
||||
const events = [
|
||||
{ kind: 'user', text: 'hi', id: 1 },
|
||||
{ kind: 'text', text: 'hello', id: 2 },
|
||||
{ kind: 'turn_end', id: 3 },
|
||||
];
|
||||
const s = createTranscript();
|
||||
for (const e of events) reduceEvent(s, e);
|
||||
// simulate an EventSource reconnect that re-replays everything from the top
|
||||
for (const e of events) reduceEvent(s, e);
|
||||
eq('still exactly two messages after replay', s.messages.length, 2);
|
||||
eq('assistant prose not doubled', s.messages[1].parts[0].text, 'hello');
|
||||
}
|
||||
|
||||
// --- a partial replay (Last-Event-ID resume) continues the same bubble ----
|
||||
{
|
||||
const s = createTranscript();
|
||||
reduceEvent(s, { kind: 'user', text: 'go', id: 1 });
|
||||
reduceEvent(s, { kind: 'text', text: 'part-A ', id: 2 });
|
||||
// reconnect: server resumes after id 2; we must drop id<=2 if re-sent and
|
||||
// keep appending to the open assistant bubble.
|
||||
reduceEvent(s, { kind: 'text', text: 'part-A ', id: 2 }); // dup, dropped
|
||||
reduceEvent(s, { kind: 'text', text: 'part-B', id: 3 }); // new, appended
|
||||
reduceEvent(s, { kind: 'turn_end', id: 4 });
|
||||
eq('resume appended to same bubble', s.messages[1].parts[0].text, 'part-A part-B');
|
||||
eq('still two messages', s.messages.length, 2);
|
||||
}
|
||||
|
||||
// --- error / cancelled annotate the open bubble ---------------------------
|
||||
{
|
||||
const s = foldAll([
|
||||
{ kind: 'user', text: 'x', id: 1 },
|
||||
{ kind: 'text', text: 'working', id: 2 },
|
||||
{ kind: 'error', error: 'ssh timeout', id: 3 },
|
||||
{ kind: 'turn_end', id: 4 },
|
||||
]);
|
||||
eq('error note on assistant bubble', s.messages[1].error, 'ssh timeout');
|
||||
}
|
||||
{
|
||||
const s = foldAll([
|
||||
{ kind: 'user', text: 'x', id: 1 },
|
||||
{ kind: 'cancelled', id: 2 },
|
||||
{ kind: 'turn_end', id: 3 },
|
||||
]);
|
||||
ok('cancelled flag on assistant bubble', s.messages[1].cancelled === true);
|
||||
}
|
||||
|
||||
// --- active state: a user event with no turn_end means a turn is running ---
|
||||
{
|
||||
const s = createTranscript();
|
||||
reduceEvent(s, { kind: 'user', text: 'go', id: 1 });
|
||||
reduceEvent(s, { kind: 'text', text: '...', id: 2 });
|
||||
ok('active while no turn_end', s.activeUserSeen === true);
|
||||
reduceEvent(s, { kind: 'turn_end', id: 3 });
|
||||
ok('inactive after turn_end', s.activeUserSeen === false);
|
||||
}
|
||||
|
||||
// --- assistant-only stream (session banner on a fresh attach) still renders -
|
||||
{
|
||||
const s = foldAll([
|
||||
{ kind: 'session', session_id: 'S1', id: 1 },
|
||||
{ kind: 'text', text: 'standing by', id: 2 },
|
||||
{ kind: 'turn_end', id: 3 },
|
||||
]);
|
||||
eq('lone assistant message created', s.messages.length, 1);
|
||||
eq('assistant prose present', s.messages[0].parts[0].text, 'standing by');
|
||||
}
|
||||
|
||||
// --- two sequential turns produce two assistant bubbles -------------------
|
||||
{
|
||||
const s = foldAll([
|
||||
{ kind: 'user', text: 'q1', id: 1 },
|
||||
{ kind: 'text', text: 'a1', id: 2 },
|
||||
{ kind: 'turn_end', id: 3 },
|
||||
{ kind: 'user', text: 'q2', id: 4 },
|
||||
{ kind: 'text', text: 'a2', id: 5 },
|
||||
{ kind: 'turn_end', id: 6 },
|
||||
]);
|
||||
eq('four messages (u,a,u,a)', s.messages.map((m) => m.role), ['user', 'assistant', 'user', 'assistant']);
|
||||
eq('second answer in its own bubble', s.messages[3].parts[0].text, 'a2');
|
||||
ok('message keys are unique', new Set(s.messages.map((m) => m.key)).size === 4);
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} assertion(s) FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nall transcript assertions passed');
|
||||
Loading…
Add table
Add a link
Reference in a new issue