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>
196 lines
7.1 KiB
JavaScript
196 lines
7.1 KiB
JavaScript
// 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;
|
|
}
|