فهرست منبع

ui: comments display — body-first projections, footnote styling, no command echo

The comment projection stores the entry's OUTPUT (the body) as its text,
never the parsed command echo — heredoc shapes included. The goal page's
current-activity line shows conversation entries' bodies, so the literal
'saddle comment' string never renders outside the terminal. Comment
cards on the task and goal pages show the body before the quote and read
as footnotes (muted, smaller), as do the goal narrative's comment events
and the dashboard timeline's comment rows.
clanker 1 ماه پیش
والد
کامیت
79c28cc48a

+ 8 - 2
src/Services/SaddleService.vala

@@ -1283,9 +1283,15 @@ namespace ClankerSaddle.Services {
                 string output = string_member(entry, "output");
                 switch (string_member(entry, "type")) {
                     case "comment":
-                        string body = strip_quotes(strip_verb(command, "saddle comment"));
+                        // Plan 13 B4: body-first — the entry's output IS
+                        // the comment body; the command echo (the
+                        // `saddle comment` invocation, heredoc shapes
+                        // included) must never become the projected
+                        // text. The parsed command stays as a legacy
+                        // fallback for pushes without output.
+                        string body = output;
                         if (body.length == 0) {
-                            body = output;
+                            body = strip_quotes(strip_verb(command, "saddle comment"));
                         }
                         if (body.length > 0) {
                             yield tasks.add_comment(task_id, "agent", body);

+ 17 - 4
src/entrypoints/GoalDetailEntrypoint.vala

@@ -502,10 +502,23 @@ namespace ClankerSaddle {
                             .materialise_async()) {
                         newest = command;
                     }
-                    if (newest != null && ((!)newest).command_text.length > 0) {
-                        pub.current_command = clip_line(((!)newest).command_text, 200);
-                        pub.current_command_display = format_stamp(((!)newest).started_at);
-                        pub.current_command_running = ((!)newest).completed_at == null;
+                    if (newest != null) {
+                        // Plan 13 B4: body-first for conversation-level
+                        // entries — the output (the comment body, the
+                        // failure explanation) leads; only a bare
+                        // command renders its command line, so the
+                        // literal `saddle comment` echo never leaves
+                        // the terminal.
+                        string line = ((!)newest).command_text;
+                        if (((!)newest).entry_type != "command"
+                                && (((!)newest).output_text ?? "").length > 0) {
+                            line = (!)newest.output_text;
+                        }
+                        if (line.length > 0) {
+                            pub.current_command = clip_line(line, 200);
+                            pub.current_command_display = format_stamp(((!)newest).started_at);
+                            pub.current_command_running = ((!)newest).completed_at == null;
+                        }
                     }
                 }
             }

+ 10 - 0
src/pages/app.css

@@ -390,6 +390,12 @@ nav .nav-goal { font-weight: 600; }
 .muted { color: var(--ink-muted); }
 .error { color: var(--danger); }
 
+/* Plan 13 B4: comments read as footnotes — muted and smaller — in the
+   goal narrative's comment events, the dashboard timeline's comment
+   rows and every comment card. The body is the content; the chrome
+   around it stays quiet. */
+.footnote { color: var(--ink-muted); font-size: 0.85rem; }
+
 /* ---- Markdown content (stm-markdown) ---- */
 
 .md h1 { font-size: 1.35rem; margin: var(--grid-2) 0 var(--grid-1); }
@@ -422,11 +428,15 @@ nav .nav-goal { font-weight: 600; }
 
 /* ---- Comments ---- */
 
+/* Footnote treatment (plan 13 B4): the card itself goes muted and
+   smaller — the body leads, the quote trails beneath it. */
 .comment {
     background: var(--surface);
     border: 1px solid var(--line);
     border-left: 3px solid var(--line);
     border-radius: var(--radius);
+    color: var(--ink-muted);
+    font-size: 0.88rem;
     margin: var(--grid-1) 0;
     padding: var(--grid-1) var(--grid-2);
 }

+ 5 - 2
src/pages/goal_detail.html

@@ -319,7 +319,10 @@
                     <span class="muted" stm-text="event.at_display">when</span>
                     <span stm-attribute.class="'badge badge-goal-' + event.kind"
                           stm-text="event.kind.replace('-', ' ')">kind</span>
-                    <div class="md goal-event-text" stm-markdown="event.text">text</div>
+                    <!-- Comment events render as footnotes (plan 13 B4):
+                         the body leads, muted and smaller. -->
+                    <div stm-attribute.class="'md goal-event-text' + (event.kind == 'comment' ? ' footnote' : '')"
+                         stm-markdown="event.text">text</div>
                     <a stm-if="event.task_id > 0"
                        stm-attribute.href="'/tasks/' + event.task_id">task</a>
                 </div>
@@ -332,8 +335,8 @@
                      stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '')">
                     <strong stm-text="comment.author">author</strong>
                     <span class="muted" stm-text="comment.created_display">when</span>
-                    <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
                     <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="goal.comments.length == 0" class="muted">No comments yet.</p>
                 <form stm-action="goal.comment">

+ 4 - 1
src/pages/home.html

@@ -237,7 +237,10 @@
                             <span class="badge badge-yeehaw" stm-if="item.task_yeehaw"
                                   title="The agent celebrated this task (saddle yeehaw)">🤠</span></td>
                         <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
-                            <span stm-text="item.text">activity</span></td>
+                            <!-- Comment rows read as footnotes (plan 13
+                                 B4): the body leads, muted and smaller. -->
+                            <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' ? 'footnote' : ''"
+                                  stm-text="item.text">activity</span></td>
                     </tr>
                     <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="muted">Nothing yet.</td></tr>
                 </tbody>

+ 1 - 1
src/pages/task_detail.html

@@ -510,8 +510,8 @@
             <div stm-for-comment-in="task.comments" stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '')">
                 <strong stm-text="comment.author">author</strong>
                 <span class="muted" stm-text="comment.when_display">when</span>
