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:
Viktor Barzin 2026-06-14 19:19:03 +00:00
parent 9d8afdd884
commit 5b5daa4bea
30 changed files with 1961 additions and 968 deletions

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

64
frontend/public/icon.svg Normal file
View 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

View 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"
}
]
}

View file

@ -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,

View file

@ -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>

View file

@ -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);

View file

@ -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,

View file

@ -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`);
}

View file

@ -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);
}

View file

@ -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');

View 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;
}

View 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');