task_log.js 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423
  1. // The task's event log (plan 17 section 3.2): the unified
  2. // ledger's human view. The feed is client-side over the
  3. // plan-17 endpoints, and its contract is:
  4. //
  5. // CHRONOLOGY - rows render OLDEST at the top and NEWEST at
  6. // the bottom, like a terminal transcript. The SSE stream
  7. // replays the most-recent-30 window oldest-first and then
  8. // appends every new row, so live traffic just extends the
  9. // tail of the feed.
  10. //
  11. // SCROLL - the feed ([data-log-feed], max-height 72vh,
  12. // overflow-y auto) is scrolled to the bottom on the first
  13. // render that carries rows, and afterwards sticks to the
  14. // bottom ONLY when the reader was already at (or within
  15. // 48px of) the bottom before the render. A reader who
  16. // scrolled up into the history is never yanked down.
  17. //
  18. // PAGING - the load-older control sits above the feed
  19. // (older history lives above it) and pages backwards via
  20. // GET /tasks/<id>/events?before=<oldest loaded id>&limit=30.
  21. // The server serves each page newest-first; the client
  22. // reverses it to oldest-first and prepends, restoring the
  23. // viewport by the scrollHeight delta so the rows under the
  24. // reader's cursor do not move. A page whose `more` flag is
  25. // false marks the ledger exhausted and hides the control.
  26. //
  27. // STREAM - the server's SSE frames carry only id: and
  28. // data: lines (no `event:` name), so every frame lands on
  29. // `onmessage` and no named listener can ever fire. The
  30. // heartbeat frame's data is the literal string "ping";
  31. // every other frame is one JSON ledger row, ingested with
  32. // the id-dedupe (which also absorbs EventSource's replay
  33. // after a reconnect, resent from the last seen id).
  34. //
  35. // Turn cards group a run's turn_text + tool_call +
  36. // tool_result events by payload.turn (per session).
  37. //
  38. // PAYLOADS - the daemon serialises a tool call's arguments
  39. // and a tool result's envelope to a JSON STRING before it
  40. // stores them: args is a string like '{"command":"ls"}'
  41. // (never an object) and result is a string holding the
  42. // {"ok":..,"exit_code":..,"stdout":..,"stderr":..,
  43. // "duration_ms":..,"bytes_out":..,"bytes_err":..,
  44. // "truncated":..} envelope. Every render path parses these
  45. // members through parseMaybeJson first and falls back to
  46. // the raw string when it is not JSON - data is never
  47. // hidden. The command the agent ran must always be
  48. // visible: command_output prefixes '$ <command>', and each
  49. // tool_call renders through a tailored body
  50. // (tailoredCallBody) - run_command heads with '$ cmd', the
  51. // file tools show paths with folded content,
  52. // replace_in_file renders a -old/+new diff, the text tools
  53. // render prose - with the generic JSON view as the
  54. // backstop for unknown tools and truncated args. Results collapse
  55. // behind a summary carrying exit code, byte counts (from
  56. // the envelope when present), the truncated flag and
  57. // duration. Filters
  58. // (commands/results/comments/transitions/usage) and the
  59. // full-text search run client-side over the loaded window.
  60. // All rendering goes through textContent/createElement -
  61. // event payloads never touch innerHTML.
  62. //
  63. // This whole script body is deliberately ASCII-only
  64. // (comments included): the page compiler HTML-escapes
  65. // script bodies, so any non-ASCII literal would come back
  66. // out of the browser as literal entity text - the entity
  67. // name spelled out - instead of the character it names.
  68. // Non-ASCII characters are written as JS unicode escapes
  69. // (\u00b7 etc).
  70. if (window.statum) {
  71. (function () {
  72. 'use strict';
  73. // How close to the bottom (px) still counts as
  74. // "reading the tail" for the stick rule.
  75. var STICK_THRESHOLD = 48;
  76. var PAGE_SIZE = 30;
  77. var state = {
  78. events: [],
  79. oldest: 0,
  80. exhausted: false,
  81. loading: false,
  82. seeded: false,
  83. filters: { commands: true, results: true, comments: true,
  84. transitions: true, usage: true },
  85. search: ''
  86. };
  87. var feed = null;
  88. var moreButton = null;
  89. var stream = null;
  90. var liveBadge = null;
  91. var liveLabel = null;
  92. var taskStatus = '';
  93. var streamState = 'live';
  94. // The badge reflects the TASK first: only a working (or
  95. // assigned) task streams live events, so every other
  96. // status names itself on the pill - still, no pulse,
  97. // the task page's own display words. The stream's
  98. // health (reconnecting...) only rides a live task.
  99. var LIVE_STATUSES = { working: 1, assigned: 1 };
  100. var BADGE_LABELS = { suspended: 'parked',
  101. review_requested: 'review requested' };
  102. function updateBadge() {
  103. if (!liveBadge) return;
  104. var cls = 'log-live';
  105. var label;
  106. if (LIVE_STATUSES[taskStatus]) {
  107. if (streamState === 'retry') {
  108. cls += ' retry';
  109. label = 'reconnecting...';
  110. } else {
  111. label = 'live';
  112. }
  113. } else {
  114. cls += ' still badge badge-' + taskStatus;
  115. label = BADGE_LABELS[taskStatus] || taskStatus || 'idle';
  116. }
  117. liveBadge.className = cls;
  118. if (liveLabel) {
  119. liveLabel.textContent = label;
  120. }
  121. }
  122. function setLive(state) {
  123. streamState = state;
  124. updateBadge();
  125. }
  126. function kindClass(kind) {
  127. return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, '');
  128. }
  129. function filterOf(kind) {
  130. if (kind === 'tool_call') return 'commands';
  131. if (kind === 'tool_result' || kind === 'command_output') return 'results';
  132. if (kind === 'comment' || kind === 'answer' || kind === 'verdict'
  133. || kind === 'yeehaw') return 'comments';
  134. if (kind === 'usage') return 'usage';
  135. return 'transitions';
  136. }
  137. function visible(evt) {
  138. if (!state.filters[filterOf(evt.kind)]) return false;
  139. if (state.search.length > 0) {
  140. var haystack = evt.kind + ' ' + evt.actor + ' '
  141. + JSON.stringify(evt.payload || {})
  142. + ' ' + strText((evt.payload || {}).args)
  143. + ' ' + strText((evt.payload || {}).result);
  144. if (haystack.toLowerCase().indexOf(state.search.toLowerCase()) === -1) {
  145. return false;
  146. }
  147. }
  148. return true;
  149. }
  150. // state.events is kept oldest-first (ascending id):
  151. // insert before the first newer row, skip ids we
  152. // already hold (SSE replay after a reconnect sends
  153. // the last frame again).
  154. function ingest(evt) {
  155. var i = 0;
  156. while (i < state.events.length && state.events[i].id < evt.id) {
  157. i++;
  158. }
  159. if (i < state.events.length && state.events[i].id === evt.id) return;
  160. state.events.splice(i, 0, evt);
  161. if (evt.id < state.oldest || state.oldest === 0) {
  162. state.oldest = evt.id;
  163. }
  164. }
  165. // True when the feed is scrolled to (or within
  166. // STICK_THRESHOLD of) its bottom - a feed shorter
  167. // than its viewport counts as at-bottom.
  168. function atBottom() {
  169. if (!feed) return true;
  170. return feed.scrollHeight - feed.scrollTop - feed.clientHeight
  171. <= STICK_THRESHOLD;
  172. }
  173. // Pin the feed to its bottom edge (the newest row).
  174. function scrollToBottom() {
  175. if (!feed) return;
  176. feed.scrollTop = feed.scrollHeight;
  177. }
  178. // The daemon JSON-encodes tool arguments and tool
  179. // result envelopes into STRINGS before storing them;
  180. // older/fake pushes send plain objects. Parse the
  181. // string forms, pass objects through, and answer
  182. // undefined when a string is not JSON (the caller
  183. // then shows the raw text so data is never hidden).
  184. function parseMaybeJson(value) {
  185. if (typeof value !== 'string') return value;
  186. try {
  187. return JSON.parse(value);
  188. } catch (error) {
  189. return undefined;
  190. }
  191. }
  192. // True for a parsed member we can treat as a bag of
  193. // properties (the object-shaped push, or a string
  194. // that decoded to an object).
  195. function isPlainObject(value) {
  196. return value !== null && typeof value === 'object'
  197. && !Array.isArray(value);
  198. }
  199. function strText(value) {
  200. return typeof value === 'string' ? value : '';
  201. }
  202. function strBytes(value) {
  203. return typeof value === 'string' ? value.length : 0;
  204. }
  205. function fmtDuration(ms) {
  206. if (ms < 1000) return ms + ' ms';
  207. return (ms / 1000).toFixed(1) + ' s';
  208. }
  209. function fmtBytes(n) {
  210. if (n < 1024) return n + ' B';
  211. if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
  212. return (n / (1024 * 1024)).toFixed(1) + ' MiB';
  213. }
  214. function preLine(text) {
  215. var pre = document.createElement('pre');
  216. pre.className = 'event-pre';
  217. pre.textContent = text;
  218. return pre;
  219. }
  220. // The parsed result envelope: the daemon stores it
  221. // as a JSON string ({"ok":..,"exit_code":..,
  222. // "stdout":..,"stderr":..,"duration_ms":..,
  223. // "bytes_out":..,"bytes_err":..,"truncated":..});
  224. // the legacy object-shaped payload is the fallback.
  225. function resultEnvelope(evt) {
  226. var p = evt.payload || {};
  227. var parsed = parseMaybeJson(p.result);
  228. if (isPlainObject(parsed)) return parsed;
  229. if (isPlainObject(p)) return p;
  230. return {};
  231. }
  232. // Command plumbing: exit status, the streams and
  233. // their sizes, plus the ledger's own framing keys.
  234. // An envelope carrying ONLY these said nothing worth
  235. // printing; anything else in it (a task_id,
  236. // "recorded", a tasks list) IS the tool's answer
  237. // and must be rendered, not swallowed by "(no
  238. // output)".
  239. var RESULT_PLUMBING = { ok: 1, exit_code: 1, stdout: 1,
  240. stderr: 1, bytes_out: 1, bytes_err: 1, truncated: 1,
  241. duration_ms: 1, turn: 1, tool: 1 };
  242. function resultExtras(envelope) {
  243. var extras = {};
  244. var keys = Object.keys(envelope);
  245. for (var i = 0; i < keys.length; i++) {
  246. if (RESULT_PLUMBING[keys[i]] === undefined
  247. && keys[i] !== 'error' && keys[i] !== 'note'
  248. && keys[i] !== 'timeout') {
  249. extras[keys[i]] = envelope[keys[i]];
  250. }
  251. }
  252. return extras;
  253. }
  254. function resultSummary(evt, envelope) {
  255. var wrap = document.createElement('summary');
  256. var failed = (typeof envelope.exit_code === 'number'
  257. && envelope.exit_code !== 0) || envelope.ok === false;
  258. wrap.className = 'result-summary' + (failed ? ' failed' : '');
  259. wrap.appendChild(document.createTextNode('result'));
  260. var bits = resultBits(evt, envelope);
  261. if (bits.length > 0) {
  262. wrap.appendChild(document.createTextNode(' ('
  263. + bits.join(', ') + ')'));
  264. }
  265. return wrap;
  266. }
  267. // The summary's metric bits (exit, size, duration) -
  268. // shared by the expander summary and the inline line.
  269. function resultBits(evt, envelope) {
  270. var p = evt.payload || {};
  271. var bits = [];
  272. if (envelope.exit_code !== undefined
  273. && envelope.exit_code !== null) {
  274. bits.push('exit ' + envelope.exit_code);
  275. } else if (envelope.ok === false) {
  276. bits.push('failed');
  277. }
  278. // Prefer the envelope's own byte counters (they
  279. // count the pre-truncation output); fall back to
  280. // measuring the strings we hold.
  281. var bytes;
  282. if (typeof envelope.bytes_out === 'number'
  283. && typeof envelope.bytes_err === 'number') {
  284. bytes = envelope.bytes_out + envelope.bytes_err;
  285. } else {
  286. bytes = strBytes(envelope.stdout) + strBytes(envelope.stderr);
  287. }
  288. if (bytes > 0) bits.push(fmtBytes(bytes));
  289. if (envelope.truncated) bits.push('truncated');
  290. if (typeof envelope.duration_ms === 'number') {
  291. bits.push(fmtDuration(envelope.duration_ms));
  292. }
  293. if (p.turn !== undefined && p.turn !== null) bits.push('turn ' + p.turn);
  294. // A result STRING that is not JSON carries no
  295. // envelope; its own size is the only metric we
  296. // have, and it is about to be rendered raw.
  297. if (bits.length === 0 && typeof p.result === 'string'
  298. && !isPlainObject(parseMaybeJson(p.result))) {
  299. bits.push(fmtBytes(p.result.length));
  300. }
  301. return bits;
  302. }
  303. // A result renders INLINE when its body is short - a
  304. // one-line acknowledgement, a replacement count, a few
  305. // lines of output - with the metric bits as a quiet
  306. // prefix line; only long bodies collapse behind the
  307. // expander summary. An ATTACHED result (riding with its
  308. // call) is always inline - the output must be visible,
  309. // not behind a click - with oversized bodies clamped by
  310. // CSS instead.
  311. var INLINE_RESULT_LIMIT = 220;
  312. function resultView(evt, envelope, forceInline) {
  313. var wrap = document.createDocumentFragment();
  314. var node = resultBody(evt, envelope);
  315. var text = (node.textContent || '').trim();
  316. if (forceInline || text.length <= INLINE_RESULT_LIMIT) {
  317. var bits = resultBits(evt, envelope);
  318. if (bits.length > 0) {
  319. wrap.appendChild(para(bits.join(' \u00b7 '), 'result-meta'));
  320. }
  321. wrap.appendChild(node);
  322. return wrap;
  323. }
  324. var details = document.createElement('details');
  325. details.className = 'event-result';
  326. details.appendChild(resultSummary(evt, envelope));
  327. details.appendChild(node);
  328. return wrap;
  329. }
  330. function resultBody(evt, envelope) {
  331. var p = evt.payload || {};
  332. var div = document.createElement('div');
  333. div.className = 'event-result-body';
  334. // A result string that is NOT JSON is the output
  335. // text itself (the daemon sends truncated raw
  336. // output this way): show it verbatim.
  337. var out = '';
  338. var err = '';
  339. var parsed = parseMaybeJson(p.result);
  340. if (isPlainObject(parsed)) {
  341. out = strText(envelope.stdout);
  342. err = strText(envelope.stderr);
  343. } else if (typeof parsed === 'string') {
  344. out = parsed; // a JSON string literal
  345. } else if (typeof p.result === 'string') {
  346. out = p.result;
  347. } else {
  348. // Legacy object-shaped payload: the members
  349. // sit on the payload itself.
  350. out = strText(envelope.stdout);
  351. err = strText(envelope.stderr);
  352. }
  353. if (out.length > 0) {
  354. div.appendChild(preLine(out));
  355. }
  356. if (err.length > 0) {
  357. var errPre = preLine(err);
  358. errPre.className = 'event-pre event-stderr';
  359. div.appendChild(errPre);
  360. }
  361. if (out.length === 0 && err.length === 0) {
  362. // Empty stdout AND stderr. An error/note/
  363. // timeout member still said something, and a
  364. // non-run_command tool's answer IS the
  365. // envelope - render it through the tailored
  366. // result body first (a read window, a
  367. // replacement count, a task list), then the
  368. // raw JSON. Only an envelope with nothing left
  369. // to say is "(no output)".
  370. var note = strText(envelope.error) || strText(envelope.note)
  371. || (envelope.timeout ? 'timed out' : '');
  372. if (note.length > 0) {
  373. var notePre = preLine(note);
  374. notePre.className = 'event-pre event-stderr';
  375. div.appendChild(notePre);
  376. } else {
  377. var extras = resultExtras(envelope);
  378. var bespoke = tailoredResultBody(strText(p.tool), extras);
  379. if (bespoke) {
  380. div.appendChild(bespoke);
  381. } else if (Object.keys(extras).length > 0) {
  382. var compact = JSON.stringify(extras);
  383. div.appendChild(preLine(compact.length <= 120
  384. ? compact : JSON.stringify(extras, null, 2)));
  385. } else {
  386. var empty = document.createElement('p');
  387. empty.className = 'text-muted';
  388. empty.textContent = '(no output)';
  389. div.appendChild(empty);
  390. }
  391. }
  392. }
  393. return div;
  394. }
  395. // ---- tailored result bodies ----
  396. //
  397. // The daemon's file and task tools answer with
  398. // structured extras (a line window of a file, a
  399. // replacement count, a task list) that deserve better
  400. // than a JSON dump. Answers null for the generic view.
  401. function okPara(text) {
  402. var node = para(text);
  403. node.className = 'result-ok';
  404. return node;
  405. }
  406. function taskList(items) {
  407. var list = document.createElement('ul');
  408. list.className = 'result-tasks';
  409. items.forEach(function (item) {
  410. if (!isPlainObject(item)) return;
  411. var li = document.createElement('li');
  412. li.textContent = '#' + (item.id !== undefined ? item.id : '?')
  413. + ' ' + strText(item.title);
  414. var status = strText(item.status);
  415. if (status.length > 0) {
  416. var chip = document.createElement('span');
  417. chip.className = 'badge event-kind-state_change';
  418. chip.textContent = status;
  419. li.appendChild(chip);
  420. }
  421. list.appendChild(li);
  422. });
  423. return list;
  424. }
  425. function tailoredResultBody(tool, extras) {
  426. var frag = document.createDocumentFragment();
  427. if (strText(extras.content).length > 0
  428. || typeof extras.total_lines === 'number') {
  429. // read_file's answer: a line window of a file;
  430. // read_binary's: a byte window. The meta line
  431. // names the window; the content folds behind an
  432. // expander - a read is reference material, not
  433. // the action's output.
  434. var meta = [];
  435. if (strText(extras.path).length > 0) {
  436. meta.push(strText(extras.path));
  437. }
  438. if (typeof extras.total_lines === 'number') {
  439. meta.push('lines ' + (typeof extras.line_start === 'number'
  440. ? extras.line_start : 1) + '-'
  441. + (typeof extras.line_end === 'number'
  442. ? extras.line_end : extras.total_lines)
  443. + ' of ' + extras.total_lines);
  444. if (typeof extras.bytes === 'number') {
  445. meta.push(fmtBytes(extras.bytes));
  446. }
  447. } else {
  448. if (strText(extras.representation).length > 0) {
  449. meta.push(strText(extras.representation));
  450. }
  451. if (typeof extras.bytes_read === 'number') {
  452. meta.push(fmtBytes(extras.bytes_read)
  453. + (typeof extras.total_bytes === 'number'
  454. ? ' of ' + fmtBytes(extras.total_bytes) : ''));
  455. }
  456. if (typeof extras.offset === 'number' && extras.offset > 0) {
  457. meta.push('at ' + fmtBytes(extras.offset));
  458. }
  459. }
  460. if (extras.truncated) meta.push('truncated');
  461. if (meta.length > 0) {
  462. frag.appendChild(para(meta.join(' \u00b7 '), 'result-meta'));
  463. }
  464. if (strText(extras.content).length > 0) {
  465. var shown = strText(extras.content);
  466. frag.appendChild(fold('content \u00b7 ' + fmtBytes(shown.length),
  467. function () { return preLine(shown); }));
  468. }
  469. return frag;
  470. }
  471. if (tool === 'replace_in_file' && typeof extras.replacements === 'number') {
  472. return okPara(extras.replacements + ' replacement'
  473. + (extras.replacements === 1 ? '' : 's') + ' in '
  474. + strText(extras.path) + ' \u00b7 '
  475. + fmtBytes(typeof extras.bytes_written === 'number'
  476. ? extras.bytes_written : 0));
  477. }
  478. if (tool === 'write_file' && typeof extras.bytes === 'number') {
  479. return okPara('wrote ' + strText(extras.path) + ' \u00b7 '
  480. + fmtBytes(extras.bytes));
  481. }
  482. if (tool === 'overwrite_file' && typeof extras.bytes_written === 'number') {
  483. return okPara('rewrote ' + strText(extras.path) + ' \u00b7 '
  484. + fmtBytes(extras.bytes_written)
  485. + (typeof extras.previous_bytes === 'number'
  486. ? ' (was ' + fmtBytes(extras.previous_bytes) + ')' : ''));
  487. }
  488. if (tool === 'create_task' && typeof extras.task_id === 'number') {
  489. return okPara('created task #' + extras.task_id
  490. + (strText(extras.status).length > 0
  491. ? ' \u00b7 ' + strText(extras.status) : ''));
  492. }
  493. if (tool === 'get_task' && extras.id !== undefined) {
  494. return taskList([extras]);
  495. }
  496. if (Array.isArray(extras.tasks)) return taskList(extras.tasks);
  497. if (Array.isArray(extras.goals)) return taskList(extras.goals);
  498. var keys = Object.keys(extras);
  499. var allTrue = keys.length > 0;
  500. var allScalars = keys.length > 0 && keys.length <= 4;
  501. for (var i = 0; i < keys.length; i++) {
  502. var value = extras[keys[i]];
  503. if (value !== true) allTrue = false;
  504. if (value !== null && typeof value === 'object') allScalars = false;
  505. }
  506. // A pure acknowledgement ({recorded:true},
  507. // {accepted:true}): a green ok line, not JSON.
  508. if (allTrue) return okPara('ok');
  509. if (allScalars) {
  510. var bits = [];
  511. for (var j = 0; j < keys.length; j++) {
  512. bits.push(keys[j] + ': ' + extras[keys[j]]);
  513. }
  514. return para(bits.join(' \u00b7 '), 'result-meta');
  515. }
  516. return null;
  517. }
  518. // The tool call renders a TAILORED body per tool: the
  519. // head line always carries the tool and its target (a
  520. // $ command, a file path, a task id) and the payload
  521. // renders in the shape a human scans - a diff for
  522. // replace_in_file, a folded blob for file content,
  523. // prose for the text tools. A bespoke renderer answers
  524. // null when its fields are missing (a truncated args
  525. // string clamps mid-JSON and stops parsing); the
  526. // generic view then shows the raw text so data is
  527. // never hidden.
  528. function toolCallBody(p) {
  529. var frag = document.createDocumentFragment();
  530. var tool = strText(p.tool).length > 0 ? strText(p.tool) : 'tool';
  531. var parsed = parseMaybeJson(p.args);
  532. var args = isPlainObject(parsed) ? parsed : null;
  533. var bespoke = args ? tailoredCallBody(tool, args) : null;
  534. if (bespoke) return bespoke;
  535. var head = preLine(tool);
  536. head.className = 'event-pre event-command';
  537. frag.appendChild(head);
  538. if (args) {
  539. var rest = argsWithout(args, 'command');
  540. if (rest.length > 0) frag.appendChild(preLine(rest));
  541. } else if (strText(p.args).length > 0) {
  542. // Args that are not JSON: the daemon sent
  543. // text; never hide it behind a parse failure.
  544. frag.appendChild(preLine(p.args));
  545. } else if (p.args !== undefined && p.args !== null
  546. && typeof p.args !== 'string') {
  547. frag.appendChild(preLine(JSON.stringify(p.args, null, 2)));
  548. }
  549. return frag;
  550. }
  551. // ---- tailored rendering helpers ----
  552. function para(text, className) {
  553. var node = document.createElement('p');
  554. if (className) node.className = className;
  555. node.textContent = text;
  556. return node;
  557. }
  558. // The one-line tool head: the primary text plus muted
  559. // detail bits separated by middle dots.
  560. function headLine(text, bits) {
  561. var pre = preLine(text);
  562. pre.className = 'event-pre event-command';
  563. if (bits && bits.length > 0) {
  564. var tail = document.createElement('span');
  565. tail.className = 'text-muted';
  566. tail.textContent = ' \u00b7 ' + bits.join(' \u00b7 ');
  567. pre.appendChild(tail);
  568. }
  569. return pre;
  570. }
  571. // A collapsed fold for long payloads (file content,
  572. // plans, reports): the label carries the size so the
  573. // feed stays scannable and the content is one click
  574. // away.
  575. function fold(label, build) {
  576. var details = document.createElement('details');
  577. details.className = 'event-fold';
  578. var summary = document.createElement('summary');
  579. summary.textContent = label;
  580. details.appendChild(summary);
  581. details.appendChild(build());
  582. return details;
  583. }
  584. function contentFold(label, text) {
  585. if (strText(text).length === 0) return null;
  586. var body = text;
  587. return fold(label + ' \u00b7 ' + fmtBytes(body.length), function () {
  588. return preLine(body);
  589. });
  590. }
  591. // Integer with thousands separators (a plain loop, no
  592. // locale dependence).
  593. function fmtInt(n) {
  594. var digits = String(Math.abs(Math.round(n)));
  595. var out = '';
  596. for (var i = 0; i < digits.length; i++) {
  597. if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
  598. out += digits.charAt(i);
  599. }
  600. return (n < 0 ? '-' : '') + out;
  601. }
  602. // The first non-empty string member among the usual
  603. // text field names - the prose of an event whose kind
  604. // has no bespoke branch.
  605. function pickText(p) {
  606. var names = ['text', 'body', 'question', 'answer',
  607. 'verdict', 'message', 'reason'];
  608. for (var i = 0; i < names.length; i++) {
  609. if (strText(p[names[i]]).length > 0) return strText(p[names[i]]);
  610. }
  611. return '';
  612. }
  613. // ---- the replace_in_file diff ----
  614. //
  615. // Common leading/trailing lines become context
  616. // (clamped to a couple of lines each side); the
  617. // differing middle renders as -old/+new. A multi-hunk
  618. // edit collapses into one hunk - an honest
  619. // replacement view, never a wrong one. Oversized
  620. // sides keep their head and tail around an ellipsis
  621. // row.
  622. var DIFF_CONTEXT = 2;
  623. var DIFF_MAX_LINES = 400;
  624. function diffRows(oldText, newText) {
  625. var oldLines = oldText.split('\n');
  626. var newLines = newText.split('\n');
  627. var start = 0;
  628. while (start < oldLines.length && start < newLines.length
  629. && oldLines[start] === newLines[start]) {
  630. start++;
  631. }
  632. var endOld = oldLines.length;
  633. var endNew = newLines.length;
  634. while (endOld > start && endNew > start
  635. && oldLines[endOld - 1] === newLines[endNew - 1]) {
  636. endOld--;
  637. endNew--;
  638. }
  639. var rows = [];
  640. var i;
  641. var hasMiddle = endOld > start || endNew > start;
  642. if (hasMiddle) {
  643. for (i = Math.max(0, start - DIFF_CONTEXT); i < start; i++) {
  644. rows.push([' ', oldLines[i]]);
  645. }
  646. pushSide(rows, oldLines, start, endOld, '-');
  647. pushSide(rows, newLines, start, endNew, '+');
  648. for (i = endOld; i < Math.min(oldLines.length, endOld + DIFF_CONTEXT); i++) {
  649. rows.push([' ', oldLines[i]]);
  650. }
  651. }
  652. return rows;
  653. }
  654. function pushSide(rows, lines, begin, end, marker) {
  655. var count = end - begin;
  656. var half = Math.floor(DIFF_MAX_LINES / 2);
  657. if (count <= DIFF_MAX_LINES) {
  658. for (var i = begin; i < end; i++) rows.push([marker, lines[i]]);
  659. return;
  660. }
  661. for (var j = begin; j < begin + half; j++) rows.push([marker, lines[j]]);
  662. rows.push([marker, '... ' + (count - half * 2) + ' more lines ...']);
  663. for (var k = end - half; k < end; k++) rows.push([marker, lines[k]]);
  664. }
  665. function diffBlock(oldText, newText) {
  666. var rows = diffRows(oldText, newText);
  667. if (rows.length === 0) return null;
  668. var div = document.createElement('div');
  669. div.className = 'event-diff';
  670. rows.forEach(function (row) {
  671. var line = document.createElement('div');
  672. line.className = 'diff-line diff-'
  673. + (row[0] === '-' ? 'del' : (row[0] === '+' ? 'add' : 'ctx'));
  674. line.textContent = row[0] + ' ' + row[1];
  675. div.appendChild(line);
  676. });
  677. return div;
  678. }
  679. // ---- the write-family rendering ----
  680. //
  681. // The file languages worth naming on a head line (an
  682. // extension chip); anything else renders no language
  683. // bit. The ledger clamps args to 4 KiB, so the content
  684. // always rides VISIBLE (never behind a fold) with CSS
  685. // clamping the block.
  686. var FILE_LANGUAGES = {
  687. '.vala': 'Vala', '.vapi': 'Vapi', '.c': 'C', '.h': 'C',
  688. '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript',
  689. '.html': 'HTML', '.css': 'CSS', '.json': 'JSON',
  690. '.md': 'Markdown', '.sh': 'Shell', '.bash': 'Shell',
  691. '.xml': 'XML', '.yml': 'YAML', '.yaml': 'YAML',
  692. '.sql': 'SQL', '.toml': 'TOML', '.ini': 'INI',
  693. '.cfg': 'Config', '.diff': 'Diff', '.patch': 'Diff',
  694. '.rst': 'reST', '.txt': 'Text'
  695. };
  696. function languageOf(path) {
  697. var lower = path.toLowerCase();
  698. var slash = lower.lastIndexOf('/');
  699. var name = slash >= 0 ? lower.substring(slash + 1) : lower;
  700. if (name === 'meson.build') return 'Meson';
  701. if (name === 'makefile') return 'Make';
  702. var dot = name.lastIndexOf('.');
  703. if (dot < 1 || dot === name.length - 1) return '';
  704. return FILE_LANGUAGES[name.substring(dot)] || '';
  705. }
  706. function contentBits(content) {
  707. var lines = content.split('\n').length;
  708. return [lines + ' line' + (lines === 1 ? '' : 's'),
  709. fmtBytes(content.length)];
  710. }
  711. function contentBlock(content) {
  712. var pre = preLine(content);
  713. pre.className = 'event-pre';
  714. return pre;
  715. }
  716. // ---- the bespoke tool bodies ----
  717. //
  718. // Each branch answers a DocumentFragment when it can
  719. // render the call faithfully, null to fall back to the
  720. // generic view.
  721. function tailoredCallBody(tool, args) {
  722. var frag = document.createDocumentFragment();
  723. var path = strText(args.path);
  724. var command = strText(args.command);
  725. var question = strText(args.question);
  726. var bits = [];
  727. var node;
  728. if (tool === 'run_command') {
  729. if (command.length === 0) return null;
  730. if (typeof args.timeout_ms === 'number') {
  731. bits.push('timeout ' + Math.round(args.timeout_ms / 1000) + 's');
  732. }
  733. frag.appendChild(headLine('$ ' + command, bits));
  734. return frag;
  735. }
  736. if (tool === 'read_file') {
  737. if (path.length === 0) return null;
  738. if (typeof args.offset === 'number' && args.offset > 1) {
  739. bits.push('from line ' + args.offset);
  740. }
  741. if (typeof args.limit === 'number') {
  742. bits.push(args.limit + ' lines');
  743. }
  744. frag.appendChild(headLine('read_file ' + path, bits));
  745. return frag;
  746. }
  747. if (tool === 'read_binary') {
  748. if (path.length === 0) return null;
  749. if (strText(args.representation).length > 0) {
  750. bits.push(strText(args.representation));
  751. }
  752. if (typeof args.offset === 'number' || typeof args.length === 'number') {
  753. bits.push('bytes ' + (args.offset || 0) + '-'
  754. + ((args.offset || 0) + (args.length || 4096) - 1));
  755. }
  756. frag.appendChild(headLine('read_binary ' + path, bits));
  757. return frag;
  758. }
  759. if (tool === 'write_file' || tool === 'overwrite_file' || tool === 'temp_doc') {
  760. var content = strText(args.content);
  761. if (content.length === 0) return null;
  762. if (tool === 'temp_doc') {
  763. frag.appendChild(headLine('temp_doc', contentBits(content)));
  764. } else {
  765. if (path.length === 0) return null;
  766. var bits2 = contentBits(content);
  767. var language = languageOf(path);
  768. if (language.length > 0) bits2.unshift(language);
  769. frag.appendChild(headLine(tool + ' ' + path, bits2));
  770. }
  771. frag.appendChild(contentBlock(content));
  772. return frag;
  773. }
  774. if (tool === 'replace_in_file') {
  775. if (path.length === 0 || strText(args.old).length === 0) return null;
  776. if (typeof args.expected_count === 'number') {
  777. bits.push('expect ' + args.expected_count);
  778. }
  779. frag.appendChild(headLine('replace_in_file ' + path, bits));
  780. node = diffBlock(strText(args.old), strText(args.new));
  781. if (node) frag.appendChild(node);
  782. return frag;
  783. }
  784. if (tool === 'temp_doc') {
  785. if (strText(args.content).length === 0) return null;
  786. frag.appendChild(headLine('temp_doc',
  787. [fmtBytes(strText(args.content).length)]));
  788. node = contentFold('content', args.content);
  789. if (node) frag.appendChild(node);
  790. return frag;
  791. }
  792. if (tool === 'query_command' || tool === 'query_file' || tool === 'query_llm') {
  793. var prompt = question.length > 0 ? question : strText(args.prompt);
  794. if (tool === 'query_command' && command.length > 0) {
  795. frag.appendChild(headLine('$ ' + command));
  796. } else if (path.length > 0) {
  797. frag.appendChild(headLine(tool + ' ' + path));
  798. } else {
  799. frag.appendChild(headLine(tool));
  800. }
  801. if (prompt.length > 0) {
  802. frag.appendChild(para('Q: ' + prompt, 'event-question'));
  803. }
  804. return frag;
  805. }
  806. if (tool === 'create_task') {
  807. if (strText(args.title).length === 0) return null;
  808. if (strText(args.mode).length > 0) bits.push(strText(args.mode));
  809. if (strText(args.branch).length > 0) {
  810. bits.push('branch ' + strText(args.branch));
  811. }
  812. frag.appendChild(headLine('create_task: ' + strText(args.title), bits));
  813. if (strText(args.brief).length > 0) {
  814. node = contentFold('brief', args.brief);
  815. if (node) frag.appendChild(node);
  816. }
  817. return frag;
  818. }
  819. if (tool === 'enqueue_task' || tool === 'delete_task' || tool === 'get_task') {
  820. if (typeof args.task_id !== 'number') return null;
  821. frag.appendChild(headLine(tool + ' #' + args.task_id));
  822. return frag;
  823. }
  824. if (tool === 'list_goals') {
  825. if (strText(args.project).length > 0) {
  826. bits.push(strText(args.project));
  827. }
  828. if (typeof args.goal_id === 'number') {
  829. bits.push('#' + args.goal_id);
  830. }
  831. frag.appendChild(headLine('list_goals', bits));
  832. return frag;
  833. }
  834. if (tool === 'update_progress' || tool === 'add_comment' || tool === 'save_note') {
  835. var text = pickText(args);
  836. if (text.length === 0) return null;
  837. if (tool === 'save_note' && strText(args.project).length > 0) {
  838. bits.push(strText(args.project));
  839. }
  840. frag.appendChild(headLine(tool, bits));
  841. frag.appendChild(para(text));
  842. return frag;
  843. }
  844. if (tool === 'ask_question') {
  845. if (question.length === 0) return null;
  846. frag.appendChild(headLine('ask_question'));
  847. frag.appendChild(para('Q: ' + question, 'event-question'));
  848. if (Array.isArray(args.options) && args.options.length > 0) {
  849. var list = document.createElement('ul');
  850. args.options.forEach(function (option) {
  851. var item = document.createElement('li');
  852. item.textContent = String(option);
  853. list.appendChild(item);
  854. });
  855. frag.appendChild(list);
  856. }
  857. node = contentFold('context', args.context);
  858. if (node) frag.appendChild(node);
  859. return frag;
  860. }
  861. if (tool === 'submit_plan' || tool === 'submit_rfc' || tool === 'submit_report') {
  862. if (strText(args.markdown).length === 0) return null;
  863. frag.appendChild(headLine(tool));
  864. node = contentFold('markdown', args.markdown);
  865. if (node) frag.appendChild(node);
  866. return frag;
  867. }
  868. if (tool === 'finish') {
  869. if (strText(args.summary).length === 0) return null;
  870. if (strText(args.status).length > 0) bits.push(strText(args.status));
  871. frag.appendChild(headLine('finish', bits));
  872. node = contentFold('report', args.summary);
  873. if (node) frag.appendChild(node);
  874. return frag;
  875. }
  876. if (tool === 'yeehaw') {
  877. if (strText(args.message).length === 0) return null;
  878. frag.appendChild(headLine(tool));
  879. var whoop = document.createElement('p');
  880. var shout = document.createElement('span');
  881. shout.className = 'yeehaw-message';
  882. shout.textContent = strText(args.message);
  883. whoop.appendChild(shout);
  884. frag.appendChild(whoop);
  885. return frag;
  886. }
  887. return null;
  888. }
  889. // Every argument except `skip`, pretty-printed; ''
  890. // when nothing else is left to show.
  891. function argsWithout(args, skip) {
  892. var rest = {};
  893. var keys = Object.keys(args);
  894. for (var i = 0; i < keys.length; i++) {
  895. if (keys[i] === skip) continue;
  896. rest[keys[i]] = args[keys[i]];
  897. }
  898. return Object.keys(rest).length > 0
  899. ? JSON.stringify(rest, null, 2) : '';
  900. }
  901. function eventNode(evt, callsById) {
  902. var row = document.createElement('div');
  903. row.className = 'event-row ' + kindClass(evt.kind);
  904. var head = document.createElement('p');
  905. head.className = 'event-head';
  906. var badge = document.createElement('span');
  907. badge.className = 'badge ' + kindClass(evt.kind);
  908. badge.textContent = String(evt.kind || '').replace(/_/g, ' ');
  909. head.appendChild(badge);
  910. var actor = document.createElement('span');
  911. actor.className = 'text-muted';
  912. actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
  913. + (evt.created_display || '');
  914. head.appendChild(actor);
  915. row.appendChild(head);
  916. var body = document.createElement('div');
  917. body.className = 'event-body';
  918. var p = evt.payload || {};
  919. if (evt.kind === 'turn_text' && typeof p.text === 'string') {
  920. var think = preLine(p.text);
  921. think.className = 'event-pre event-turn';
  922. body.appendChild(think);
  923. } else if (evt.kind === 'tool_call') {
  924. body.appendChild(toolCallBody(p));
  925. var attached = callsById ? callsById[evt.id] : null;
  926. if (attached) {
  927. // The outcome rides with its call: the
  928. // attached block reads as the output of the
  929. // action above it, never a separate row - and
  930. // always visible, never behind a click.
  931. var mount = document.createElement('div');
  932. mount.className = 'event-attached';
  933. mount.appendChild(resultView(attached,
  934. resultEnvelope(attached), true));
  935. body.appendChild(mount);
  936. }
  937. } else if (evt.kind === 'tool_result') {
  938. var envelope = resultEnvelope(evt);
  939. body.appendChild(resultView(evt, envelope));
  940. } else if (evt.kind === 'command_output') {
  941. // The command the agent ran rides the same
  942. // event: prefix it so the output reads as
  943. // '$ cmd ... output' instead of bare text.
  944. var text = p.text || p.chunk || '';
  945. if (strText(p.command).length > 0) {
  946. var cmdPre = preLine('$ ' + p.command);
  947. cmdPre.className = 'event-pre event-command';
  948. body.appendChild(cmdPre);
  949. body.appendChild(preLine(text));
  950. } else {
  951. body.appendChild(preLine(text));
  952. }
  953. } else if (evt.kind === 'progress') {
  954. var note = document.createElement('p');
  955. note.textContent = p.text || '';
  956. body.appendChild(note);
  957. } else if (evt.kind === 'comment') {
  958. var quote = document.createElement('blockquote');
  959. if (p.quote) {
  960. var q = document.createElement('p');
  961. q.className = 'text-muted';
  962. q.textContent = '> ' + p.quote;
  963. quote.appendChild(q);
  964. }
  965. var cbody = document.createElement('p');
  966. cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
  967. quote.appendChild(cbody);
  968. body.appendChild(quote);
  969. } else if (evt.kind === 'yeehaw') {
  970. // The agent's celebration: the event-head badge
  971. // already carries the yeehaw palette (kindClass ->
  972. // .badge-yeehaw); here the message renders in the
  973. // bold .yeehaw-message span, never a payload dump.
  974. var whoop = document.createElement('p');
  975. var shout = document.createElement('span');
  976. shout.className = 'yeehaw-message';
  977. shout.textContent = strText(p.message);
  978. whoop.appendChild(shout);
  979. body.appendChild(whoop);
  980. } else if (evt.kind === 'usage') {
  981. // One glancable line: model, token counts with
  982. // separators, cached share, wall clock - not a
  983. // key/value list to squint at. The daemon may
  984. // report either token count alone.
  985. var ubits = [];
  986. if (strText(p.model).length > 0) ubits.push(strText(p.model));
  987. var inTok = typeof p.prompt_tokens === 'number'
  988. ? fmtInt(p.prompt_tokens) + ' in' : '';
  989. var outTok = typeof p.completion_tokens === 'number'
  990. ? fmtInt(p.completion_tokens) + ' out' : '';
  991. if (inTok.length > 0 || outTok.length > 0) {
  992. ubits.push(inTok
  993. + (inTok.length > 0 && outTok.length > 0 ? ' / ' : '')
  994. + outTok + ' tok');
  995. }
  996. if (typeof p.cached_tokens === 'number') {
  997. ubits.push(fmtInt(p.cached_tokens) + ' cached');
  998. }
  999. if (typeof p.elapsed_ms === 'number') {
  1000. ubits.push(fmtDuration(p.elapsed_ms));
  1001. }
  1002. if (ubits.length === 0) ubits.push('unreported');
  1003. body.appendChild(para(ubits.join(' \u00b7 '), 'event-usage'));
  1004. } else if (evt.kind === 'state_change') {
  1005. // {from, to, text} from the web's transitions:
  1006. // the arrow line reads at a glance.
  1007. var from = strText(p.from);
  1008. var to = strText(p.to);
  1009. if (from.length > 0 && to.length > 0) {
  1010. body.appendChild(para(from + ' \u2192 ' + to));
  1011. } else {
  1012. body.appendChild(para(strText(p.text)
  1013. || JSON.stringify(p)));
  1014. }
  1015. } else if (evt.kind === 'lifecycle') {
  1016. // {event, detail?} from the daemon's harness
  1017. // observations.
  1018. var what = strText(p.event);
  1019. var detail = strText(p.detail);
  1020. if (what.length > 0) {
  1021. body.appendChild(para(detail.length > 0
  1022. ? what + ' \u2014 ' + detail : what));
  1023. } else {
  1024. body.appendChild(para(pickText(p) || JSON.stringify(p)));
  1025. }
  1026. } else if (evt.kind === 'answer' || evt.kind === 'verdict') {
  1027. body.appendChild(para(pickText(p) || JSON.stringify(p)));
  1028. } else {
  1029. var generic = document.createElement('p');
  1030. generic.textContent = p.text || JSON.stringify(p);
  1031. body.appendChild(generic);
  1032. }
  1033. row.appendChild(body);
  1034. return row;
  1035. }
  1036. function turnCard(events, callsById) {
  1037. var card = document.createElement('div');
  1038. card.className = 'turn-card';
  1039. var head = document.createElement('p');
  1040. head.className = 'turn-head';
  1041. if (events[0].payload && events[0].payload.turn !== undefined
  1042. && events[0].payload.turn !== null) {
  1043. var chip = document.createElement('span');
  1044. chip.className = 'turn-chip';
  1045. chip.textContent = 'turn ' + events[0].payload.turn;
  1046. head.appendChild(chip);
  1047. } else {
  1048. var plain = document.createElement('span');
  1049. plain.textContent = 'agent turn';
  1050. head.appendChild(plain);
  1051. }
  1052. var meta = document.createElement('span');
  1053. meta.textContent = events.length + ' event'
  1054. + (events.length === 1 ? '' : 's')
  1055. + (events[0].session_id
  1056. ? ' \u00b7 session ' + events[0].session_id : '');
  1057. head.appendChild(meta);
  1058. card.appendChild(head);
  1059. events.forEach(function (evt) {
  1060. card.appendChild(eventNode(evt, callsById));
  1061. });
  1062. return card;
  1063. }
  1064. // ---- quiet-call folding ----
  1065. //
  1066. // A side-effect tool whose content already renders as
  1067. // its own event (update_progress -> progress,
  1068. // add_comment -> comment, yeehaw -> yeehaw) shows as
  1069. // that event ALONE when the call succeeded quietly:
  1070. // the pair of rows around it (the duplicate call and
  1071. // the bare ack result) folds away. A failure unfolds
  1072. // both - the call carries the context, the result the
  1073. // error. The companion must be visible between the
  1074. // call and its result (the daemon parks it there), or
  1075. // nothing would show instead.
  1076. var FOLDABLE_TOOLS = { update_progress: 'progress',
  1077. add_comment: 'comment', yeehaw: 'yeehaw' };
  1078. function quietResult(evt) {
  1079. var envelope = resultEnvelope(evt);
  1080. if (envelope.ok === false) return false;
  1081. if (typeof envelope.exit_code === 'number'
  1082. && envelope.exit_code !== 0) return false;
  1083. if (strText(envelope.error).length > 0
  1084. || strText(envelope.note).length > 0) return false;
  1085. if (envelope.timeout) return false;
  1086. if (strText(envelope.stdout).length > 0
  1087. || strText(envelope.stderr).length > 0) return false;
  1088. var extras = resultExtras(envelope);
  1089. var keys = Object.keys(extras);
  1090. for (var i = 0; i < keys.length; i++) {
  1091. var value = extras[keys[i]];
  1092. if (value === true || value === false) continue;
  1093. if (typeof value === 'string' && value.length === 0) continue;
  1094. // A row pointer (comment_id, task_id...): the
  1095. // content it names renders elsewhere.
  1096. if (typeof value === 'number'
  1097. && keys[i].indexOf('_id') === keys[i].length - 3) continue;
  1098. return false;
  1099. }
  1100. return true;
  1101. }
  1102. // One call per turn: the first tool_result after a call
  1103. // is its own (or the answer never came yet). The live
  1104. // wire's result payloads carry no turn of their own -
  1105. // matching rides on order, tool name and session, with
  1106. // a turn mismatch (both present) the only rejection.
  1107. function sameSession(a, b) {
  1108. return a === b || a == null || b == null;
  1109. }
  1110. function matchResultIndex(rows, index) {
  1111. var evt = rows[index];
  1112. var p = evt.payload || {};
  1113. if (typeof p.turn !== 'number') return -1;
  1114. for (var j = index + 1; j < rows.length; j++) {
  1115. var other = rows[j];
  1116. if (other.kind === 'tool_result') {
  1117. var q = other.payload || {};
  1118. if ((q.turn === p.turn || q.turn == null)
  1119. && sameSession(other.session_id, evt.session_id)
  1120. && strText(q.tool) === strText(p.tool)) {
  1121. return j;
  1122. }
  1123. return -1;
  1124. }
  1125. if (other.kind === 'tool_call') return -1;
  1126. }
  1127. return -1;
  1128. }
  1129. function foldedQuietCalls(rows) {
  1130. var folded = {};
  1131. rows.forEach(function (evt, index) {
  1132. if (evt.kind !== 'tool_call') return;
  1133. var p = evt.payload || {};
  1134. if (typeof p.turn !== 'number') return;
  1135. var companion = FOLDABLE_TOOLS[strText(p.tool)];
  1136. if (!companion) return;
  1137. var resultIndex = matchResultIndex(rows, index);
  1138. if (resultIndex < 0) return;
  1139. var hasCompanion = false;
  1140. for (var k = index + 1; k < resultIndex; k++) {
  1141. if (rows[k].kind === companion
  1142. && sameSession(rows[k].session_id, evt.session_id)) {
  1143. hasCompanion = true;
  1144. break;
  1145. }
  1146. }
  1147. if (hasCompanion && quietResult(rows[resultIndex])) {
  1148. folded[index] = true;
  1149. folded[resultIndex] = true;
  1150. }
  1151. });
  1152. return folded;
  1153. }
  1154. // Rebuild the feed from the loaded window. `mode`
  1155. // is 'append' (live SSE rows landed at the tail -
  1156. // stick to the bottom only if the reader was
  1157. // there), 'prepend' (older history was added
  1158. // above - keep the viewport on the same rows), or
  1159. // undefined (first render, filter or search change
  1160. // - bottom is the reading position, so seed there;
  1161. // afterwards behave like 'append'). Every branch
  1162. // captures the scroll state before the rebuild and
  1163. // only moves the viewport per its rule - a reader
  1164. // scrolled up into the history is never yanked.
  1165. function render(mode) {
  1166. if (!feed) return;
  1167. var wasAtBottom = atBottom();
  1168. var heightBefore = feed.scrollHeight;
  1169. var scrollTopBefore = feed.scrollTop;
  1170. while (feed.firstChild) feed.removeChild(feed.firstChild);
  1171. var visibleRows = state.events.filter(visible);
  1172. if (visibleRows.length === 0) {
  1173. var empty = document.createElement('p');
  1174. empty.className = 'text-muted log-empty';
  1175. empty.textContent = state.events.length === 0
  1176. ? 'Waiting for the first events\u2026'
  1177. : 'No events match the filters.';
  1178. feed.appendChild(empty);
  1179. }
  1180. // Quiet-call folding + result attachment: a quiet
  1181. // side-effect pair disappears behind its companion
  1182. // event; every other call CARRIES its result - the
  1183. // call row renders the action, the attached block
  1184. // under it renders the outcome - so the two never
  1185. // read as separate rows.
  1186. var folded = foldedQuietCalls(visibleRows);
  1187. var callsById = {};
  1188. var ridesById = {};
  1189. visibleRows.forEach(function (evt, index) {
  1190. if (folded[index] || evt.kind !== 'tool_call') return;
  1191. var r = matchResultIndex(visibleRows, index);
  1192. if (r >= 0 && !folded[r]) {
  1193. callsById[evt.id] = visibleRows[r];
  1194. ridesById[visibleRows[r].id] = true;
  1195. }
  1196. });
  1197. // Grouping: consecutive turn-family events sharing a
  1198. // session and payload.turn render as one card; every
  1199. // other event renders inline. Folded rows and rows
  1200. // riding with their call drop out of the walk.
  1201. var i = 0;
  1202. while (i < visibleRows.length) {
  1203. var evt = visibleRows[i];
  1204. if (folded[i] || ridesById[evt.id]) { i++; continue; }
  1205. var kind = evt.kind;
  1206. if (kind === 'turn_text' || kind === 'tool_call'
  1207. || kind === 'tool_result') {
  1208. var turn = evt.payload ? evt.payload.turn : undefined;
  1209. var group = [evt];
  1210. var j = i + 1;
  1211. while (j < visibleRows.length) {
  1212. var next = visibleRows[j];
  1213. if (folded[j] || ridesById[next.id]) { j++; continue; }
  1214. var nextTurn = next.payload ? next.payload.turn : undefined;
  1215. if ((next.kind === 'turn_text' || next.kind === 'tool_call'
  1216. || next.kind === 'tool_result')
  1217. && next.session_id === evt.session_id
  1218. && (turn === nextTurn
  1219. || (turn === undefined && nextTurn === undefined))) {
  1220. group.push(next);
  1221. j++;
  1222. } else {
  1223. break;
  1224. }
  1225. }
  1226. feed.appendChild(turnCard(group, callsById));
  1227. i = j;
  1228. } else {
  1229. feed.appendChild(eventNode(evt, callsById));
  1230. i++;
  1231. }
  1232. }
  1233. // The load-older control rides INSIDE the feed as
  1234. // its first row - it scrolls away with the history
  1235. // instead of squatting above the pane - so every
  1236. // rebuild must re-seat it.
  1237. if (moreButton) {
  1238. feed.insertBefore(moreButton, feed.firstChild);
  1239. moreButton.hidden = state.exhausted;
  1240. moreButton.disabled = state.loading;
  1241. moreButton.textContent = state.loading
  1242. ? 'Loading\u2026' : 'Load older events';
  1243. }
  1244. if (!state.seeded && visibleRows.length > 0) {
  1245. // First render carrying rows: newest is the
  1246. // reading position, start pinned to it.
  1247. state.seeded = true;
  1248. scrollToBottom();
  1249. } else if (mode === 'prepend') {
  1250. if (wasAtBottom) {
  1251. scrollToBottom();
  1252. } else {
  1253. // Grow the viewport downward by exactly
  1254. // the height added above it, so the
  1255. // rows under the cursor stay put.
  1256. feed.scrollTop = scrollTopBefore
  1257. + (feed.scrollHeight - heightBefore);
  1258. }
  1259. } else if (wasAtBottom) {
  1260. scrollToBottom();
  1261. } else {
  1262. feed.scrollTop = scrollTopBefore;
  1263. }
  1264. }
  1265. function onStreamMessage(message) {
  1266. // Heartbeat frames carry the literal string
  1267. // "ping" as data; named listeners never fire
  1268. // because the server sends no event: line.
  1269. if (message.data === 'ping') return;
  1270. try {
  1271. ingest(JSON.parse(message.data));
  1272. render('append');
  1273. } catch (error) {
  1274. console.warn('[log] bad event:', error);
  1275. }
  1276. }
  1277. function connect() {
  1278. var log = statum.read('task_log');
  1279. if (!log) return;
  1280. var base = '/tasks/' + log.task_id;
  1281. stream = new EventSource(base + '/events/stream');
  1282. stream.onmessage = onStreamMessage;
  1283. stream.onopen = function () {
  1284. setLive('live');
  1285. };
  1286. stream.onerror = function () {
  1287. setLive('retry');
  1288. // EventSource retries on its own (retry: 3s
  1289. // hint); the reconnect replays from the
  1290. // last seen id and ingest dedupes it.
  1291. };
  1292. if (moreButton) {
  1293. moreButton.addEventListener('click', function () {
  1294. loadMore(base);
  1295. });
  1296. }
  1297. }
  1298. // Page one window of history older than the oldest
  1299. // row we hold. The endpoint serves newest-first
  1300. // within the page; reverse it so ingest prepends in
  1301. // chronological order, and keep the viewport put.
  1302. function loadMore(base) {
  1303. if (state.loading || state.exhausted) return;
  1304. state.loading = true;
  1305. render();
  1306. var url = base + '/events?before=' + (state.oldest || 0)
  1307. + '&limit=' + PAGE_SIZE;
  1308. fetch(url, { credentials: 'same-origin' })
  1309. .then(function (response) { return response.json(); })
  1310. .then(function (page) {
  1311. var rows = page.events || [];
  1312. rows.slice().reverse().forEach(ingest);
  1313. if (!page.more || rows.length === 0) {
  1314. state.exhausted = true;
  1315. }
  1316. state.loading = false;
  1317. render('prepend');
  1318. })
  1319. .catch(function (error) {
  1320. console.warn('[log] paging failed:', error);
  1321. state.loading = false;
  1322. render();
  1323. });
  1324. }
  1325. document.addEventListener('click', function (event) {
  1326. if (!event.target.closest) return;
  1327. var toggle = event.target.closest('[data-log-filter]');
  1328. if (toggle) {
  1329. var key = toggle.getAttribute('data-log-filter');
  1330. state.filters[key] = !state.filters[key];
  1331. toggle.classList.toggle('active', state.filters[key]);
  1332. render();
  1333. }
  1334. });
  1335. document.addEventListener('input', function (event) {
  1336. if (!event.target.closest) return;
  1337. var search = event.target.closest('[data-log-search]');
  1338. if (search) {
  1339. state.search = search.value.trim();
  1340. render();
  1341. }
  1342. });
  1343. statum.afterEvaluate(function () {
  1344. var page = statum.read('task_log');
  1345. if (!page || !page.exists) return;
  1346. feed = document.querySelector('[data-log-feed]');
  1347. moreButton = document.querySelector('[data-log-more]');
  1348. liveBadge = document.querySelector('[data-log-live]');
  1349. liveLabel = document.querySelector('[data-live-label]');
  1350. taskStatus = strText(page.task_status);
  1351. updateBadge();
  1352. var header = document.querySelector('[data-log-title]');
  1353. if (header) {
  1354. header.textContent = '#' + page.task_id + ' \u2014 '
  1355. + page.task_title;
  1356. }
  1357. if (!stream && feed) connect();
  1358. render();
  1359. });
  1360. })();
  1361. }