-                <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
                 <div stm-text="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>
             <form stm-action="task.comment">

+ 98 - 0
test/web_stack_test.py

@@ -2034,6 +2034,34 @@ def bundle_pages(ctx):
     check('stm-attribute.value="task.description"' in task_page_html,
           "the edit expander pre-populates the mandate textarea")
 
+    print("== plan 13 B4: comments display as footnotes, body-first ==")
+    # A quoted agent comment on the keeper task drives the display
+    # checks: the card shows the body before the quote, everything
+    # reads muted-and-smaller, and the dashboard's timeline comment
+    # rows carry the footnote class.
+    conn = db()
+    conn.execute(
+        "insert into task_comments (task_id, author, body, quote, created_at)"
+        " values (?, 'agent', 'The weld passed its pressure test.',"
+        " 'previous inspection notes', strftime('%s','now'))", (keeper,))
+    conn.commit()
+    conn.close()
+    task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
+    check("saddle comment" not in task_page_html,
+          "the task page never shows the command echo")
+    check(task_page_html.index('stm-text="comment.body"')
+          < task_page_html.index('stm-text="comment.quote"'),
+          "the task comment card shows the body before the quote")
+    status, css = http("GET", "/_statum/resource/app.css", timeout=15)
+    check(status == 200 and ".footnote" in css
+          and re.search(r"\.comment\s*\{[^}]*color:\s*var\(--ink-muted\)", css)
+          and re.search(r"\.comment\s*\{[^}]*font-size:\s*0\.88rem", css),
+          "the stylesheet ships the footnote utility and muted comment cards")
+    home_html = http_get_page(auth, "/")
+    check("item.kind == 'comment' || item.kind == 'goal-comment' ? 'footnote'"
+          in home_html,
+          "the dashboard timeline renders comment rows as footnotes")
+
 
 
 
@@ -6155,6 +6183,76 @@ def bundle_replay(ctx):
     check(task_state(auth, run_task)["status"] == "failed",
           "the goal-run task stays failed after the replays")
 
+    print("== plan 13 B4: the comment projection stores the body ==")
+    # The heredoc shape: the command is the pipe invocation, the OUTPUT
+    # is the body. The projections must carry the body — the command
+    # echo never becomes comment text, and the literal `saddle
+    # comment` string never renders outside the terminal.
+    act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "Comment goal", "brief": "Reports cleanly.",
+        "branch_name": "goal/comment"})
+    conn = db()
+    comment_goal = conn.execute(
+        "select id from goals where title = 'Comment goal'").fetchone()[0]
+    conn.close()
+    fake.goal_script = [("suspend",)]
+    act(auth, goal_state(comment_goal)["start"], {})
+    conn = db()
+    comment_run = conn.execute(
+        "select id from tasks where goal_id = ? and kind = 'goal_run'",
+        (comment_goal,)).fetchone()[0]
+    conn.close()
+    check(wait_for(lambda: task_state(auth, comment_run)["status"] == "suspended"
+                   or False, timeout=60, what="comment goal agent parks") is not None,
+          "the comment goal's agent parks")
+    conn = db()
+    comment_session = conn.execute(
+        "select id from sessions where task_id = ? and ended_at is null",
+        (comment_run,)).fetchone()[0]
+    conn.close()
+
+    comment_body = "The flange weld holds. First sentence carries the information."
+    comment_entry = [{"id": 8001, "command": "cat << 'EOF' | saddle comment",
+                      "output": comment_body, "exit_code": 0, "type": "comment",
+                      "timestamp": "2026-09-11T00:03:00Z"}]
+    status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
+                           {"session_id": comment_session, "state": "suspended",
+                            "entries": comment_entry})
+    check(status == 200, "the comment entry push is accepted")
+    conn = db()
+    task_comment_bodies = [r[0] for r in conn.execute(
+        "select body from task_comments where task_id = ?", (comment_run,))]
+    goal_comment_bodies = [r[0] for r in conn.execute(
+        "select body from goal_comments where goal_id = ?", (comment_goal,))]
+    event_texts = [r[0] for r in conn.execute(
+        "select text from goal_events where goal_id = ? and kind = 'comment'",
+        (comment_goal,))]
+    conn.close()
+    check(task_comment_bodies == [comment_body],
+          "the task comment stores the body, not the command echo")
+    check(goal_comment_bodies == [comment_body],
+          "the goal comment stores the body, not the command echo")
+    check(len(event_texts) == 1 and event_texts[0].startswith("The flange weld holds.")
+          and "saddle comment" not in event_texts[0] and "EOF" not in event_texts[0],
+          "the goal_events comment projection stores the body text")
+
+    state = goal_state(comment_goal)
+    check(state["current_command"] == comment_body,
+          "the goal page's current-activity line shows the comment body")
+    check(state["comments"] and state["comments"][0]["body"] == comment_body
+          and any(e["kind"] == "comment"
+                  and e["text"].startswith("The flange weld holds.")
+                  for e in state["events"]),
+          "the goal slot carries the body in its comments and narrative")
+    page = http_get_page(auth, "/goals/%d" % comment_goal)
+    check("saddle comment" not in page,
+          "the rendered goal page never shows the command echo")
+    check("event.kind == 'comment' ? ' footnote'" in page,
+          "the narrative's comment events render with footnote styling")
+    check(page.index('stm-markdown="comment.body"')
+          < page.index('stm-text="comment.quote"'),
+          "the comment card shows the body before the quote")
+
     # Drain: nothing queued remains for the shard's shutdown.
     check(queued_count() == 0, "the replay shard drains its queue")