Browse Source

Task log updates

Billy Barrow 1 month ago
parent
commit
7fda933cef
6 changed files with 1677 additions and 1294 deletions
  1. 7 1
      meson.build
  2. 1 0
      src/main.vala
  3. 116 102
      src/pages/app.css
  4. 1 1166
      src/pages/task_log.html
  5. 1314 0
      src/pages/task_log.js
  6. 238 25
      test/test_task_log_js.py

+ 7 - 1
meson.build

@@ -152,6 +152,12 @@ app_js = custom_target('app-js',
     command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'app.js', '--class-name', 'AppJs',
               '--ns', 'ClankerSaddle', '-c', 'text/javascript', '@INPUT@'],
 )
+task_log_js = custom_target('task-log-js-res',
+    input: 'src/pages/task_log.js',
+    output: 'TaskLogJs.vala',
+    command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'task_log.js', '--class-name', 'TaskLogJs',
+              '--ns', 'ClankerSaddle', '-c', 'text/javascript', '@INPUT@'],
+)
 favicon_png = custom_target('favicon-png',
     input: 'src/clanker-saddle.png',
     output: 'FaviconResource.vala',
@@ -252,7 +258,7 @@ app_generated = [
 ]
 
 clanker_saddle_exe = executable('clanker-saddle',
-    app_sources + app_generated + [app_css, app_js, favicon_png, emblem_png, push_crypto],
+    app_sources + app_generated + [app_css, app_js, task_log_js, favicon_png, emblem_png, push_crypto],
     include_directories: include_directories('src/Push'),
     vala_args: ['--vapidir=' + join_paths(meson.current_source_dir(), 'vapi')],
     dependencies: [

+ 1 - 0
src/main.vala

@@ -196,6 +196,7 @@ int main(string[] args) {
         // spry:resources-begin
         statum.add_resource<ClankerSaddle.AppCss>();
         statum.add_resource<ClankerSaddle.AppJs>();
+        statum.add_resource<ClankerSaddle.TaskLogJs>();
         statum.add_resource<ClankerSaddle.FaviconResource>();
         statum.add_resource<ClankerSaddle.EmblemResource>();
         // spry:resources-end

+ 116 - 102
src/pages/app.css

@@ -952,12 +952,12 @@ dialog#quote-comment-dialog blockquote {
     color: var(--plan);
 }
 
-/* ---- Event log (task_log.html): the live console ----
-   The unified ledger's human view reads as a terminal transcript:
-   one dark shell (the app's .terminal palette) under the light
-   page, turns hanging from a rail with a violet chip, every event
-   kind glyphed and tinted, tool results parsed into prose/lists
-   and collapsed behind coloured summaries. */
+/* ---- Event log (task_log.html): the live console, light ----
+   The unified ledger's human view reads as a calm transcript: one
+   white shell under the page, turns hanging from a rail with a
+   violet chip, every event kind glyphed and quietly tinted, tool
+   results parsed into prose/lists - short ones inline with their
+   metric bits, long ones behind the coloured expander summary. */
 
 .log-page h2 { margin: 0; }
 .log-back { margin: 0 0 var(--grid-1); }
@@ -975,19 +975,19 @@ dialog#quote-comment-dialog blockquote {
 
 .log-live {
     align-items: center;
-    background: #12261e;
-    border: 1px solid #2ea04366;
+    background: #dafbe1;
+    border: 1px solid var(--success);
     border-radius: 999px;
-    color: #56d364;
+    color: var(--success);
     display: inline-flex;
     font-size: 0.8rem;
     gap: 0.4rem;
     padding: 0.2rem 0.7rem;
 }
 .log-live.retry {
-    background: #2d2200;
-    border-color: #e3b34166;
-    color: #e3b341;
+    background: #fff8c5;
+    border-color: var(--warning);
+    color: var(--warning);
 }
 .live-dot {
     animation: live-pulse 1.6s ease-in-out infinite;
@@ -1011,57 +1011,58 @@ dialog#quote-comment-dialog blockquote {
 .log-task-link:hover { background: var(--line-soft); }
 
 .log-shell {
-    background: #0d1117;
-    border: 1px solid #30363d;
+    background: var(--surface);
+    border: 1px solid var(--line);
     border-radius: var(--radius);
-    box-shadow: 0 8px 32px #00000030;
+    box-shadow: 0 4px 16px #00000012;
     overflow: hidden;
 }
 
 .log-toolbar {
     align-items: center;
-    background: #161b22;
-    border-bottom: 1px solid #30363d;
+    background: var(--surface-sunken);
+    border-bottom: 1px solid var(--line-soft);
     display: flex;
     flex-wrap: wrap;
     gap: var(--grid-1);
     padding: var(--grid-1) var(--grid-2);
 }
-.log-toolbar-label { color: #8b949e; font-size: 0.8rem; }
+.log-toolbar-label { color: var(--ink-muted); font-size: 0.8rem; }
 .log-toolbar input[type="search"] {
-    background: #0d1117;
-    border: 1px solid #30363d;
+    background: var(--surface);
+    border: 1px solid var(--line);
     border-radius: 999px;
-    color: #c9d1d9;
+    color: var(--ink);
     flex: 1;
     min-width: 12rem;
     padding: 0.3rem 0.9rem;
 }
-.log-toolbar input[type="search"]::placeholder { color: #8b949e; }
+.log-toolbar input[type="search"]::placeholder { color: var(--ink-muted); }
 
 .log-filter {
     background: transparent;
-    border: 1px solid #30363d;
+    border: 1px solid var(--line);
     border-radius: 999px;
-    color: #8b949e;
+    color: var(--ink-muted);
     font-size: 0.8rem;
     padding: 0.2rem 0.75rem;
 }
-.log-filter:hover { border-color: #8b949e; color: #c9d1d9; }
+.log-filter:hover { border-color: var(--ink-muted); color: var(--ink); }
 .log-filter.active {
-    background: #1f6feb26;
-    border-color: #1f6feb;
-    color: #79c0ff;
+    background: #ddf4ff;
+    border-color: #0969da;
+    color: #0969da;
 }
 
 .log-more {
     background: transparent;
-    border: none;
-    color: #79c0ff;
+    border: 1px dashed var(--line);
+    color: var(--accent);
     display: block;
-    margin: 0 auto;
+    margin: 0 auto var(--grid-2);
+    padding: 0.25rem 0.9rem;
 }
-.log-more:hover { background: transparent; text-decoration: underline; }
+.log-more:hover { background: var(--surface-sunken); text-decoration: none; }
 
 .log-feed {
     display: flex;
@@ -1070,27 +1071,25 @@ dialog#quote-comment-dialog blockquote {
     max-height: 75vh;
     overflow-y: auto;
     padding: var(--grid-2);
-    scrollbar-color: #30363d #0d1117;
+    scrollbar-color: var(--line) transparent;
     scrollbar-width: thin;
 }
 .log-feed::-webkit-scrollbar { width: 10px; }
 .log-feed::-webkit-scrollbar-thumb {
-    background: #30363d;
+    background: var(--line);
     border-radius: 5px;
 }
 .log-feed::-webkit-scrollbar-track { background: transparent; }
 
 .log-empty {
-    color: #8b949e;
+    color: var(--ink-muted);
     padding: var(--grid-4) 0;
     text-align: center;
 }
 
-.log-feed .muted, .log-feed .muted a { color: #8b949e; }
-
 /* Turns hang from a rail; their events sit under it. */
 .turn-card {
-    border-left: 2px solid #30363d;
+    border-left: 2px solid var(--line);
     display: flex;
     flex-direction: column;
     gap: 0.4rem;
@@ -1099,7 +1098,7 @@ dialog#quote-comment-dialog blockquote {
 }
 .turn-head {
     align-items: center;
-    color: #8b949e;
+    color: var(--ink-muted);
     display: flex;
     font-size: 0.7rem;
     gap: 0.5rem;
@@ -1108,10 +1107,10 @@ dialog#quote-comment-dialog blockquote {
     text-transform: uppercase;
 }
 .turn-chip {
-    background: #bc8cff1f;
-    border: 1px solid #bc8cff66;
+    background: #f3e8ff;
+    border: 1px solid #8250df55;
     border-radius: 999px;
-    color: #d2b8ff;
+    color: var(--plan);
     padding: 0.05rem 0.6rem;
 }
 
@@ -1121,9 +1120,9 @@ dialog#quote-comment-dialog blockquote {
     padding: 0.1rem 0.4rem 0.1rem 1.8rem;
     position: relative;
 }
-.event-row:hover { background: #161b22; }
+.event-row:hover { background: var(--surface-sunken); }
 .event-row::before {
-    color: #8b949e;
+    color: var(--ink-muted);
     content: "\00b7";
     display: inline-block;
     font-family: ui-monospace, "Cascadia Mono", monospace;
@@ -1134,20 +1133,20 @@ dialog#quote-comment-dialog blockquote {
     top: 0.2rem;
     width: 1rem;
 }
-.event-row.event-kind-tool_call::before { color: #79c0ff; content: "\25b8"; }
+.event-row.event-kind-tool_call::before { color: var(--accent); content: "\25b8"; }
 .event-row.event-kind-tool_result::before { content: "\21b3"; }
-.event-row.event-kind-turn_text::before { color: #bc8cff; content: "\2726"; }
-.event-row.event-kind-command_output::before { color: #79c0ff; content: "\203a"; }
-.event-row.event-kind-progress::before { color: #56d364; content: "\25c6"; }
-.event-row.event-kind-comment::before { color: #e3b341; content: "\201c"; }
-.event-row.event-kind-answer::before { color: #56d364; content: "\2713"; }
-.event-row.event-kind-verdict::before { color: #e3b341; content: "\2261"; }
+.event-row.event-kind-turn_text::before { color: var(--plan); content: "\2726"; }
+.event-row.event-kind-command_output::before { color: var(--accent); content: "\203a"; }
+.event-row.event-kind-progress::before { color: var(--success); content: "\25c6"; }
+.event-row.event-kind-comment::before { color: var(--warning); content: "\201c"; }
+.event-row.event-kind-answer::before { color: var(--success); content: "\2713"; }
+.event-row.event-kind-verdict::before { color: var(--warning); content: "\2261"; }
 .event-row.event-kind-usage::before { content: "\2211"; }
-.event-row.event-kind-state_change::before { color: #79c0ff; content: "\2192"; }
-.event-row.event-kind-lifecycle::before { color: #bc8cff; content: "\25cf"; }
-.event-row.event-kind-force_wake::before { color: #e3b341; content: "\21d1"; }
-.event-row.event-kind-cancel::before { color: #ff7b72; content: "\00d7"; }
-.event-row.event-kind-yeehaw::before { color: #f8e8b8; content: "\2605"; }
+.event-row.event-kind-state_change::before { color: var(--accent); content: "\2192"; }
+.event-row.event-kind-lifecycle::before { color: var(--plan); content: "\25cf"; }
+.event-row.event-kind-force_wake::before { color: var(--warning); content: "\21d1"; }
+.event-row.event-kind-cancel::before { color: var(--danger); content: "\00d7"; }
+.event-row.event-kind-yeehaw::before { color: #7d5b00; content: "\2605"; }
 
 .event-row .event-head {
     align-items: baseline;
@@ -1163,28 +1162,28 @@ dialog#quote-comment-dialog blockquote {
     letter-spacing: 0.03em;
     padding: 0.05rem 0.55rem;
 }
-.event-kind-turn_text { background: #bc8cff1f; color: #d2b8ff; }
-.event-kind-tool_call { background: #1f6feb26; color: #79c0ff; }
-.event-kind-tool_result { background: #30363d55; color: #8b949e; }
-.event-kind-command_output { background: #1f6feb26; color: #79c0ff; }
-.event-kind-progress { background: #2ea04326; color: #56d364; }
-.event-kind-comment { background: #e3b3411f; color: #e3b341; }
-.event-kind-answer { background: #2ea04326; color: #56d364; }
-.event-kind-verdict { background: #e3b3411f; color: #e3b341; }
-.event-kind-state_change { background: #30363d55; color: #79c0ff; }
-.event-kind-lifecycle { background: #bc8cff1f; color: #d2b8ff; }
-.event-kind-usage { background: #30363d55; color: #8b949e; }
-.event-kind-force_wake { background: #e3b3411f; color: #e3b341; }
-.event-kind-cancel { background: #f8514926; color: #ff7b72; }
-.event-kind-yeehaw { background: #f8e8b826; color: #f8e8b8; }
+.event-kind-turn_text { background: #f3e8ff; color: var(--plan); }
+.event-kind-tool_call { background: #ddf4ff; color: #0969da; }
+.event-kind-tool_result { background: var(--line-soft); color: var(--ink-muted); }
+.event-kind-command_output { background: #ddf4ff; color: #0969da; }
+.event-kind-progress { background: #dafbe1; color: var(--success); }
+.event-kind-comment { background: #fff8c5; color: var(--warning); }
+.event-kind-answer { background: #dafbe1; color: var(--success); }
+.event-kind-verdict { background: #fff8c5; color: var(--warning); }
+.event-kind-state_change { background: #ddf4ff; color: #0969da; }
+.event-kind-lifecycle { background: #f3e8ff; color: var(--plan); }
+.event-kind-usage { background: var(--line-soft); color: var(--ink-muted); }
+.event-kind-force_wake { background: #fff8c5; color: var(--warning); }
+.event-kind-cancel { background: #ffebe9; color: var(--danger); }
+.event-kind-yeehaw { background: #f8e8b8; color: #7d5b00; }
 
 .log-feed p { margin: 0.15rem 0; }
 
 .event-pre {
-    background: #161b22;
-    border: 1px solid #21262d;
+    background: var(--surface-sunken);
+    border: 1px solid var(--line-soft);
     border-radius: 4px;
-    color: #c9d1d9;
+    color: var(--ink);
     font-size: 0.8rem;
     margin: 0.2rem 0;
     overflow-x: auto;
@@ -1193,70 +1192,85 @@ dialog#quote-comment-dialog blockquote {
     word-break: break-word;
 }
 .event-pre.event-command {
-    background: #79c0ff0d;
-    border-color: #79c0ff33;
-    color: #79c0ff;
+    background: #ddf4ff66;
+    border-color: #0969da33;
+    color: var(--accent);
 }
 .event-pre.event-stderr {
-    background: #ff7b720d;
-    border-color: #ff7b7233;
-    color: #ff7b72;
+    background: #ffebe966;
+    border-color: #cf222e33;
+    color: var(--danger);
 }
 
 .event-turn {
-    background: #bc8cff0d;
-    border-color: #bc8cff4d;
-    border-left: 3px solid #bc8cff;
-    color: #d2b8ff;
+    background: transparent;
+    border-color: #8250df44;
+    border-left: 3px solid var(--plan);
+    color: var(--ink-muted);
 }
 
-.event-question { color: #e3b341; font-style: italic; }
+.event-question { color: var(--warning); font-style: italic; }
 .event-usage {
-    color: #8b949e;
+    color: var(--ink-muted);
     font-family: ui-monospace, "Cascadia Mono", monospace;
     font-size: 0.8rem;
 }
 
 .event-fold { margin: 0.2rem 0; }
 .event-fold > summary {
-    color: #8b949e;
+    color: var(--ink-muted);
     cursor: pointer;
     font-size: 0.8rem;
 }
-.event-fold > summary:hover { color: #c9d1d9; }
+.event-fold > summary:hover { color: var(--ink); }
 .event-fold[open] > summary { margin-bottom: 0.2rem; }
 
-/* Tool results: coloured summary line, parsed bodies. */
+/* Tool results: short ones render inline under a quiet metric
+   line; long ones collapse behind the coloured summary. */
 .event-result > summary {
-    color: #56d364;
+    color: var(--success);
     cursor: pointer;
     font-family: ui-monospace, "Cascadia Mono", monospace;
     font-size: 0.8rem;
 }
-.event-result > summary.failed, .event-result > summary.failed ~ * .result-ok { color: #ff7b72; }
-.event-result > summary::marker { color: #8b949e; }
+.event-result > summary.failed { color: var(--danger); }
+.event-result > summary::marker { color: var(--ink-muted); }
+/* Tool results ride attached to their call: the block reads as
+   the output of the action above it. */
+.event-attached {
+    border-left: 2px solid var(--line-soft);
+    margin: 0.3rem 0 0.1rem 0.4rem;
+    padding-left: 0.75rem;
+}
+.event-attached > .result-meta { margin: 0; }
+.event-attached .event-result { margin: 0.1rem 0; }
+
 .event-result-body { margin-top: 0.3rem; }
-.result-ok { color: #56d364; font-family: ui-monospace, "Cascadia Mono", monospace; font-size: 0.85rem; }
+.result-ok {
+    color: var(--success);
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.85rem;
+}
 .result-meta {
-    color: #8b949e;
+    color: var(--ink-muted);
     font-family: ui-monospace, "Cascadia Mono", monospace;
-    font-size: 0.8rem;
+    font-size: 0.75rem;
 }
 .result-tasks { margin: 0.2rem 0; padding-left: 1.2rem; }
 .result-tasks li { margin: 0.15rem 0; }
 .result-tasks .badge { margin-left: 0.5rem; }
 
 .log-feed blockquote {
-    border-left: 3px solid #30363d;
-    color: #c9d1d9;
+    border-left: 3px solid var(--line);
+    color: var(--ink);
     margin: 0.2rem 0;
     padding: 0 0.75rem;
 }
-.log-feed .yeehaw-message { color: #f8e8b8; }
+.log-feed .yeehaw-message { color: #7d5b00; }
 
 .event-diff {
-    background: #0d1117;
-    border: 1px solid #21262d;
+    background: var(--surface);
+    border: 1px solid var(--line-soft);
     border-radius: 6px;
     font-family: ui-monospace, "Cascadia Mono", monospace;
     font-size: 0.8rem;
@@ -1270,9 +1284,9 @@ dialog#quote-comment-dialog blockquote {
     white-space: pre-wrap;
     word-break: break-word;
 }
-.diff-del { background: #3d1418; color: #ffa7a4; }
-.diff-add { background: #12261e; color: #7ee2a8; }
-.diff-ctx { color: #8b949e; }
+.diff-del { background: #ffebe9; color: #a40e26; }
+.diff-add { background: #dafbe1; color: #116329; }
+.diff-ctx { color: var(--ink-muted); }
 
 @media (max-width: 40rem) {
     .log-header { align-items: flex-start; flex-direction: column; }

File diff suppressed because it is too large
+ 1 - 1166
src/pages/task_log.html


+ 1314 - 0
src/pages/task_log.js

@@ -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();
+            });
+        })();
+    }

+ 238 - 25
test/test_task_log_js.py

@@ -2,17 +2,19 @@
 """Static + logic tests for the task event-log page's client JS.
 
 The app binary cannot be built in every container (statum-mkpstm /
-libstatum are build-time dependencies), so this suite verifies
-src/pages/task_log.html two ways:
+libstatum are build-time dependencies), so this suite verifies the
+page (src/pages/task_log.html) and its client JS resource
+(src/pages/task_log.js, loaded via pstm-res-src) two ways:
 
-1. Statically - the <script> body must be ASCII-only (the page
-   compiler HTML-escapes script bodies, so any non-ASCII literal is
+1. Statically - the script must be ASCII-only (the page compiler
+   HTML-escapes inline script bodies, so any non-ASCII literal is
    re-served as literal entity text like "&middot;" after the
-   browser's one decode pass), must wire the SSE stream through
-   `onmessage` (the server sends id:/data: frames with no `event:`
-   name, so named listeners can never fire), must not listen for an
-   'append' event name, and its delimiter structure must balance
-   after strings and comments are stripped.
+   browser's one decode pass; the resource keeps the discipline),
+   must wire the SSE stream through `onmessage` (the server sends
+   id:/data: frames with no `event:` name, so named listeners can
+   never fire), must not listen for an 'append' event name, and its
+   delimiter structure must balance after strings and comments are
+   stripped.
 
 2. Behaviourally - the ingest / render-order / scroll-stick /
    load-more logic is ported verbatim below as pure python functions
@@ -30,15 +32,15 @@ import unittest
 
 PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                     os.pardir, "src", "pages", "task_log.html")
+SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                      os.pardir, "src", "pages", "task_log.js")
 
 
 def script_body():
-    with open(PAGE, encoding="utf-8") as handle:
-        source = handle.read()
-    head, rest = source.split("<script>", 1)
-    body, _ = rest.split("</script>", 1)
-    assert head, "no content before the script block"
-    return body
+    # The client JS lives in its own resource file; the page markup
+    # carries no inline script.
+    with open(SCRIPT, encoding="utf-8") as handle:
+        return handle.read()
 
 
 # ---------------------------------------------------------------------------
@@ -60,6 +62,13 @@ class ScriptSourceTests(unittest.TestCase):
                      for index, char in enumerate(self.body) if ord(char) > 127]
         self.assertEqual(offenders, [])
 
+    def test_page_loads_the_resource_and_carries_no_inline_script(self):
+        with open(PAGE, encoding="utf-8") as handle:
+            markup = handle.read()
+        self.assertNotIn("<script>", markup,
+                         "the client JS lives in task_log.js, not inline")
+        self.assertIn('pstm-res-src="task_log.js"', markup)
+
     def test_unicode_escapes_used_for_separators(self):
         """The separator characters must still render - as escapes,
         not as raw bytes and not as HTML entities."""
@@ -85,15 +94,21 @@ class ScriptSourceTests(unittest.TestCase):
     def test_exhausted_set_when_no_more(self):
         self.assertIn("state.exhausted = true", self.body)
 
-    def test_more_button_sits_above_the_feed(self):
-        # Search the page markup only - the script's comments also
-        # mention [data-log-feed], before the elements do.
+    def test_more_button_rides_inside_the_feed(self):
+        # The load-older control is the feed's FIRST row - it
+        # scrolls away with the history instead of sitting above
+        # the pane. Search the page markup only (the script's
+        # comments also mention [data-log-feed]).
         with open(PAGE, encoding="utf-8") as handle:
             markup = handle.read().split("</script>", 1)[1]
-        more = markup.index("data-log-more")
         feed = markup.index("data-log-feed")
-        self.assertLess(more, feed,
-                        "the load-older control must precede the feed markup")
+        more = markup.index("data-log-more", feed)
+        close = markup.index("</div>", feed)
+        self.assertLess(feed, more)
+        self.assertLess(more, close,
+                        "the load-older control must live inside the feed container")
+        self.assertLess(more, markup.index("<p", feed),
+                        "the load-older control must be the feed's first row")
 
     def test_delimiters_balance(self):
         """A cheap structural stand-in for a JS parser (none is
@@ -512,8 +527,9 @@ def result_extras(envelope):
                 and key not in ("error", "note", "timeout"))
 
 
-def result_summary(evt):
-    """PORT of resultSummary -> the summary line's text."""
+def result_bits(evt):
+    """PORT of resultBits -> the metric bits shared by the expander
+    summary and the inline line."""
     envelope = result_envelope(evt)
     payload = evt.get("payload") or {}
     bits = []
@@ -535,11 +551,31 @@ def result_summary(evt):
         bits.append(fmt_duration(envelope["duration_ms"]))
     if payload.get("turn") is not None:
         bits.append("turn %s" % payload["turn"])
-    # PORT: a non-JSON result string carries no envelope; its own
-    # size is the only metric we have.
     if not bits and isinstance(payload.get("result"), str) \
             and not is_plain_object(parse_maybe_json(payload["result"])):
         bits.append(fmt_bytes(len(payload["result"])))
+    return bits
+
+
+INLINE_RESULT_LIMIT = 220
+
+
+def result_view(evt):
+    """PORT of resultView -> (inline, bits_text, body_text): a
+    short body renders inline under its metric bits; a long one
+    collapses behind the expander summary."""
+    out, err, note, extras, no_output = result_output(evt)
+    parts = [part for part in (out, err, note, extras) if part]
+    if no_output:
+        parts = ["(no output)"]
+    body_text = "\n".join(parts).strip()
+    return len(body_text) <= INLINE_RESULT_LIMIT, \
+        " \u00b7 ".join(result_bits(evt)), body_text
+
+
+def result_summary(evt):
+    """PORT of resultSummary -> the summary line's text."""
+    bits = result_bits(evt)
     return "result" + (" (" + ", ".join(bits) + ")" if bits else "")
 
 
@@ -727,6 +763,101 @@ def state_change_line(payload):
     return str_text(payload.get("text")) or json.dumps(payload)
 
 
+# --- Quiet-call folding + result attachment (the live-tail polish):
+# a successful side-effect tool whose content already renders as its
+# own event folds its call/ack pair away; every other call carries
+# its result as an attached block instead of a separate row. ---
+
+FOLDABLE_TOOLS = {"update_progress": "progress", "add_comment": "comment",
+                  "yeehaw": "yeehaw"}
+
+
+def quiet_result(evt):
+    """PORT of quietResult: an ack-shaped success with nothing to
+    say - ok, no error/note/timeout, no output, extras only true/
+    false/empty-string."""
+    envelope = result_envelope(evt)
+    if envelope.get("ok") is False:
+        return False
+    if isinstance(envelope.get("exit_code"), int) and envelope["exit_code"] != 0:
+        return False
+    if str_text(envelope.get("error")) or str_text(envelope.get("note")):
+        return False
+    if envelope.get("timeout"):
+        return False
+    if str_text(envelope.get("stdout")) or str_text(envelope.get("stderr")):
+        return False
+    for value in result_extras(envelope).values():
+        if value is True or value is False:
+            continue
+        if isinstance(value, str) and value == "":
+            continue
+        return False
+    return True
+
+
+def match_result_index(rows, index):
+    """PORT of matchResultIndex: the first tool_result after the
+    call when it matches turn/session/tool; -1 otherwise."""
+    evt = rows[index]
+    payload = evt.get("payload") or {}
+    if not isinstance(payload.get("turn"), int):
+        return -1
+    for j in range(index + 1, len(rows)):
+        other = rows[j]
+        if other.get("kind") == "tool_result":
+            other_payload = other.get("payload") or {}
+            if other_payload.get("turn") == payload.get("turn") \
+                    and other.get("session_id") == evt.get("session_id") \
+                    and str_text(other_payload.get("tool")) == str_text(payload.get("tool")):
+                return j
+            return -1
+        if other.get("kind") == "tool_call":
+            return -1
+    return -1
+
+
+def folded_quiet_calls(rows):
+    """PORT of foldedQuietCalls -> the folded row indices."""
+    folded = set()
+    for index, evt in enumerate(rows):
+        if evt.get("kind") != "tool_call":
+            continue
+        payload = evt.get("payload") or {}
+        if not isinstance(payload.get("turn"), int):
+            continue
+        companion = FOLDABLE_TOOLS.get(str_text(payload.get("tool")))
+        if not companion:
+            continue
+        result_index = match_result_index(rows, index)
+        if result_index < 0:
+            continue
+        has_companion = any(
+            rows[k].get("kind") == companion
+            and rows[k].get("session_id") == evt.get("session_id")
+            for k in range(index + 1, result_index))
+        if has_companion and quiet_result(rows[result_index]):
+            folded.add(index)
+            folded.add(result_index)
+    return folded
+
+
+def attach_pairs(rows, folded):
+    """PORT of render's attachment walk -> (calls_by_id,
+    rides_by_id): every non-folded call carries its matching
+    result; the result then never renders as its own row."""
+    calls_by_id = {}
+    rides_by_id = {}
+    for index, evt in enumerate(rows):
+        if index in folded or evt.get("kind") != "tool_call":
+            continue
+        result_index = match_result_index(rows, index)
+        if result_index >= 0 and result_index not in folded:
+            calls_by_id[evt["id"]] = rows[result_index]["id"]
+            rides_by_id[rows[result_index]["id"]] = True
+    return calls_by_id, rides_by_id
+
+
 DIFF_CONTEXT = 2
 DIFF_MAX_LINES = 400
 
@@ -1101,6 +1232,88 @@ class StateChangeLineTests(unittest.TestCase):
         self.assertEqual(state_change_line({}), "{}")
 
 
+QUIET_ACK = json.dumps({"ok": True, "recorded": True})
+FAILED_ACK = json.dumps({"ok": False, "error": "web callback unreachable"})
+
+
+def progress_trio(result_inner=QUIET_ACK, with_companion=True):
+    """The live-tail's repetitive trio: an update_progress call,
+    the progress event carrying the same text, then the ack
+    result."""
+    rows = [{"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
+        "turn": 1551, "tool": "update_progress",
+        "args": json.dumps({"text": "Segfault located"})}}]
+    if with_companion:
+        rows.append({"id": 2, "kind": "progress", "session_id": 201,
+                     "payload": {"text": "Segfault located"}})
+    rows.append({"id": 3, "kind": "tool_result", "session_id": 201, "payload": {
+        "turn": 1551, "tool": "update_progress", "result": result_inner}})
+    return rows
+
+
+class QuietFoldingTests(unittest.TestCase):
+    """A successful update_progress renders as its progress event
+    ALONE; the duplicate call and the bare ack fold away. A failure
+    unfolds both."""
+
+    def test_quiet_trio_folds_call_and_result(self):
+        folded = folded_quiet_calls(progress_trio())
+        self.assertEqual(folded, {0, 2})
+
+    def test_failed_ack_unfolds_the_pair(self):
+        self.assertEqual(folded_quiet_calls(progress_trio(FAILED_ACK)), set())
+
+    def test_missing_companion_unfolds(self):
+        """Without the progress event between them there would be
+        nothing to show instead - keep the pair."""
+        self.assertEqual(folded_quiet_calls(progress_trio(with_companion=False)),
+                         set())
+
+    def test_missing_result_unfolds(self):
+        rows = progress_trio()[:2]
+        self.assertEqual(folded_quiet_calls(rows), set())
+
+    def test_non_foldable_tool_never_folds(self):
+        rows = [
+            {"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
+                "turn": 5, "tool": "run_command",
+                "args": json.dumps({"command": "ls"})}},
+            {"id": 2, "kind": "tool_result", "session_id": 201, "payload": {
+                "turn": 5, "tool": "run_command", "result": QUIET_ACK}},
+        ]
+        self.assertEqual(folded_quiet_calls(rows), set())
+
+
+class ResultAttachmentTests(unittest.TestCase):
+    """A non-folded call carries its result as an attached block;
+    the result never renders as a separate row."""
+
+    def test_call_and_result_pair_up(self):
+        rows = [
+            {"id": 10, "kind": "tool_call", "session_id": 7, "payload": {
+                "turn": 5, "tool": "run_command",
+                "args": json.dumps({"command": "make"})}},
+            {"id": 11, "kind": "tool_result", "session_id": 7, "payload": {
+                "turn": 5, "tool": "run_command",
+                "result": json.dumps({"ok": True, "exit_code": 0,
+                                      "stdout": "built\n", "stderr": "",
+                                      "duration_ms": 900})}},
+        ]
+        calls_by_id, rides_by_id = attach_pairs(rows, set())
+        self.assertEqual(calls_by_id, {10: 11})
+        self.assertEqual(rides_by_id, {11: True})
+
+    def test_folded_pair_attaches_nothing(self):
+        calls_by_id, rides_by_id = attach_pairs(
+            progress_trio(), folded_quiet_calls(progress_trio()))
+        self.assertEqual((calls_by_id, rides_by_id), ({}, {}))
+
+    def test_result_without_visible_call_stands_alone(self):
+        rows = progress_trio()[2:]  # only the result is visible
+        calls_by_id, rides_by_id = attach_pairs(rows, set())
+        self.assertEqual((calls_by_id, rides_by_id), ({}, {}))
+
+
 class ToolResultRenderTests(unittest.TestCase):
 
     def evt(self, payload):

Some files were not shown because too many files changed in this diff