| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423 |
- // The task's event log (plan 17 section 3.2): the unified
- // ledger's human view. The feed is client-side over the
- // plan-17 endpoints, and its contract is:
- //
- // CHRONOLOGY - rows render OLDEST at the top and NEWEST at
- // the bottom, like a terminal transcript. The SSE stream
- // replays the most-recent-30 window oldest-first and then
- // appends every new row, so live traffic just extends the
- // tail of the feed.
- //
- // SCROLL - the feed ([data-log-feed], max-height 72vh,
- // overflow-y auto) is scrolled to the bottom on the first
- // render that carries rows, and afterwards sticks to the
- // bottom ONLY when the reader was already at (or within
- // 48px of) the bottom before the render. A reader who
- // scrolled up into the history is never yanked down.
- //
- // PAGING - the load-older control sits above the feed
- // (older history lives above it) and pages backwards via
- // GET /tasks/<id>/events?before=<oldest loaded id>&limit=30.
- // The server serves each page newest-first; the client
- // reverses it to oldest-first and prepends, restoring the
- // viewport by the scrollHeight delta so the rows under the
- // reader's cursor do not move. A page whose `more` flag is
- // false marks the ledger exhausted and hides the control.
- //
- // STREAM - the server's SSE frames carry only id: and
- // data: lines (no `event:` name), so every frame lands on
- // `onmessage` and no named listener can ever fire. The
- // heartbeat frame's data is the literal string "ping";
- // every other frame is one JSON ledger row, ingested with
- // the id-dedupe (which also absorbs EventSource's replay
- // after a reconnect, resent from the last seen id).
- //
- // Turn cards group a run's turn_text + tool_call +
- // tool_result events by payload.turn (per session).
- //
- // PAYLOADS - the daemon serialises a tool call's arguments
- // and a tool result's envelope to a JSON STRING before it
- // stores them: args is a string like '{"command":"ls"}'
- // (never an object) and result is a string holding the
- // {"ok":..,"exit_code":..,"stdout":..,"stderr":..,
- // "duration_ms":..,"bytes_out":..,"bytes_err":..,
- // "truncated":..} envelope. Every render path parses these
- // members through parseMaybeJson first and falls back to
- // the raw string when it is not JSON - data is never
- // hidden. The command the agent ran must always be
- // visible: command_output prefixes '$ <command>', and each
- // tool_call renders through a tailored body
- // (tailoredCallBody) - run_command heads with '$ cmd', the
- // file tools show paths with folded content,
- // replace_in_file renders a -old/+new diff, the text tools
- // render prose - with the generic JSON view as the
- // backstop for unknown tools and truncated args. Results collapse
- // behind a summary carrying exit code, byte counts (from
- // the envelope when present), the truncated flag and
- // duration. Filters
- // (commands/results/comments/transitions/usage) and the
- // full-text search run client-side over the loaded window.
- // All rendering goes through textContent/createElement -
- // event payloads never touch innerHTML.
- //
- // This whole script body is deliberately ASCII-only
- // (comments included): the page compiler HTML-escapes
- // script bodies, so any non-ASCII literal would come back
- // out of the browser as literal entity text - the entity
- // name spelled out - instead of the character it names.
- // Non-ASCII characters are written as JS unicode escapes
- // (\u00b7 etc).
- if (window.statum) {
- (function () {
- 'use strict';
- // How close to the bottom (px) still counts as
- // "reading the tail" for the stick rule.
- var STICK_THRESHOLD = 48;
- var PAGE_SIZE = 30;
- var state = {
- events: [],
- oldest: 0,
- exhausted: false,
- loading: false,
- seeded: false,
- filters: { commands: true, results: true, comments: true,
- transitions: true, usage: true },
- search: ''
- };
- var feed = null;
- var moreButton = null;
- var stream = null;
- var liveBadge = null;
- var liveLabel = null;
- var taskStatus = '';
- var streamState = 'live';
- // The badge reflects the TASK first: only a working (or
- // assigned) task streams live events, so every other
- // status names itself on the pill - still, no pulse,
- // the task page's own display words. The stream's
- // health (reconnecting...) only rides a live task.
- var LIVE_STATUSES = { working: 1, assigned: 1 };
- var BADGE_LABELS = { suspended: 'parked',
- review_requested: 'review requested' };
- function updateBadge() {
- if (!liveBadge) return;
- var cls = 'log-live';
- var label;
- if (LIVE_STATUSES[taskStatus]) {
- if (streamState === 'retry') {
- cls += ' retry';
- label = 'reconnecting...';
- } else {
- label = 'live';
- }
- } else {
- cls += ' still badge badge-' + taskStatus;
- label = BADGE_LABELS[taskStatus] || taskStatus || 'idle';
- }
- liveBadge.className = cls;
- if (liveLabel) {
- liveLabel.textContent = label;
- }
- }
- function setLive(state) {
- streamState = state;
- updateBadge();
- }
- function kindClass(kind) {
- return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, '');
- }
- function filterOf(kind) {
- if (kind === 'tool_call') return 'commands';
- if (kind === 'tool_result' || kind === 'command_output') return 'results';
- if (kind === 'comment' || kind === 'answer' || kind === 'verdict'
- || kind === 'yeehaw') return 'comments';
- if (kind === 'usage') return 'usage';
- return 'transitions';
- }
- function visible(evt) {
- if (!state.filters[filterOf(evt.kind)]) return false;
- if (state.search.length > 0) {
- var haystack = evt.kind + ' ' + evt.actor + ' '
- + JSON.stringify(evt.payload || {})
- + ' ' + strText((evt.payload || {}).args)
- + ' ' + strText((evt.payload || {}).result);
- if (haystack.toLowerCase().indexOf(state.search.toLowerCase()) === -1) {
- return false;
- }
- }
- return true;
- }
- // state.events is kept oldest-first (ascending id):
- // insert before the first newer row, skip ids we
- // already hold (SSE replay after a reconnect sends
- // the last frame again).
- function ingest(evt) {
- var i = 0;
- while (i < state.events.length && state.events[i].id < evt.id) {
- i++;
- }
- if (i < state.events.length && state.events[i].id === evt.id) return;
- state.events.splice(i, 0, evt);
- if (evt.id < state.oldest || state.oldest === 0) {
- state.oldest = evt.id;
- }
- }
- // True when the feed is scrolled to (or within
- // STICK_THRESHOLD of) its bottom - a feed shorter
- // than its viewport counts as at-bottom.
- function atBottom() {
- if (!feed) return true;
- return feed.scrollHeight - feed.scrollTop - feed.clientHeight
- <= STICK_THRESHOLD;
- }
- // Pin the feed to its bottom edge (the newest row).
- function scrollToBottom() {
- if (!feed) return;
- feed.scrollTop = feed.scrollHeight;
- }
- // The daemon JSON-encodes tool arguments and tool
- // result envelopes into STRINGS before storing them;
- // older/fake pushes send plain objects. Parse the
- // string forms, pass objects through, and answer
- // undefined when a string is not JSON (the caller
- // then shows the raw text so data is never hidden).
- function parseMaybeJson(value) {
- if (typeof value !== 'string') return value;
- try {
- return JSON.parse(value);
- } catch (error) {
- return undefined;
- }
- }
- // True for a parsed member we can treat as a bag of
- // properties (the object-shaped push, or a string
- // that decoded to an object).
- function isPlainObject(value) {
- return value !== null && typeof value === 'object'
- && !Array.isArray(value);
- }
- function strText(value) {
- return typeof value === 'string' ? value : '';
- }
- function strBytes(value) {
- return typeof value === 'string' ? value.length : 0;
- }
- function fmtDuration(ms) {
- if (ms < 1000) return ms + ' ms';
- return (ms / 1000).toFixed(1) + ' s';
- }
- function fmtBytes(n) {
- if (n < 1024) return n + ' B';
- if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
- return (n / (1024 * 1024)).toFixed(1) + ' MiB';
- }
- function preLine(text) {
- var pre = document.createElement('pre');
- pre.className = 'event-pre';
- pre.textContent = text;
- return pre;
- }
- // The parsed result envelope: the daemon stores it
- // as a JSON string ({"ok":..,"exit_code":..,
- // "stdout":..,"stderr":..,"duration_ms":..,
- // "bytes_out":..,"bytes_err":..,"truncated":..});
- // the legacy object-shaped payload is the fallback.
- function resultEnvelope(evt) {
- var p = evt.payload || {};
- var parsed = parseMaybeJson(p.result);
- if (isPlainObject(parsed)) return parsed;
- if (isPlainObject(p)) return p;
- return {};
- }
- // Command plumbing: exit status, the streams and
- // their sizes, plus the ledger's own framing keys.
- // An envelope carrying ONLY these said nothing worth
- // printing; anything else in it (a task_id,
- // "recorded", a tasks list) IS the tool's answer
- // and must be rendered, not swallowed by "(no
- // output)".
- var RESULT_PLUMBING = { ok: 1, exit_code: 1, stdout: 1,
- stderr: 1, bytes_out: 1, bytes_err: 1, truncated: 1,
- duration_ms: 1, turn: 1, tool: 1 };
- function resultExtras(envelope) {
- var extras = {};
- var keys = Object.keys(envelope);
- for (var i = 0; i < keys.length; i++) {
- if (RESULT_PLUMBING[keys[i]] === undefined
- && keys[i] !== 'error' && keys[i] !== 'note'
- && keys[i] !== 'timeout') {
- extras[keys[i]] = envelope[keys[i]];
- }
- }
- return extras;
- }
- function resultSummary(evt, envelope) {
- var wrap = document.createElement('summary');
- var failed = (typeof envelope.exit_code === 'number'
- && envelope.exit_code !== 0) || envelope.ok === false;
- wrap.className = 'result-summary' + (failed ? ' failed' : '');
- wrap.appendChild(document.createTextNode('result'));
- var bits = resultBits(evt, envelope);
- if (bits.length > 0) {
- wrap.appendChild(document.createTextNode(' ('
- + bits.join(', ') + ')'));
- }
- return wrap;
- }
- // The summary's metric bits (exit, size, duration) -
- // shared by the expander summary and the inline line.
- function resultBits(evt, envelope) {
- var p = evt.payload || {};
- var bits = [];
- if (envelope.exit_code !== undefined
- && envelope.exit_code !== null) {
- bits.push('exit ' + envelope.exit_code);
- } else if (envelope.ok === false) {
- bits.push('failed');
- }
- // Prefer the envelope's own byte counters (they
- // count the pre-truncation output); fall back to
- // measuring the strings we hold.
- var bytes;
- if (typeof envelope.bytes_out === 'number'
- && typeof envelope.bytes_err === 'number') {
- bytes = envelope.bytes_out + envelope.bytes_err;
- } else {
- bytes = strBytes(envelope.stdout) + strBytes(envelope.stderr);
- }
- if (bytes > 0) bits.push(fmtBytes(bytes));
- if (envelope.truncated) bits.push('truncated');
- if (typeof envelope.duration_ms === 'number') {
- bits.push(fmtDuration(envelope.duration_ms));
- }
- if (p.turn !== undefined && p.turn !== null) bits.push('turn ' + p.turn);
- // A result STRING that is not JSON carries no
- // envelope; its own size is the only metric we
- // have, and it is about to be rendered raw.
- if (bits.length === 0 && typeof p.result === 'string'
- && !isPlainObject(parseMaybeJson(p.result))) {
- bits.push(fmtBytes(p.result.length));
- }
- return bits;
- }
- // A result renders INLINE when its body is short - a
- // one-line acknowledgement, a replacement count, a few
- // lines of output - with the metric bits as a quiet
- // prefix line; only long bodies collapse behind the
- // expander summary. An ATTACHED result (riding with its
- // call) is always inline - the output must be visible,
- // not behind a click - with oversized bodies clamped by
- // CSS instead.
- var INLINE_RESULT_LIMIT = 220;
- function resultView(evt, envelope, forceInline) {
- var wrap = document.createDocumentFragment();
- var node = resultBody(evt, envelope);
- var text = (node.textContent || '').trim();
- if (forceInline || text.length <= INLINE_RESULT_LIMIT) {
- var bits = resultBits(evt, envelope);
- if (bits.length > 0) {
- wrap.appendChild(para(bits.join(' \u00b7 '), 'result-meta'));
- }
- wrap.appendChild(node);
- return wrap;
- }
- var details = document.createElement('details');
- details.className = 'event-result';
- details.appendChild(resultSummary(evt, envelope));
- details.appendChild(node);
- return wrap;
- }
- function resultBody(evt, envelope) {
- var p = evt.payload || {};
- var div = document.createElement('div');
- div.className = 'event-result-body';
- // A result string that is NOT JSON is the output
- // text itself (the daemon sends truncated raw
- // output this way): show it verbatim.
- var out = '';
- var err = '';
- var parsed = parseMaybeJson(p.result);
- if (isPlainObject(parsed)) {
- out = strText(envelope.stdout);
- err = strText(envelope.stderr);
- } else if (typeof parsed === 'string') {
- out = parsed; // a JSON string literal
- } else if (typeof p.result === 'string') {
- out = p.result;
- } else {
- // Legacy object-shaped payload: the members
- // sit on the payload itself.
- out = strText(envelope.stdout);
- err = strText(envelope.stderr);
- }
- if (out.length > 0) {
- div.appendChild(preLine(out));
- }
- if (err.length > 0) {
- var errPre = preLine(err);
- errPre.className = 'event-pre event-stderr';
- div.appendChild(errPre);
- }
- if (out.length === 0 && err.length === 0) {
- // Empty stdout AND stderr. An error/note/
- // timeout member still said something, and a
- // non-run_command tool's answer IS the
- // envelope - render it through the tailored
- // result body first (a read window, a
- // replacement count, a task list), then the
- // raw JSON. Only an envelope with nothing left
- // to say is "(no output)".
- var note = strText(envelope.error) || strText(envelope.note)
- || (envelope.timeout ? 'timed out' : '');
- if (note.length > 0) {
- var notePre = preLine(note);
- notePre.className = 'event-pre event-stderr';
- div.appendChild(notePre);
- } else {
- var extras = resultExtras(envelope);
- var bespoke = tailoredResultBody(strText(p.tool), extras);
- if (bespoke) {
- div.appendChild(bespoke);
- } else if (Object.keys(extras).length > 0) {
- var compact = JSON.stringify(extras);
- div.appendChild(preLine(compact.length <= 120
- ? compact : JSON.stringify(extras, null, 2)));
- } else {
- var empty = document.createElement('p');
- empty.className = 'text-muted';
- empty.textContent = '(no output)';
- div.appendChild(empty);
- }
- }
- }
- return div;
- }
- // ---- tailored result bodies ----
- //
- // The daemon's file and task tools answer with
- // structured extras (a line window of a file, a
- // replacement count, a task list) that deserve better
- // than a JSON dump. Answers null for the generic view.
- function okPara(text) {
- var node = para(text);
- node.className = 'result-ok';
- return node;
- }
- function taskList(items) {
- var list = document.createElement('ul');
- list.className = 'result-tasks';
- items.forEach(function (item) {
- if (!isPlainObject(item)) return;
- var li = document.createElement('li');
- li.textContent = '#' + (item.id !== undefined ? item.id : '?')
- + ' ' + strText(item.title);
- var status = strText(item.status);
- if (status.length > 0) {
- var chip = document.createElement('span');
- chip.className = 'badge event-kind-state_change';
- chip.textContent = status;
- li.appendChild(chip);
- }
- list.appendChild(li);
- });
- return list;
- }
- function tailoredResultBody(tool, extras) {
- var frag = document.createDocumentFragment();
- if (strText(extras.content).length > 0
- || typeof extras.total_lines === 'number') {
- // read_file's answer: a line window of a file;
- // read_binary's: a byte window. The meta line
- // names the window; the content folds behind an
- // expander - a read is reference material, not
- // the action's output.
- var meta = [];
- if (strText(extras.path).length > 0) {
- meta.push(strText(extras.path));
- }
- if (typeof extras.total_lines === 'number') {
- meta.push('lines ' + (typeof extras.line_start === 'number'
- ? extras.line_start : 1) + '-'
- + (typeof extras.line_end === 'number'
- ? extras.line_end : extras.total_lines)
- + ' of ' + extras.total_lines);
- if (typeof extras.bytes === 'number') {
- meta.push(fmtBytes(extras.bytes));
- }
- } else {
- if (strText(extras.representation).length > 0) {
- meta.push(strText(extras.representation));
- }
- if (typeof extras.bytes_read === 'number') {
- meta.push(fmtBytes(extras.bytes_read)
- + (typeof extras.total_bytes === 'number'
- ? ' of ' + fmtBytes(extras.total_bytes) : ''));
- }
- if (typeof extras.offset === 'number' && extras.offset > 0) {
- meta.push('at ' + fmtBytes(extras.offset));
- }
- }
- if (extras.truncated) meta.push('truncated');
- if (meta.length > 0) {
- frag.appendChild(para(meta.join(' \u00b7 '), 'result-meta'));
- }
- if (strText(extras.content).length > 0) {
- var shown = strText(extras.content);
- frag.appendChild(fold('content \u00b7 ' + fmtBytes(shown.length),
- function () { return preLine(shown); }));
- }
- return frag;
- }
- if (tool === 'replace_in_file' && typeof extras.replacements === 'number') {
- return okPara(extras.replacements + ' replacement'
- + (extras.replacements === 1 ? '' : 's') + ' in '
- + strText(extras.path) + ' \u00b7 '
- + fmtBytes(typeof extras.bytes_written === 'number'
- ? extras.bytes_written : 0));
- }
- if (tool === 'write_file' && typeof extras.bytes === 'number') {
- return okPara('wrote ' + strText(extras.path) + ' \u00b7 '
- + fmtBytes(extras.bytes));
- }
- if (tool === 'overwrite_file' && typeof extras.bytes_written === 'number') {
- return okPara('rewrote ' + strText(extras.path) + ' \u00b7 '
- + fmtBytes(extras.bytes_written)
- + (typeof extras.previous_bytes === 'number'
- ? ' (was ' + fmtBytes(extras.previous_bytes) + ')' : ''));
- }
- if (tool === 'create_task' && typeof extras.task_id === 'number') {
- return okPara('created task #' + extras.task_id
- + (strText(extras.status).length > 0
- ? ' \u00b7 ' + strText(extras.status) : ''));
- }
- if (tool === 'get_task' && extras.id !== undefined) {
- return taskList([extras]);
- }
- if (Array.isArray(extras.tasks)) return taskList(extras.tasks);
- if (Array.isArray(extras.goals)) return taskList(extras.goals);
- var keys = Object.keys(extras);
- var allTrue = keys.length > 0;
- var allScalars = keys.length > 0 && keys.length <= 4;
- for (var i = 0; i < keys.length; i++) {
- var value = extras[keys[i]];
- if (value !== true) allTrue = false;
- if (value !== null && typeof value === 'object') allScalars = false;
- }
- // A pure acknowledgement ({recorded:true},
- // {accepted:true}): a green ok line, not JSON.
- if (allTrue) return okPara('ok');
- if (allScalars) {
- var bits = [];
- for (var j = 0; j < keys.length; j++) {
- bits.push(keys[j] + ': ' + extras[keys[j]]);
- }
- return para(bits.join(' \u00b7 '), 'result-meta');
- }
- return null;
- }
- // The tool call renders a TAILORED body per tool: the
- // head line always carries the tool and its target (a
- // $ command, a file path, a task id) and the payload
- // renders in the shape a human scans - a diff for
- // replace_in_file, a folded blob for file content,
- // prose for the text tools. A bespoke renderer answers
- // null when its fields are missing (a truncated args
- // string clamps mid-JSON and stops parsing); the
- // generic view then shows the raw text so data is
- // never hidden.
- function toolCallBody(p) {
- var frag = document.createDocumentFragment();
- var tool = strText(p.tool).length > 0 ? strText(p.tool) : 'tool';
- var parsed = parseMaybeJson(p.args);
- var args = isPlainObject(parsed) ? parsed : null;
- var bespoke = args ? tailoredCallBody(tool, args) : null;
- if (bespoke) return bespoke;
- var head = preLine(tool);
- head.className = 'event-pre event-command';
- frag.appendChild(head);
- if (args) {
- var rest = argsWithout(args, 'command');
- if (rest.length > 0) frag.appendChild(preLine(rest));
- } else if (strText(p.args).length > 0) {
- // Args that are not JSON: the daemon sent
- // text; never hide it behind a parse failure.
- frag.appendChild(preLine(p.args));
- } else if (p.args !== undefined && p.args !== null
- && typeof p.args !== 'string') {
- frag.appendChild(preLine(JSON.stringify(p.args, null, 2)));
- }
- return frag;
- }
- // ---- tailored rendering helpers ----
- function para(text, className) {
- var node = document.createElement('p');
- if (className) node.className = className;
- node.textContent = text;
- return node;
- }
- // The one-line tool head: the primary text plus muted
- // detail bits separated by middle dots.
- function headLine(text, bits) {
- var pre = preLine(text);
- pre.className = 'event-pre event-command';
- if (bits && bits.length > 0) {
- var tail = document.createElement('span');
- tail.className = 'text-muted';
- tail.textContent = ' \u00b7 ' + bits.join(' \u00b7 ');
- pre.appendChild(tail);
- }
- return pre;
- }
- // A collapsed fold for long payloads (file content,
- // plans, reports): the label carries the size so the
- // feed stays scannable and the content is one click
- // away.
- function fold(label, build) {
- var details = document.createElement('details');
- details.className = 'event-fold';
- var summary = document.createElement('summary');
- summary.textContent = label;
- details.appendChild(summary);
- details.appendChild(build());
- return details;
- }
- function contentFold(label, text) {
- if (strText(text).length === 0) return null;
- var body = text;
- return fold(label + ' \u00b7 ' + fmtBytes(body.length), function () {
- return preLine(body);
- });
- }
- // Integer with thousands separators (a plain loop, no
- // locale dependence).
- function fmtInt(n) {
- var digits = String(Math.abs(Math.round(n)));
- var out = '';
- for (var i = 0; i < digits.length; i++) {
- if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
- out += digits.charAt(i);
- }
- return (n < 0 ? '-' : '') + out;
- }
- // The first non-empty string member among the usual
- // text field names - the prose of an event whose kind
- // has no bespoke branch.
- function pickText(p) {
- var names = ['text', 'body', 'question', 'answer',
- 'verdict', 'message', 'reason'];
- for (var i = 0; i < names.length; i++) {
- if (strText(p[names[i]]).length > 0) return strText(p[names[i]]);
- }
- return '';
- }
- // ---- the replace_in_file diff ----
- //
- // Common leading/trailing lines become context
- // (clamped to a couple of lines each side); the
- // differing middle renders as -old/+new. A multi-hunk
- // edit collapses into one hunk - an honest
- // replacement view, never a wrong one. Oversized
- // sides keep their head and tail around an ellipsis
- // row.
- var DIFF_CONTEXT = 2;
- var DIFF_MAX_LINES = 400;
- function diffRows(oldText, newText) {
- var oldLines = oldText.split('\n');
- var newLines = newText.split('\n');
- var start = 0;
- while (start < oldLines.length && start < newLines.length
- && oldLines[start] === newLines[start]) {
- start++;
- }
- var endOld = oldLines.length;
- var endNew = newLines.length;
- while (endOld > start && endNew > start
- && oldLines[endOld - 1] === newLines[endNew - 1]) {
- endOld--;
- endNew--;
- }
- var rows = [];
- var i;
- var hasMiddle = endOld > start || endNew > start;
- if (hasMiddle) {
- for (i = Math.max(0, start - DIFF_CONTEXT); i < start; i++) {
- rows.push([' ', oldLines[i]]);
- }
- pushSide(rows, oldLines, start, endOld, '-');
- pushSide(rows, newLines, start, endNew, '+');
- for (i = endOld; i < Math.min(oldLines.length, endOld + DIFF_CONTEXT); i++) {
- rows.push([' ', oldLines[i]]);
- }
- }
- return rows;
- }
- function pushSide(rows, lines, begin, end, marker) {
- var count = end - begin;
- var half = Math.floor(DIFF_MAX_LINES / 2);
- if (count <= DIFF_MAX_LINES) {
- for (var i = begin; i < end; i++) rows.push([marker, lines[i]]);
- return;
- }
- for (var j = begin; j < begin + half; j++) rows.push([marker, lines[j]]);
- rows.push([marker, '... ' + (count - half * 2) + ' more lines ...']);
- for (var k = end - half; k < end; k++) rows.push([marker, lines[k]]);
- }
- function diffBlock(oldText, newText) {
- var rows = diffRows(oldText, newText);
- if (rows.length === 0) return null;
- var div = document.createElement('div');
- div.className = 'event-diff';
- rows.forEach(function (row) {
- var line = document.createElement('div');
- line.className = 'diff-line diff-'
- + (row[0] === '-' ? 'del' : (row[0] === '+' ? 'add' : 'ctx'));
- line.textContent = row[0] + ' ' + row[1];
- div.appendChild(line);
- });
- return div;
- }
- // ---- the write-family rendering ----
- //
- // The file languages worth naming on a head line (an
- // extension chip); anything else renders no language
- // bit. The ledger clamps args to 4 KiB, so the content
- // always rides VISIBLE (never behind a fold) with CSS
- // clamping the block.
- var FILE_LANGUAGES = {
- '.vala': 'Vala', '.vapi': 'Vapi', '.c': 'C', '.h': 'C',
- '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript',
- '.html': 'HTML', '.css': 'CSS', '.json': 'JSON',
- '.md': 'Markdown', '.sh': 'Shell', '.bash': 'Shell',
- '.xml': 'XML', '.yml': 'YAML', '.yaml': 'YAML',
- '.sql': 'SQL', '.toml': 'TOML', '.ini': 'INI',
- '.cfg': 'Config', '.diff': 'Diff', '.patch': 'Diff',
- '.rst': 'reST', '.txt': 'Text'
- };
- function languageOf(path) {
- var lower = path.toLowerCase();
- var slash = lower.lastIndexOf('/');
- var name = slash >= 0 ? lower.substring(slash + 1) : lower;
- if (name === 'meson.build') return 'Meson';
- if (name === 'makefile') return 'Make';
- var dot = name.lastIndexOf('.');
- if (dot < 1 || dot === name.length - 1) return '';
- return FILE_LANGUAGES[name.substring(dot)] || '';
- }
- function contentBits(content) {
- var lines = content.split('\n').length;
- return [lines + ' line' + (lines === 1 ? '' : 's'),
- fmtBytes(content.length)];
- }
- function contentBlock(content) {
- var pre = preLine(content);
- pre.className = 'event-pre';
- return pre;
- }
- // ---- the bespoke tool bodies ----
- //
- // Each branch answers a DocumentFragment when it can
- // render the call faithfully, null to fall back to the
- // generic view.
- function tailoredCallBody(tool, args) {
- var frag = document.createDocumentFragment();
- var path = strText(args.path);
- var command = strText(args.command);
- var question = strText(args.question);
- var bits = [];
- var node;
- if (tool === 'run_command') {
- if (command.length === 0) return null;
- if (typeof args.timeout_ms === 'number') {
- bits.push('timeout ' + Math.round(args.timeout_ms / 1000) + 's');
- }
- frag.appendChild(headLine('$ ' + command, bits));
- return frag;
- }
- if (tool === 'read_file') {
- if (path.length === 0) return null;
- if (typeof args.offset === 'number' && args.offset > 1) {
- bits.push('from line ' + args.offset);
- }
- if (typeof args.limit === 'number') {
- bits.push(args.limit + ' lines');
- }
- frag.appendChild(headLine('read_file ' + path, bits));
- return frag;
- }
- if (tool === 'read_binary') {
- if (path.length === 0) return null;
- if (strText(args.representation).length > 0) {
- bits.push(strText(args.representation));
- }
- if (typeof args.offset === 'number' || typeof args.length === 'number') {
- bits.push('bytes ' + (args.offset || 0) + '-'
- + ((args.offset || 0) + (args.length || 4096) - 1));
- }
- frag.appendChild(headLine('read_binary ' + path, bits));
- return frag;
- }
- if (tool === 'write_file' || tool === 'overwrite_file' || tool === 'temp_doc') {
- var content = strText(args.content);
- if (content.length === 0) return null;
- if (tool === 'temp_doc') {
- frag.appendChild(headLine('temp_doc', contentBits(content)));
- } else {
- if (path.length === 0) return null;
- var bits2 = contentBits(content);
- var language = languageOf(path);
- if (language.length > 0) bits2.unshift(language);
- frag.appendChild(headLine(tool + ' ' + path, bits2));
- }
- frag.appendChild(contentBlock(content));
- return frag;
- }
- if (tool === 'replace_in_file') {
- if (path.length === 0 || strText(args.old).length === 0) return null;
- if (typeof args.expected_count === 'number') {
- bits.push('expect ' + args.expected_count);
- }
- frag.appendChild(headLine('replace_in_file ' + path, bits));
- node = diffBlock(strText(args.old), strText(args.new));
- if (node) frag.appendChild(node);
- return frag;
- }
- if (tool === 'temp_doc') {
- if (strText(args.content).length === 0) return null;
- frag.appendChild(headLine('temp_doc',
- [fmtBytes(strText(args.content).length)]));
- node = contentFold('content', args.content);
- if (node) frag.appendChild(node);
- return frag;
- }
- if (tool === 'query_command' || tool === 'query_file' || tool === 'query_llm') {
- var prompt = question.length > 0 ? question : strText(args.prompt);
- if (tool === 'query_command' && command.length > 0) {
- frag.appendChild(headLine('$ ' + command));
- } else if (path.length > 0) {
- frag.appendChild(headLine(tool + ' ' + path));
- } else {
- frag.appendChild(headLine(tool));
- }
- if (prompt.length > 0) {
- frag.appendChild(para('Q: ' + prompt, 'event-question'));
- }
- return frag;
- }
- if (tool === 'create_task') {
- if (strText(args.title).length === 0) return null;
- if (strText(args.mode).length > 0) bits.push(strText(args.mode));
- if (strText(args.branch).length > 0) {
- bits.push('branch ' + strText(args.branch));
- }
- frag.appendChild(headLine('create_task: ' + strText(args.title), bits));
- if (strText(args.brief).length > 0) {
- node = contentFold('brief', args.brief);
- if (node) frag.appendChild(node);
- }
- return frag;
- }
- if (tool === 'enqueue_task' || tool === 'delete_task' || tool === 'get_task') {
- if (typeof args.task_id !== 'number') return null;
- frag.appendChild(headLine(tool + ' #' + args.task_id));
- return frag;
- }
- if (tool === 'list_goals') {
- if (strText(args.project).length > 0) {
- bits.push(strText(args.project));
- }
- if (typeof args.goal_id === 'number') {
- bits.push('#' + args.goal_id);
- }
- frag.appendChild(headLine('list_goals', bits));
- return frag;
- }
- if (tool === 'update_progress' || tool === 'add_comment' || tool === 'save_note') {
- var text = pickText(args);
- if (text.length === 0) return null;
- if (tool === 'save_note' && strText(args.project).length > 0) {
- bits.push(strText(args.project));
- }
- frag.appendChild(headLine(tool, bits));
- frag.appendChild(para(text));
- return frag;
- }
- if (tool === 'ask_question') {
- if (question.length === 0) return null;
- frag.appendChild(headLine('ask_question'));
- frag.appendChild(para('Q: ' + question, 'event-question'));
- if (Array.isArray(args.options) && args.options.length > 0) {
- var list = document.createElement('ul');
- args.options.forEach(function (option) {
- var item = document.createElement('li');
- item.textContent = String(option);
- list.appendChild(item);
- });
- frag.appendChild(list);
- }
- node = contentFold('context', args.context);
- if (node) frag.appendChild(node);
- return frag;
- }
- if (tool === 'submit_plan' || tool === 'submit_rfc' || tool === 'submit_report') {
- if (strText(args.markdown).length === 0) return null;
- frag.appendChild(headLine(tool));
- node = contentFold('markdown', args.markdown);
- if (node) frag.appendChild(node);
- return frag;
- }
- if (tool === 'finish') {
- if (strText(args.summary).length === 0) return null;
- if (strText(args.status).length > 0) bits.push(strText(args.status));
- frag.appendChild(headLine('finish', bits));
- node = contentFold('report', args.summary);
- if (node) frag.appendChild(node);
- return frag;
- }
- if (tool === 'yeehaw') {
- if (strText(args.message).length === 0) return null;
- frag.appendChild(headLine(tool));
- var whoop = document.createElement('p');
- var shout = document.createElement('span');
- shout.className = 'yeehaw-message';
- shout.textContent = strText(args.message);
- whoop.appendChild(shout);
- frag.appendChild(whoop);
- return frag;
- }
- return null;
- }
- // Every argument except `skip`, pretty-printed; ''
- // when nothing else is left to show.
- function argsWithout(args, skip) {
- var rest = {};
- var keys = Object.keys(args);
- for (var i = 0; i < keys.length; i++) {
- if (keys[i] === skip) continue;
- rest[keys[i]] = args[keys[i]];
- }
- return Object.keys(rest).length > 0
- ? JSON.stringify(rest, null, 2) : '';
- }
- function eventNode(evt, callsById) {
- var row = document.createElement('div');
- row.className = 'event-row ' + kindClass(evt.kind);
- var head = document.createElement('p');
- head.className = 'event-head';
- var badge = document.createElement('span');
- badge.className = 'badge ' + kindClass(evt.kind);
- badge.textContent = String(evt.kind || '').replace(/_/g, ' ');
- head.appendChild(badge);
- var actor = document.createElement('span');
- actor.className = 'text-muted';
- actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
- + (evt.created_display || '');
- head.appendChild(actor);
- row.appendChild(head);
- var body = document.createElement('div');
- body.className = 'event-body';
- var p = evt.payload || {};
- if (evt.kind === 'turn_text' && typeof p.text === 'string') {
- var think = preLine(p.text);
- think.className = 'event-pre event-turn';
- body.appendChild(think);
- } else if (evt.kind === 'tool_call') {
- body.appendChild(toolCallBody(p));
- var attached = callsById ? callsById[evt.id] : null;
- if (attached) {
- // The outcome rides with its call: the
- // attached block reads as the output of the
- // action above it, never a separate row - and
- // always visible, never behind a click.
- var mount = document.createElement('div');
- mount.className = 'event-attached';
- mount.appendChild(resultView(attached,
- resultEnvelope(attached), true));
- body.appendChild(mount);
- }
- } else if (evt.kind === 'tool_result') {
- var envelope = resultEnvelope(evt);
- body.appendChild(resultView(evt, envelope));
- } else if (evt.kind === 'command_output') {
- // The command the agent ran rides the same
- // event: prefix it so the output reads as
- // '$ cmd ... output' instead of bare text.
- var text = p.text || p.chunk || '';
- if (strText(p.command).length > 0) {
- var cmdPre = preLine('$ ' + p.command);
- cmdPre.className = 'event-pre event-command';
- body.appendChild(cmdPre);
- body.appendChild(preLine(text));
- } else {
- body.appendChild(preLine(text));
- }
- } else if (evt.kind === 'progress') {
- var note = document.createElement('p');
- note.textContent = p.text || '';
- body.appendChild(note);
- } else if (evt.kind === 'comment') {
- var quote = document.createElement('blockquote');
- if (p.quote) {
- var q = document.createElement('p');
- q.className = 'text-muted';
- q.textContent = '> ' + p.quote;
- quote.appendChild(q);
- }
- var cbody = document.createElement('p');
- cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
- quote.appendChild(cbody);
- body.appendChild(quote);
- } else if (evt.kind === 'yeehaw') {
- // The agent's celebration: the event-head badge
- // already carries the yeehaw palette (kindClass ->
- // .badge-yeehaw); here the message renders in the
- // bold .yeehaw-message span, never a payload dump.
- var whoop = document.createElement('p');
- var shout = document.createElement('span');
- shout.className = 'yeehaw-message';
- shout.textContent = strText(p.message);
- whoop.appendChild(shout);
- body.appendChild(whoop);
- } else if (evt.kind === 'usage') {
- // One glancable line: model, token counts with
- // separators, cached share, wall clock - not a
- // key/value list to squint at. The daemon may
- // report either token count alone.
- var ubits = [];
- if (strText(p.model).length > 0) ubits.push(strText(p.model));
- var inTok = typeof p.prompt_tokens === 'number'
- ? fmtInt(p.prompt_tokens) + ' in' : '';
- var outTok = typeof p.completion_tokens === 'number'
- ? fmtInt(p.completion_tokens) + ' out' : '';
- if (inTok.length > 0 || outTok.length > 0) {
- ubits.push(inTok
- + (inTok.length > 0 && outTok.length > 0 ? ' / ' : '')
- + outTok + ' tok');
- }
- if (typeof p.cached_tokens === 'number') {
- ubits.push(fmtInt(p.cached_tokens) + ' cached');
- }
- if (typeof p.elapsed_ms === 'number') {
- ubits.push(fmtDuration(p.elapsed_ms));
- }
- if (ubits.length === 0) ubits.push('unreported');
- body.appendChild(para(ubits.join(' \u00b7 '), 'event-usage'));
- } else if (evt.kind === 'state_change') {
- // {from, to, text} from the web's transitions:
- // the arrow line reads at a glance.
- var from = strText(p.from);
- var to = strText(p.to);
- if (from.length > 0 && to.length > 0) {
- body.appendChild(para(from + ' \u2192 ' + to));
- } else {
- body.appendChild(para(strText(p.text)
- || JSON.stringify(p)));
- }
- } else if (evt.kind === 'lifecycle') {
- // {event, detail?} from the daemon's harness
- // observations.
- var what = strText(p.event);
- var detail = strText(p.detail);
- if (what.length > 0) {
- body.appendChild(para(detail.length > 0
- ? what + ' \u2014 ' + detail : what));
- } else {
- body.appendChild(para(pickText(p) || JSON.stringify(p)));
- }
- } else if (evt.kind === 'answer' || evt.kind === 'verdict') {
- body.appendChild(para(pickText(p) || JSON.stringify(p)));
- } else {
- var generic = document.createElement('p');
- generic.textContent = p.text || JSON.stringify(p);
- body.appendChild(generic);
- }
- row.appendChild(body);
- return row;
- }
- function turnCard(events, callsById) {
- var card = document.createElement('div');
- card.className = 'turn-card';
- var head = document.createElement('p');
- head.className = 'turn-head';
- if (events[0].payload && events[0].payload.turn !== undefined
- && events[0].payload.turn !== null) {
- var chip = document.createElement('span');
- chip.className = 'turn-chip';
- chip.textContent = 'turn ' + events[0].payload.turn;
- head.appendChild(chip);
- } else {
- var plain = document.createElement('span');
- plain.textContent = 'agent turn';
- head.appendChild(plain);
- }
- var meta = document.createElement('span');
- meta.textContent = events.length + ' event'
- + (events.length === 1 ? '' : 's')
- + (events[0].session_id
- ? ' \u00b7 session ' + events[0].session_id : '');
- head.appendChild(meta);
- card.appendChild(head);
- events.forEach(function (evt) {
- card.appendChild(eventNode(evt, callsById));
- });
- return card;
- }
- // ---- quiet-call folding ----
- //
- // A side-effect tool whose content already renders as
- // its own event (update_progress -> progress,
- // add_comment -> comment, yeehaw -> yeehaw) shows as
- // that event ALONE when the call succeeded quietly:
- // the pair of rows around it (the duplicate call and
- // the bare ack result) folds away. A failure unfolds
- // both - the call carries the context, the result the
- // error. The companion must be visible between the
- // call and its result (the daemon parks it there), or
- // nothing would show instead.
- var FOLDABLE_TOOLS = { update_progress: 'progress',
- add_comment: 'comment', yeehaw: 'yeehaw' };
- function quietResult(evt) {
- var envelope = resultEnvelope(evt);
- if (envelope.ok === false) return false;
- if (typeof envelope.exit_code === 'number'
- && envelope.exit_code !== 0) return false;
- if (strText(envelope.error).length > 0
- || strText(envelope.note).length > 0) return false;
- if (envelope.timeout) return false;
- if (strText(envelope.stdout).length > 0
- || strText(envelope.stderr).length > 0) return false;
- var extras = resultExtras(envelope);
- var keys = Object.keys(extras);
- for (var i = 0; i < keys.length; i++) {
- var value = extras[keys[i]];
- if (value === true || value === false) continue;
- if (typeof value === 'string' && value.length === 0) continue;
- // A row pointer (comment_id, task_id...): the
- // content it names renders elsewhere.
- if (typeof value === 'number'
- && keys[i].indexOf('_id') === keys[i].length - 3) continue;
- return false;
- }
- return true;
- }
- // One call per turn: the first tool_result after a call
- // is its own (or the answer never came yet). The live
- // wire's result payloads carry no turn of their own -
- // matching rides on order, tool name and session, with
- // a turn mismatch (both present) the only rejection.
- function sameSession(a, b) {
- return a === b || a == null || b == null;
- }
- function matchResultIndex(rows, index) {
- var evt = rows[index];
- var p = evt.payload || {};
- if (typeof p.turn !== 'number') return -1;
- for (var j = index + 1; j < rows.length; j++) {
- var other = rows[j];
- if (other.kind === 'tool_result') {
- var q = other.payload || {};
- if ((q.turn === p.turn || q.turn == null)
- && sameSession(other.session_id, evt.session_id)
- && strText(q.tool) === strText(p.tool)) {
- return j;
- }
- return -1;
- }
- if (other.kind === 'tool_call') return -1;
- }
- return -1;
- }
- function foldedQuietCalls(rows) {
- var folded = {};
- rows.forEach(function (evt, index) {
- if (evt.kind !== 'tool_call') return;
- var p = evt.payload || {};
- if (typeof p.turn !== 'number') return;
- var companion = FOLDABLE_TOOLS[strText(p.tool)];
- if (!companion) return;
- var resultIndex = matchResultIndex(rows, index);
- if (resultIndex < 0) return;
- var hasCompanion = false;
- for (var k = index + 1; k < resultIndex; k++) {
- if (rows[k].kind === companion
- && sameSession(rows[k].session_id, evt.session_id)) {
- hasCompanion = true;
- break;
- }
- }
- if (hasCompanion && quietResult(rows[resultIndex])) {
- folded[index] = true;
- folded[resultIndex] = true;
- }
- });
- return folded;
- }
- // Rebuild the feed from the loaded window. `mode`
- // is 'append' (live SSE rows landed at the tail -
- // stick to the bottom only if the reader was
- // there), 'prepend' (older history was added
- // above - keep the viewport on the same rows), or
- // undefined (first render, filter or search change
- // - bottom is the reading position, so seed there;
- // afterwards behave like 'append'). Every branch
- // captures the scroll state before the rebuild and
- // only moves the viewport per its rule - a reader
- // scrolled up into the history is never yanked.
- function render(mode) {
- if (!feed) return;
- var wasAtBottom = atBottom();
- var heightBefore = feed.scrollHeight;
- var scrollTopBefore = feed.scrollTop;
- while (feed.firstChild) feed.removeChild(feed.firstChild);
- var visibleRows = state.events.filter(visible);
- if (visibleRows.length === 0) {
- var empty = document.createElement('p');
- empty.className = 'text-muted log-empty';
- empty.textContent = state.events.length === 0
- ? 'Waiting for the first events\u2026'
- : 'No events match the filters.';
- feed.appendChild(empty);
- }
- // Quiet-call folding + result attachment: a quiet
- // side-effect pair disappears behind its companion
- // event; every other call CARRIES its result - the
- // call row renders the action, the attached block
- // under it renders the outcome - so the two never
- // read as separate rows.
- var folded = foldedQuietCalls(visibleRows);
- var callsById = {};
- var ridesById = {};
- visibleRows.forEach(function (evt, index) {
- if (folded[index] || evt.kind !== 'tool_call') return;
- var r = matchResultIndex(visibleRows, index);
- if (r >= 0 && !folded[r]) {
- callsById[evt.id] = visibleRows[r];
- ridesById[visibleRows[r].id] = true;
- }
- });
- // Grouping: consecutive turn-family events sharing a
- // session and payload.turn render as one card; every
- // other event renders inline. Folded rows and rows
- // riding with their call drop out of the walk.
- var i = 0;
- while (i < visibleRows.length) {
- var evt = visibleRows[i];
- if (folded[i] || ridesById[evt.id]) { i++; continue; }
- var kind = evt.kind;
- if (kind === 'turn_text' || kind === 'tool_call'
- || kind === 'tool_result') {
- var turn = evt.payload ? evt.payload.turn : undefined;
- var group = [evt];
- var j = i + 1;
- while (j < visibleRows.length) {
- var next = visibleRows[j];
- if (folded[j] || ridesById[next.id]) { j++; continue; }
- var nextTurn = next.payload ? next.payload.turn : undefined;
- if ((next.kind === 'turn_text' || next.kind === 'tool_call'
- || next.kind === 'tool_result')
- && next.session_id === evt.session_id
- && (turn === nextTurn
- || (turn === undefined && nextTurn === undefined))) {
- group.push(next);
- j++;
- } else {
- break;
- }
- }
- feed.appendChild(turnCard(group, callsById));
- i = j;
- } else {
- feed.appendChild(eventNode(evt, callsById));
- i++;
- }
- }
- // The load-older control rides INSIDE the feed as
- // its first row - it scrolls away with the history
- // instead of squatting above the pane - so every
- // rebuild must re-seat it.
- if (moreButton) {
- feed.insertBefore(moreButton, feed.firstChild);
- moreButton.hidden = state.exhausted;
- moreButton.disabled = state.loading;
- moreButton.textContent = state.loading
- ? 'Loading\u2026' : 'Load older events';
- }
- if (!state.seeded && visibleRows.length > 0) {
- // First render carrying rows: newest is the
- // reading position, start pinned to it.
- state.seeded = true;
- scrollToBottom();
- } else if (mode === 'prepend') {
- if (wasAtBottom) {
- scrollToBottom();
- } else {
- // Grow the viewport downward by exactly
- // the height added above it, so the
- // rows under the cursor stay put.
- feed.scrollTop = scrollTopBefore
- + (feed.scrollHeight - heightBefore);
- }
- } else if (wasAtBottom) {
- scrollToBottom();
- } else {
- feed.scrollTop = scrollTopBefore;
- }
- }
- function onStreamMessage(message) {
- // Heartbeat frames carry the literal string
- // "ping" as data; named listeners never fire
- // because the server sends no event: line.
- if (message.data === 'ping') return;
- try {
- ingest(JSON.parse(message.data));
- render('append');
- } catch (error) {
- console.warn('[log] bad event:', error);
- }
- }
- function connect() {
- var log = statum.read('task_log');
- if (!log) return;
- var base = '/tasks/' + log.task_id;
- stream = new EventSource(base + '/events/stream');
- stream.onmessage = onStreamMessage;
- stream.onopen = function () {
- setLive('live');
- };
- stream.onerror = function () {
- setLive('retry');
- // EventSource retries on its own (retry: 3s
- // hint); the reconnect replays from the
- // last seen id and ingest dedupes it.
- };
- if (moreButton) {
- moreButton.addEventListener('click', function () {
- loadMore(base);
- });
- }
- }
- // Page one window of history older than the oldest
- // row we hold. The endpoint serves newest-first
- // within the page; reverse it so ingest prepends in
- // chronological order, and keep the viewport put.
- function loadMore(base) {
- if (state.loading || state.exhausted) return;
- state.loading = true;
- render();
- var url = base + '/events?before=' + (state.oldest || 0)
- + '&limit=' + PAGE_SIZE;
- fetch(url, { credentials: 'same-origin' })
- .then(function (response) { return response.json(); })
- .then(function (page) {
- var rows = page.events || [];
- rows.slice().reverse().forEach(ingest);
- if (!page.more || rows.length === 0) {
- state.exhausted = true;
- }
- state.loading = false;
- render('prepend');
- })
- .catch(function (error) {
- console.warn('[log] paging failed:', error);
- state.loading = false;
- render();
- });
- }
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var toggle = event.target.closest('[data-log-filter]');
- if (toggle) {
- var key = toggle.getAttribute('data-log-filter');
- state.filters[key] = !state.filters[key];
- toggle.classList.toggle('active', state.filters[key]);
- render();
- }
- });
- document.addEventListener('input', function (event) {
- if (!event.target.closest) return;
- var search = event.target.closest('[data-log-search]');
- if (search) {
- state.search = search.value.trim();
- render();
- }
- });
- statum.afterEvaluate(function () {
- var page = statum.read('task_log');
- if (!page || !page.exists) return;
- feed = document.querySelector('[data-log-feed]');
- moreButton = document.querySelector('[data-log-more]');
- liveBadge = document.querySelector('[data-log-live]');
- liveLabel = document.querySelector('[data-live-label]');
- taskStatus = strText(page.task_status);
- updateBadge();
- var header = document.querySelector('[data-log-title]');
- if (header) {
- header.textContent = '#' + page.task_id + ' \u2014 '
- + page.task_title;
- }
- if (!stream && feed) connect();
- render();
- });
- })();
- }
|