|
@@ -0,0 +1,1314 @@
|
|
|
|
|
+ // 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;
|
|
|
|
|
+
|
|
|
|
|
+ // The live pill: green pulse while the stream holds,
|
|
|
|
|
+ // amber while EventSource retries after a drop.
|
|
|
|
|
+ function setLive(state) {
|
|
|
|
|
+ if (!liveBadge) return;
|
|
|
|
|
+ liveBadge.className = 'log-live' + (state === 'retry' ? ' retry' : '');
|
|
|
|
|
+ if (liveLabel) {
|
|
|
|
|
+ liveLabel.textContent = state === 'retry'
|
|
|
|
|
+ ? 'reconnecting...' : 'live';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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.
|
|
|
|
|
+ var INLINE_RESULT_LIMIT = 220;
|
|
|
|
|
+
|
|
|
|
|
+ function resultView(evt, envelope) {
|
|
|
|
|
+ var wrap = document.createDocumentFragment();
|
|
|
|
|
+ var node = resultBody(evt, envelope);
|
|
|
|
|
+ var text = (node.textContent || '').trim();
|
|
|
|
|
+ if (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 = '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.
|
|
|
|
|
+ var meta = [];
|
|
|
|
|
+ if (strText(extras.path).length > 0) {
|
|
|
|
|
+ meta.push(strText(extras.path));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof extras.line_start === 'number'
|
|
|
|
|
+ && typeof extras.total_lines === 'number') {
|
|
|
|
|
+ meta.push('lines ' + extras.line_start + '-'
|
|
|
|
|
+ + (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));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (extras.truncated) meta.push('truncated');
|
|
|
|
|
+ if (meta.length > 0) {
|
|
|
|
|
+ frag.appendChild(para(meta.join(' \u00b7 '), 'result-meta'));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (strText(extras.content).length > 0) {
|
|
|
|
|
+ frag.appendChild(preLine(extras.content));
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 = '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 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') {
|
|
|
|
|
+ if (path.length === 0 || strText(args.content).length === 0) return null;
|
|
|
|
|
+ frag.appendChild(headLine(tool + ' ' + path,
|
|
|
|
|
+ [fmtBytes(strText(args.content).length)]));
|
|
|
|
|
+ node = contentFold('content', args.content);
|
|
|
|
|
+ if (node) frag.appendChild(node);
|
|
|
|
|
+ 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 = '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.
|
|
|
|
|
+ var mount = document.createElement('div');
|
|
|
|
|
+ mount.className = 'event-attached';
|
|
|
|
|
+ mount.appendChild(resultView(attached,
|
|
|
|
|
+ resultEnvelope(attached)));
|
|
|
|
|
+ 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 = '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;
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // One call per turn: the first tool_result after a call
|
|
|
|
|
+ // is its own (or the answer never came yet). Answers
|
|
|
|
|
+ // -1 when the scan hits another call first.
|
|
|
|
|
+ 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 && 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
|
|
|
|
|
+ && 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 = '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]');
|
|
|
|
|
+ var header = document.querySelector('[data-log-title]');
|
|
|
|
|
+ if (header) {
|
|
|
|
|
+ header.textContent = '#' + page.task_id + ' \u2014 '
|
|
|
|
|
+ + page.task_title;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!stream && feed) connect();
|
|
|
|
|
+ render();
|
|
|
|
|
+ });
|
|
|
|
|
+ })();
|
|
|
|
|
+ }
|