claude-agent-service/frontend/src/lib/transcript.js
Viktor Barzin 5b5daa4bea 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>
2026-06-14 19:19:03 +00:00

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