Ver Fonte

Improvements to UI

Billy Barrow há 1 mês atrás
pai
commit
215e8281e3

+ 43 - 2
src/Api/SaddleApi.vala

@@ -821,6 +821,7 @@ namespace ClankerSaddle.Api {
     public class SaddleChunksEndpoint : Object, Endpoint {
 
         private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private EventService events = inject<EventService>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
@@ -856,16 +857,56 @@ namespace ClankerSaddle.Api {
 
             poll_state.note_push(session_id);
             try {
-                int64 stored = yield events.store_pushed_events(session_id,
+                var fresh = yield events.store_pushed_events(session_id,
                     ((!)session).task_id, body.get_array_member("events"));
+                yield adopt_agent_comments((!)session, fresh);
                 var response = new Json.Object();
-                response.set_int_member("stored", stored);
+                response.set_int_member("stored", (int64) fresh.count());
                 return SaddleWire.json_ok(response);
             } catch (Error e) {
                 return SaddleWire.json_error(400, e.message);
             }
         }
 
+        /**
+         * The plan-17 ledger carries agent remarks as comment events;
+         * the comment SECTIONS (task and goal pages) read the
+         * task_comments table. Every newly stored model-authored
+         * comment event materialises its row here — exactly once,
+         * because only fresh (session, seq) pairs leave the store.
+         */
+        private async void adopt_agent_comments(ClankerSaddle.Data.Session session,
+                Invercargill.DataStructures.Vector<ClankerSaddle.Data.EventRecord> fresh) {
+            foreach (var record in fresh) {
+                if (record.kind != EVENT_COMMENT
+                        || record.actor != EVENT_ACTOR_MODEL) {
+                    continue;
+                }
+                string? body = null;
+                try {
+                    var node = Json.from_string(record.payload);
+                    if (node != null && ((!)node).get_object() != null) {
+                        var payload = ((!)node).get_object();
+                        if (payload.has_member("body")
+                                && payload.get_member("body").get_value_type() == typeof(string)) {
+                            body = payload.get_string_member("body");
+                        }
+                    }
+                } catch (Error parse_error) {
+                    body = null;
+                }
+                if (body == null || ((!)body).strip().length == 0) {
+                    continue;
+                }
+                try {
+                    yield tasks.adopt_agent_comment(session.task_id, (!)body);
+                } catch (Error comment_error) {
+                    printerr("[ClankerSaddle] Agent comment materialisation failed: %s\n",
+                        comment_error.message);
+                }
+            }
+        }
+
         internal static async ClankerSaddle.Data.Session? session_owned_by(
                 InvercargillSql.Orm.OrmSession db, int64 session_id, Saddle saddle) throws Error {
             if (session_id <= 0) {

+ 25 - 7
src/Services/EventService.vala

@@ -101,19 +101,21 @@ namespace ClankerSaddle.Services {
          * an error; an event without a positive seq is refused (the
          * daemon contract makes seq mandatory). The daemon's `ts` is
          * authoritative for `created_at` (fallback: now). Returns the
-         * count of rows newly stored.
+         * records newly stored (a redelivered seq contributes none) —
+         * callers use the fresh set to materialise side effects
+         * exactly once (the agent comment rows).
          *
          * P2 (web-db health plan): the whole batch inserts inside ONE
          * transaction through one prepared INSERT OR IGNORE statement
          * — SQLite consumes a duplicate (session_id, seq) inside the
-         * statement itself (no per-row conflict round trip, no
-         * per-row re-query), and the batch's accumulated changes
-         * count is exactly the newly stored rows (an ignored row
-         * contributes 0). Staging stays as before: validation runs to
+         * statement itself. Newness is settled by a per-row unique-key
+         * probe ahead of the insert (batches are small), so the fresh
+         * set is exact even though INSERT OR IGNORE reports only a
+         * count. Staging stays as before: validation runs to
          * completion first, so one invalid event (bad seq/kind)
          * refuses the whole push with nothing stored.
          */
-        public async int64 store_pushed_events(int64 session_id, int64 task_id,
+        public async Vector<EventRecord> store_pushed_events(int64 session_id, int64 task_id,
                 Json.Array events) throws Error {
             var staged = new Vector<EventRecord>();
             foreach (var element in events.get_elements()) {
@@ -155,6 +157,22 @@ namespace ClankerSaddle.Services {
             foreach (var record in staged) {
                 parameter_sets.add(mapper.map_from(record));
             }
+            // The fresh set: staged records whose (session_id, seq)
+            // unique key is not yet stored. INSERT OR IGNORE only
+            // reports a count, so newness is probed per row ahead of
+            // the insert (a redelivered seq must not re-materialise
+            // anything downstream).
+            var fresh = new Vector<EventRecord>();
+            foreach (var record in staged) {
+                var clash = yield db.query<EventRecord>()
+                    .where(expr("session_id == $0 && seq == $1",
+                        elem<int64?>(record.session_id),
+                        elem<int64?>(record.seq)))
+                    .first_async();
+                if (clash == null) {
+                    fresh.add(record);
+                }
+            }
             var transaction = connection.begin_transaction();
             int64 stored = 0;
             try {
@@ -169,7 +187,7 @@ namespace ClankerSaddle.Services {
                 transaction.rollback();
                 throw batch_error;
             }
-            return stored;
+            return fresh;
         }
 
         // ------------------------------------------------------------------

+ 22 - 0
src/Services/TaskService.vala

@@ -37,6 +37,7 @@ namespace ClankerSaddle.Services {
      * restorations) — never agent content.
      */
     public const string COMMENT_AUTHOR_SYSTEM = "system";
+    public const string COMMENT_AUTHOR_AGENT = "agent";
 
     /**
      * Plan 16 S5 (U2): MECHANICAL harness bookkeeping that every
@@ -802,6 +803,27 @@ namespace ClankerSaddle.Services {
             return yield add_comment(task_id, COMMENT_AUTHOR_SYSTEM, body);
         }
 
+        /**
+         * Materialises an AGENT comment row from its already-stored
+         * ledger event: the plan-17 chunks push carries the remark as
+         * a comment event, and this creates the task_comments row the
+         * page's comment section renders. No second event is appended
+         * (the ledger already carries the remark); only fresh
+         * (session, seq) pairs reach here, so a redelivered push can
+         * never double the row.
+         */
+        public async TaskComment adopt_agent_comment(int64 task_id, string body) throws Error {
+            var comment = new TaskComment() {
+                task_id = task_id,
+                author = COMMENT_AUTHOR_AGENT,
+                body = body,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<TaskComment>(comment);
+            yield pulse.bump(task_id, "agent comment");
+            return comment;
+        }
+
         /**
          * Raises a blocking prompt on a task — `question` (optionally with
          * multiple-choice `options` and a markdown `body`), `rfc` or

+ 6 - 1
src/pages/app.css

@@ -1186,7 +1186,8 @@ dialog#quote-comment-dialog blockquote {
     color: var(--ink);
     font-size: 0.8rem;
     margin: 0.2rem 0;
-    overflow-x: auto;
+    max-height: 20rem;
+    overflow: auto;
     padding: 0.3rem 0.5rem;
     white-space: pre-wrap;
     word-break: break-word;
@@ -1244,6 +1245,10 @@ dialog#quote-comment-dialog blockquote {
 }
 .event-attached > .result-meta { margin: 0; }
 .event-attached .event-result { margin: 0.1rem 0; }
+.event-attached .event-pre {
+    max-height: 16rem;
+    overflow: auto;
+}
 
 .event-result-body { margin-top: 0.3rem; }
 .result-ok {

+ 1 - 1
src/pages/progress_feed.html

@@ -12,7 +12,7 @@
 <body>
     <pstm-input name="entries"/>
     <div stm-if="entries.length > 0">
-        <p class="progress-latest" stm-text="entries[0].summary">latest</p>
+        <div class="progress-latest md" stm-markdown="entries[0].summary">latest</div>
         <p class="muted" stm-text="'reported ' + entries[0].when_display">when</p>
         <details class="history-expander" stm-if="entries.length > 1">
             <summary>Show history (<span stm-text="entries.length">0</span> updates)</summary>

+ 1 - 1
src/pages/task_detail.html

@@ -312,7 +312,7 @@
                 <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
                 <span stm-if="comment.is_system" class="system-label">system</span>
                 <span class="muted" stm-text="comment.when_display">when</span>
-                <div stm-text="comment.body">body</div>
+                <div class="md" stm-markdown="comment.body">body</div>
                 <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
             </div>
             <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>

+ 93 - 19
src/pages/task_log.js

@@ -302,14 +302,17 @@
             // 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.
+            // expander summary. An ATTACHED result (riding with its
+            // call) is always inline - the output must be visible,
+            // not behind a click - with oversized bodies clamped by
+            // CSS instead.
             var INLINE_RESULT_LIMIT = 220;
 
-            function resultView(evt, envelope) {
+            function resultView(evt, envelope, forceInline) {
                 var wrap = document.createDocumentFragment();
                 var node = resultBody(evt, envelope);
                 var text = (node.textContent || '').trim();
-                if (text.length <= INLINE_RESULT_LIMIT) {
+                if (forceInline || text.length <= INLINE_RESULT_LIMIT) {
                     var bits = resultBits(evt, envelope);
                     if (bits.length > 0) {
                         wrap.appendChild(para(bits.join(' \u00b7 '), 'result-meta'));
@@ -426,27 +429,45 @@
                 var frag = document.createDocumentFragment();
                 if (strText(extras.content).length > 0
                         || typeof extras.total_lines === 'number') {
-                    // read_file's answer: a line window of a file.
+                    // read_file's answer: a line window of a file;
+                    // read_binary's: a byte window. The meta line
+                    // names the window; the content folds behind an
+                    // expander - a read is reference material, not
+                    // the action's output.
                     var meta = [];
                     if (strText(extras.path).length > 0) {
                         meta.push(strText(extras.path));
                     }
-                    if (typeof extras.line_start === 'number'
-                            && typeof extras.total_lines === 'number') {
-                        meta.push('lines ' + extras.line_start + '-'
+                    if (typeof extras.total_lines === 'number') {
+                        meta.push('lines ' + (typeof extras.line_start === 'number'
+                                ? extras.line_start : 1) + '-'
                             + (typeof extras.line_end === 'number'
                                 ? extras.line_end : extras.total_lines)
                             + ' of ' + extras.total_lines);
-                    }
-                    if (typeof extras.bytes === 'number') {
-                        meta.push(fmtBytes(extras.bytes));
+                        if (typeof extras.bytes === 'number') {
+                            meta.push(fmtBytes(extras.bytes));
+                        }
+                    } else {
+                        if (strText(extras.representation).length > 0) {
+                            meta.push(strText(extras.representation));
+                        }
+                        if (typeof extras.bytes_read === 'number') {
+                            meta.push(fmtBytes(extras.bytes_read)
+                                + (typeof extras.total_bytes === 'number'
+                                    ? ' of ' + fmtBytes(extras.total_bytes) : ''));
+                        }
+                        if (typeof extras.offset === 'number' && extras.offset > 0) {
+                            meta.push('at ' + fmtBytes(extras.offset));
+                        }
                     }
                     if (extras.truncated) meta.push('truncated');
                     if (meta.length > 0) {
                         frag.appendChild(para(meta.join(' \u00b7 '), 'result-meta'));
                     }
                     if (strText(extras.content).length > 0) {
-                        frag.appendChild(preLine(extras.content));
+                        var shown = strText(extras.content);
+                        frag.appendChild(fold('content \u00b7 ' + fmtBytes(shown.length),
+                            function () { return preLine(shown); }));
                     }
                     return frag;
                 }
@@ -671,6 +692,47 @@
                 return div;
             }
 
+            // ---- the write-family rendering ----
+            //
+            // The file languages worth naming on a head line (an
+            // extension chip); anything else renders no language
+            // bit. The ledger clamps args to 4 KiB, so the content
+            // always rides VISIBLE (never behind a fold) with CSS
+            // clamping the block.
+            var FILE_LANGUAGES = {
+                '.vala': 'Vala', '.vapi': 'Vapi', '.c': 'C', '.h': 'C',
+                '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript',
+                '.html': 'HTML', '.css': 'CSS', '.json': 'JSON',
+                '.md': 'Markdown', '.sh': 'Shell', '.bash': 'Shell',
+                '.xml': 'XML', '.yml': 'YAML', '.yaml': 'YAML',
+                '.sql': 'SQL', '.toml': 'TOML', '.ini': 'INI',
+                '.cfg': 'Config', '.diff': 'Diff', '.patch': 'Diff',
+                '.rst': 'reST', '.txt': 'Text'
+            };
+
+            function languageOf(path) {
+                var lower = path.toLowerCase();
+                var slash = lower.lastIndexOf('/');
+                var name = slash >= 0 ? lower.substring(slash + 1) : lower;
+                if (name === 'meson.build') return 'Meson';
+                if (name === 'makefile') return 'Make';
+                var dot = name.lastIndexOf('.');
+                if (dot < 1 || dot === name.length - 1) return '';
+                return FILE_LANGUAGES[name.substring(dot)] || '';
+            }
+
+            function contentBits(content) {
+                var lines = content.split('\n').length;
+                return [lines + ' line' + (lines === 1 ? '' : 's'),
+                    fmtBytes(content.length)];
+            }
+
+            function contentBlock(content) {
+                var pre = preLine(content);
+                pre.className = 'event-pre';
+                return pre;
+            }
+
             // ---- the bespoke tool bodies ----
             //
             // Each branch answers a DocumentFragment when it can
@@ -715,12 +777,19 @@
                     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);
+                if (tool === 'write_file' || tool === 'overwrite_file' || tool === 'temp_doc') {
+                    var content = strText(args.content);
+                    if (content.length === 0) return null;
+                    if (tool === 'temp_doc') {
+                        frag.appendChild(headLine('temp_doc', contentBits(content)));
+                    } else {
+                        if (path.length === 0) return null;
+                        var bits2 = contentBits(content);
+                        var language = languageOf(path);
+                        if (language.length > 0) bits2.unshift(language);
+                        frag.appendChild(headLine(tool + ' ' + path, bits2));
+                    }
+                    frag.appendChild(contentBlock(content));
                     return frag;
                 }
                 if (tool === 'replace_in_file') {
@@ -880,11 +949,12 @@
                     if (attached) {
                         // The outcome rides with its call: the
                         // attached block reads as the output of the
-                        // action above it, never a separate row.
+                        // action above it, never a separate row - and
+                        // always visible, never behind a click.
                         var mount = document.createElement('div');
                         mount.className = 'event-attached';
                         mount.appendChild(resultView(attached,
-                            resultEnvelope(attached)));
+                            resultEnvelope(attached), true));
                         body.appendChild(mount);
                     }
                 } else if (evt.kind === 'tool_result') {
@@ -1047,6 +1117,10 @@
                     var value = extras[keys[i]];
                     if (value === true || value === false) continue;
                     if (typeof value === 'string' && value.length === 0) continue;
+                    // A row pointer (comment_id, task_id...): the
+                    // content it names renders elsewhere.
+                    if (typeof value === 'number'
+                            && keys[i].indexOf('_id') === keys[i].length - 3) continue;
                     return false;
                 }
                 return true;

+ 131 - 25
test/test_task_log_js.py

@@ -560,17 +560,19 @@ def result_bits(evt):
 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."""
+def result_view(evt, attached=False):
+    """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 - UNLESS it rides attached to its
+    call, where the output is always visible (CSS clamps oversized
+    bodies)."""
     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
+    inline = attached or len(body_text) <= INLINE_RESULT_LIMIT
+    return inline, " \u00b7 ".join(result_bits(evt)), body_text
 
 
 def result_summary(evt):
@@ -637,20 +639,33 @@ def tailored_result_text(tool, extras):
         meta = []
         if str_text(extras.get("path")):
             meta.append(str_text(extras.get("path")))
-        if isinstance(extras.get("line_start"), int) \
-                and isinstance(extras.get("total_lines"), int):
-            end = extras.get("line_end")
-            if not isinstance(end, int):
-                end = extras["total_lines"]
+        if isinstance(extras.get("total_lines"), int):
+            start = extras.get("line_start") \
+                if isinstance(extras.get("line_start"), int) else 1
+            end = extras.get("line_end") \
+                if isinstance(extras.get("line_end"), int) else extras["total_lines"]
             meta.append("lines %d-%d of %d"
-                        % (extras["line_start"], end, extras["total_lines"]))
-        if isinstance(extras.get("bytes"), int):
-            meta.append(fmt_bytes(extras["bytes"]))
+                        % (start, end, extras["total_lines"]))
+            if isinstance(extras.get("bytes"), int):
+                meta.append(fmt_bytes(extras["bytes"]))
+        else:
+            if str_text(extras.get("representation")):
+                meta.append(str_text(extras.get("representation")))
+            if isinstance(extras.get("bytes_read"), int):
+                window = fmt_bytes(extras["bytes_read"])
+                if isinstance(extras.get("total_bytes"), int):
+                    window += " of " + fmt_bytes(extras["total_bytes"])
+                meta.append(window)
+            if isinstance(extras.get("offset"), int) and extras["offset"] > 0:
+                meta.append("at " + fmt_bytes(extras["offset"]))
         if extras.get("truncated"):
             meta.append("truncated")
         parts = [" \u00b7 ".join(meta)]
         if str_text(extras.get("content")):
-            parts.append(str_text(extras["content"]))
+            # The content rides behind an expander: a read is
+            # reference material, not the action's output.
+            parts.append("[fold] content \u00b7 "
+                         + fmt_bytes(len(str_text(extras.get("content")))))
         return "\n".join(parts)
     if tool == "replace_in_file" and isinstance(extras.get("replacements"), int):
         count = extras["replacements"]
@@ -775,7 +790,8 @@ FOLDABLE_TOOLS = {"update_progress": "progress", "add_comment": "comment",
 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."""
+    false/empty-string/row pointers (_id integers whose content
+    renders elsewhere)."""
     envelope = result_envelope(evt)
     if envelope.get("ok") is False:
         return False
@@ -787,11 +803,13 @@ def quiet_result(evt):
         return False
     if str_text(envelope.get("stdout")) or str_text(envelope.get("stderr")):
         return False
-    for value in result_extras(envelope).values():
+    for key, value in result_extras(envelope).items():
         if value is True or value is False:
             continue
         if isinstance(value, str) and value == "":
             continue
+        if isinstance(value, int) and key.endswith("_id"):
+            continue
         return False
     return True
 
@@ -912,6 +930,38 @@ def diff_rows(old_text, new_text):
     return rows
 
 
+FILE_LANGUAGES = {".vala": "Vala", ".vapi": "Vapi", ".c": "C", ".h": "C",
+                  ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
+                  ".html": "HTML", ".css": "CSS", ".json": "JSON",
+                  ".md": "Markdown", ".sh": "Shell", ".bash": "Shell",
+                  ".xml": "XML", ".yml": "YAML", ".yaml": "YAML",
+                  ".sql": "SQL", ".toml": "TOML", ".ini": "INI",
+                  ".cfg": "Config", ".diff": "Diff", ".patch": "Diff",
+                  ".rst": "reST", ".txt": "Text"}
+
+
+def language_of(path):
+    """PORT of languageOf: the extension chip, with meson.build and
+    makefile as whole-name matches; '' when nothing matches."""
+    lower = path.lower()
+    name = lower[lower.rfind("/") + 1:]
+    if name == "meson.build":
+        return "Meson"
+    if name == "makefile":
+        return "Make"
+    dot = name.rfind(".")
+    if dot < 1 or dot == len(name) - 1:
+        return ""
+    return FILE_LANGUAGES.get(name[dot:], "")
+
+
+def content_bits(content):
+    """PORT of contentBits: line count and byte size."""
+    lines = content.count("\n") + 1
+    return ["%d line%s" % (lines, "" if lines == 1 else "s"),
+            fmt_bytes(len(content))]
+
+
 def tailored_blocks(tool, args):
     """PORT of tailoredCallBody -> the bespoke blocks, or None to
     fall back to the generic JSON view (fields missing - e.g. a
@@ -941,12 +991,19 @@ def tailored_blocks(tool, args):
         if isinstance(args.get("limit"), int):
             bits.append("%d lines" % args["limit"])
         return [head("read_file " + path, bits)]
-    if tool in ("write_file", "overwrite_file"):
+    if tool in ("write_file", "overwrite_file", "temp_doc"):
         content = str_text(args.get("content"))
-        if not path or not content:
+        if not content:
+            return None
+        if tool == "temp_doc":
+            return [head("temp_doc", content_bits(content)), ("pre", content)]
+        if not path:
             return None
-        return [head(tool + " " + path, [fmt_bytes(len(content))]),
-                ("fold", "content \u00b7 " + fmt_bytes(len(content)))]
+        more = content_bits(content)
+        language = language_of(path)
+        if language:
+            more.insert(0, language)
+        return [head(tool + " " + path, more), ("pre", content)]
     if tool == "replace_in_file":
         old = str_text(args.get("old"))
         if not path or not old:
@@ -1125,10 +1182,17 @@ class ToolCallRenderTests(unittest.TestCase):
         self.assertFalse([t for t in diff_texts if t.startswith("- ")],
                          "an unchanged line must never render as a removal")
 
-    def test_write_file_folds_its_content(self):
+    def test_write_file_renders_language_lines_and_content(self):
         blocks = tool_call_render(self.evt(FIXTURE_WRITE_CALL))
-        self.assertEqual(blocks[0], ("head", "write_file notes.md  \u00b7 24 B"))
-        self.assertEqual(blocks[1], ("fold", "content \u00b7 24 B"))
+        self.assertEqual(blocks[0],
+                         ("head", "write_file notes.md  \u00b7 Markdown \u00b7 3 lines \u00b7 24 B"))
+        self.assertEqual(blocks[1], ("pre", "# Notes\n- first\n- second"))
+
+    def test_meson_build_gets_its_language(self):
+        self.assertEqual(language_of("sub/meson.build"), "Meson")
+        self.assertEqual(language_of("src/app.vala"), "Vala")
+        self.assertEqual(language_of("Makefile".lower()), "Make")
+        self.assertEqual(language_of("data/bin"), "")
 
     def test_truncated_args_fall_back_to_the_raw_view(self):
         blocks = tool_call_render(self.evt(FIXTURE_TRUNCATED_ARGS))
@@ -1286,6 +1350,21 @@ class QuietFoldingTests(unittest.TestCase):
         self.assertEqual(
             folded_quiet_calls(progress_trio(result_turn=9999)), set())
 
+    def test_add_comment_folds_behind_its_comment_event(self):
+        """The add_comment result carries a comment_id row pointer -
+        quiet - and the comment event between carries the body."""
+        rows = [
+            {"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
+                "turn": 60, "tool": "add_comment",
+                "args": json.dumps({"body": "Merged and pushed."})}},
+            {"id": 2, "kind": "comment", "session_id": 201, "actor": "model",
+             "payload": {"body": "Merged and pushed."}},
+            {"id": 3, "kind": "tool_result", "session_id": 201, "payload": {
+                "tool": "add_comment",
+                "result": json.dumps({"ok": True, "comment_id": 234})}},
+        ]
+        self.assertEqual(folded_quiet_calls(rows), {0, 2})
+
     def test_failed_ack_unfolds_the_pair(self):
         self.assertEqual(folded_quiet_calls(progress_trio(FAILED_ACK)), set())
 
@@ -1346,6 +1425,22 @@ class ResultAttachmentTests(unittest.TestCase):
         self.assertEqual(calls_by_id, {20: 21})
         self.assertEqual(rides_by_id, {21: True})
 
+    def test_long_command_output_is_visible_when_attached(self):
+        """A command's output must be VISIBLE riding with its call -
+        never behind a click - even past the inline limit; CSS
+        clamps the overflow."""
+        big_output = "line %d\n" * 60 % tuple(range(60))
+        evt = {"kind": "tool_result", "payload": {
+            "tool": "run_command",
+            "result": json.dumps({"ok": True, "exit_code": 0,
+                                  "stdout": big_output, "stderr": "",
+                                  "duration_ms": 5000})}}
+        standalone, _, body = result_view(evt)
+        attached, _, attached_body = result_view(evt, attached=True)
+        self.assertFalse(standalone, "long standalone results collapse")
+        self.assertTrue(attached, "attached results are always inline")
+        self.assertEqual(attached_body, body)
+
     def test_folded_pair_attaches_nothing(self):
         calls_by_id, rides_by_id = attach_pairs(
             progress_trio(), folded_quiet_calls(progress_trio()))
@@ -1450,7 +1545,18 @@ class ToolResultRenderTests(unittest.TestCase):
         out, err, note, extras, no_output = result_output(self.evt(
             {"tool": "read_file", "result": inner}))
         self.assertIn("src/main.vala \u00b7 lines 100-160 of 540 \u00b7 4.1 KiB", extras)
-        self.assertIn("int main() {", extras)
+        self.assertIn("[fold] content \u00b7 12 B", extras)
+
+    def test_read_binary_result_renders_the_window(self):
+        inner = json.dumps({"ok": True, "path": "model.bin",
+                            "representation": "hex", "total_bytes": 1024,
+                            "offset": 256, "bytes_read": 512,
+                            "truncated": False,
+                            "content": "00000100  7f 45 4c 46"})
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "read_binary", "result": inner}))
+        self.assertIn("model.bin \u00b7 hex \u00b7 512 B of 1.0 KiB \u00b7 at 256 B", extras)
+        self.assertIn("[fold] content", extras)
 
     def test_write_file_result_states_the_write(self):
         out, err, note, extras, no_output = result_output(self.evt(