Explorar el Código

ui: agent celebrations — saddle yeehaw festive card, 🤠 badge and timeline events

clanker hace 1 mes
padre
commit
06760a6895

+ 9 - 2
src/Services/SaddleService.vala

@@ -1253,8 +1253,10 @@ namespace ClankerSaddle.Services {
          * progress and raise the review banner, the `saddle done` entry
          * completes the task with the summary the agent gave, and the
          * `saddle failure` entry fails it with the explanation the agent
-         * piped in. Returns true once a done or failed event finished
-         * the task.
+         * piped in. `saddle yeehaw` entries are handled explicitly but
+         * project nothing — the stored `session_commands` row IS the
+         * celebration the card, badge and timeline read back. Returns
+         * true once a done or failed event finished the task.
          */
         public async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
                 TaskService tasks) throws Error {
@@ -1309,6 +1311,11 @@ namespace ClankerSaddle.Services {
                             : strip_quotes(strip_verb(command, "saddle failure"));
                         yield tasks.fail_task(task_id, explanation);
                         return true;
+                    case "yeehaw":
+                        // The celebration needs no task-side projection:
+                        // store_pushed_entries already persisted the row
+                        // the yeehaw card, badge and timeline read from.
+                        break;
                     default:
                         break;
                 }

+ 134 - 0
src/Services/TaskService.vala

@@ -25,6 +25,17 @@ namespace ClankerSaddle.Services {
     public const string BLOCK_REVIEW = "review";
     public const string BLOCK_PLAN = "plan";
 
+    /**
+     * The agent-celebration entry type (`saddle yeehaw <message>`): the
+     * daemon pushes it as a round-results entry carrying the message in
+     * the output field, so it stores like any other `session_commands`
+     * row — nothing new is persisted for the badge or the card.
+     */
+    public const string ENTRY_YEEHAW = "yeehaw";
+
+    /** The task page's yeehaw card cap (newest entries kept). */
+    public const int YEEHAW_CARD_MAX = 3;
+
     /**
      * The stable verdict wordings (plan 11): they are stored as the
      * block answers AND ride the resuming allocation's
@@ -95,6 +106,25 @@ namespace ClankerSaddle.Services {
         public ImmutableLot<Session> sessions { get; set; }
     }
 
+    /**
+     * One agent celebration (`saddle yeehaw`) read back for display: the
+     * message from the entry's output field and when it landed.
+     */
+    public class YeehawEntry : Object {
+        public string message { get; set; default = ""; }
+        public DateTime? at { get; set; }
+    }
+
+    /**
+     * The task's celebration feed for the yeehaw card: the newest
+     * {@link YEEHAW_CARD_MAX} entries plus the uncapped total (the
+     * "+N more" hint derives from the two).
+     */
+    public class YeehawFeed : Object {
+        public Vector<YeehawEntry> latest { get; set; default = new Vector<YeehawEntry>(); }
+        public int64 total { get; set; }
+    }
+
     /**
      * Task storage and lifecycle: creation, dependency-aware readiness,
      * agent interaction primitives (comments, blocks, plans, progress,
@@ -615,6 +645,110 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
         }
 
+        /**
+         * True when any of the task's sessions carries a `yeehaw` entry —
+         * the badge's presence check, one cheap EXISTS over the existing
+         * `session_commands` rows (nothing new is stored for the badge).
+         */
+        public async bool has_yeehaw(int64 task_id) throws Error {
+            var command = connection.create_command(
+                "SELECT EXISTS(SELECT 1 FROM session_commands sc"
+                + " JOIN sessions s ON s.id = sc.session_id"
+                + " WHERE s.task_id = :task AND sc.type = :kind) AS hit");
+            command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
+            command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
+            var hit = yield command.execute_scalar_async();
+            int64? value = 0;
+            return hit != null && hit.try_get_as<int64?>(out value) && ((!)value) != 0;
+        }
+
+        /**
+         * Every task id whose sessions carry a `yeehaw` entry (stringly
+         * keyed) — the batch presence check the task list and the
+         * dashboard's task references badge from, one DISTINCT query
+         * instead of one EXISTS per row.
+         */
+        public async Invercargill.DataStructures.HashSet<string> yeehaw_task_ids() throws Error {
+            var ids = new Invercargill.DataStructures.HashSet<string>();
+            var command = connection.create_command(
+                "SELECT DISTINCT s.task_id AS task_id FROM sessions s"
+                + " JOIN session_commands sc ON sc.session_id = s.id"
+                + " WHERE sc.type = :kind");
+            command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
+            foreach (var row in yield command.execute_query_async()) {
+                var element = row.get("task_id");
+                int64? value = 0;
+                if (element != null && element.try_get_as<int64?>(out value) && value != null) {
+                    ids.add(((!)value).to_string());
+                }
+            }
+            return ids;
+        }
+
+        /**
+         * The task's yeehaw card data: the newest
+         * {@link YEEHAW_CARD_MAX} celebrations (message + when, newest
+         * first) plus the uncapped total. Reads the existing
+         * `session_commands` rows through the sessions join — the card
+         * stores nothing of its own.
+         */
+        public async YeehawFeed yeehaw_feed(int64 task_id) throws Error {
+            var total_command = connection.create_command(
+                "SELECT COUNT(*) AS total FROM session_commands sc"
+                + " JOIN sessions s ON s.id = sc.session_id"
+                + " WHERE s.task_id = :task AND sc.type = :kind");
+            total_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
+            total_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
+            var total = yield total_command.execute_scalar_async();
+            int64? total_value = 0;
+            if (total != null) {
+                total.try_get_as<int64?>(out total_value);
+            }
+
+            var feed = new YeehawFeed() { total = total_value };
+            var rows_command = connection.create_command(
+                "SELECT sc.output_text AS message, sc.command_text AS command,"
+                + " sc.started_at AS started, sc.completed_at AS completed"
+                + " FROM session_commands sc JOIN sessions s ON s.id = sc.session_id"
+                + " WHERE s.task_id = :task AND sc.type = :kind"
+                + " ORDER BY sc.id DESC LIMIT :cap");
+            rows_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
+            rows_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
+            rows_command.with_parameter<Invercargill.Element>("cap", elem<int64?>(YEEHAW_CARD_MAX));
+            foreach (var row in yield rows_command.execute_query_async()) {
+                string message = string_of(row.get("message"));
+                if (message.length == 0) {
+                    // The message rides the output field; the command
+                    // line is the fallback for empty-output pushes.
+                    message = string_of(row.get("command"));
+                }
+                DateTime? at = date_time_of(row.get("started"));
+                if (at == null) {
+                    at = date_time_of(row.get("completed"));
+                }
+                feed.latest.add(new YeehawEntry() { message = message, at = at });
+            }
+            return feed;
+        }
+
+        /** A raw query column back as a string ("" when null/unreadable). */
+        private static string string_of(Invercargill.Element? element) {
+            if (element == null) {
+                return "";
+            }
+            string value = "";
+            return ((!)element).try_get_as<string>(out value) ? value : "";
+        }
+
+        /** A raw query column back as a DateTime (null when null/unreadable). */
+        private static DateTime? date_time_of(Invercargill.Element? element) {
+            if (element == null) {
+                return null;
+            }
+            DateTime value;
+            return ((!)element).try_get_as<DateTime>(out value) ? value : null;
+        }
+
         /** Appends a comment; `author` is 'bot' or 'user:<name>'. */
         public async TaskComment add_comment(int64 task_id, string author, string body,
                 string? quote = null) throws Error {

+ 32 - 1
src/Services/TimelineService.vala

@@ -10,7 +10,8 @@ namespace ClankerSaddle.Services {
     /** One materialised cross-task timeline event (plan 10 phase 3). */
     public class TimelineEvent : Object {
         /** created | started | suspended | completed | failed |
-         *  block-raised | block-answered | comment | progress | dispatch */
+         *  block-raised | block-answered | comment | progress | dispatch |
+         *  yeehaw */
         public string kind { get; set; default = ""; }
         /** The event's epoch second — the newest-first sort key. */
         public int64 at_epoch { get; set; }
@@ -168,6 +169,36 @@ namespace ClankerSaddle.Services {
                 });
             }
 
+            // Agent celebrations (`saddle yeehaw`): the stored
+            // session_commands rows surface as their own event kind —
+            // "🤖 yeehaw'd: <first line>" — keyed on the owning session's
+            // task. Entries without a timestamp cannot sort and are
+            // skipped like every other timestamp-less source row.
+            var session_tasks = new Dictionary<string, string>();
+            foreach (var session in yield db.query<Session>().materialise_async()) {
+                session_tasks.set(session.id.to_string(), session.task_id.to_string());
+            }
+            foreach (var entry in yield db.query<SessionCommand>()
+                    .where(expr("type == $0", elem(ENTRY_YEEHAW)))
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                if (entry.started_at == null) {
+                    continue;
+                }
+                string? task_key = session_tasks.get_or_default(entry.session_id.to_string());
+                if (task_key == null) {
+                    continue;
+                }
+                string? lead = first_line(entry.output_text ?? entry.command_text);
+                string text = lead != null
+                    ? @"🤖 yeehaw'd: $(snippet((!)lead))" : "🤖 yeehaw'd";
+                events.add(new TimelineEvent() {
+                    kind = ENTRY_YEEHAW, at_epoch = ((!)entry.started_at).to_unix(),
+                    task_id = int64.parse((!)task_key),
+                    task_title = titles.get_or_default((!)task_key) ?? "",
+                    text = text, sequence = sequence++
+                });
+            }
+
             var sorted = events.sort((a, b) => {
                 if (a.at_epoch != b.at_epoch) {
                     return a.at_epoch > b.at_epoch ? -1 : 1;

+ 10 - 1
src/entrypoints/HomeEntrypoint.vala

@@ -27,6 +27,11 @@ namespace ClankerSaddle {
         public int64 task_id { get; set; }
         public string task_title { get; set; default = ""; }
         public string text { get; set; default = ""; }
+        /**
+         * The referenced task earned its 🤠 badge — any of its sessions
+         * carries a `saddle yeehaw` entry (derived, nothing stored).
+         */
+        public bool task_yeehaw { get; set; }
     }
 
     /** An active task with its latest progress line. */
@@ -256,6 +261,9 @@ namespace ClankerSaddle {
             }
 
             var activity = new Vector<DashboardActivityView>();
+            // The 🤠 badge rides every timeline task reference: one
+            // batch presence set for the whole activity window.
+            var yeehaw_ids = yield tasks.yeehaw_task_ids();
             foreach (var item in yield timeline.recent_window()) {
                 activity.add(new DashboardActivityView() {
                     kind = item.kind,
@@ -264,7 +272,8 @@ namespace ClankerSaddle {
                         .format("%Y-%m-%d %H:%M"),
                     task_id = item.task_id,
                     task_title = item.task_title,
-                    text = item.text
+                    text = item.text,
+                    task_yeehaw = yeehaw_ids.has(item.task_id.to_string())
                 });
             }
 

+ 35 - 0
src/entrypoints/TaskDetailEntrypoint.vala

@@ -24,6 +24,15 @@ namespace ClankerSaddle {
         public string when_display { get; set; default = ""; }
     }
 
+    /**
+     * One agent celebration (`saddle yeehaw`) on the task page: the
+     * message plus when it landed, rendered inside the festive card.
+     */
+    public class YeehawView : Object {
+        public string message { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+    }
+
     /**
      * A block (question, RFC, review or plan) with its answer state and
      * answer action. {@link is_plan_review} marks the plan-acceptance
@@ -157,6 +166,16 @@ namespace ClankerSaddle {
         /** The signed-in user holds a "Notify me" pref for this task. */
         public bool notify_me { get; set; }
         public Statum.Model.ActionDto? toggle_notify { get; set; }
+        /**
+         * The task has at least one `saddle yeehaw` entry — the 🤠
+         * badge's presence, derived from the existing
+         * session_commands rows (nothing new is stored).
+         */
+        public bool yeehaw { get; set; }
+        /** The task's uncapped yeehaw count (the card's "+N more" hint). */
+        public int64 yeehaw_total { get; set; }
+        /** Yeehaw entries beyond the card cap (0 within it). */
+        public int64 yeehaw_more { get; set; }
     }
 
     /**
@@ -278,6 +297,21 @@ namespace ClankerSaddle {
                 });
             }
 
+            // The festive yeehaw card (saddle yeehaw): newest entries
+            // first, capped at YEEHAW_CARD_MAX with a "+N more" hint;
+            // pub.yeehaw is the status-card-corner badge's presence.
+            var yeehaw_feed = yield tasks.yeehaw_feed(task.id);
+            pub.yeehaw = yeehaw_feed.total > 0;
+            pub.yeehaw_total = yeehaw_feed.total;
+            pub.yeehaw_more = (yeehaw_feed.total - yeehaw_feed.latest.length).clamp(0, int64.MAX);
+            var yeehaws = new Vector<YeehawView>();
+            foreach (var row in yeehaw_feed.latest) {
+                yeehaws.add(new YeehawView() {
+                    message = row.message,
+                    when_display = format_time(row.at)
+                });
+            }
+
             // Newest-first (plan 10 phase 3): the blocking sections
             // lead with the most recent question, RFC or review.
             var blocks_ascending = new Vector<BlockView>();
@@ -444,6 +478,7 @@ namespace ClankerSaddle {
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["comments"] = StateJson.list<CommentView>(comments.to_immutable_buffer());
             props["progress"] = StateJson.list<ProgressView>(progress.to_immutable_buffer());
+            props["yeehaws"] = StateJson.list<YeehawView>(yeehaws.to_immutable_buffer());
             props["blocks"] = block_views(blocks);
             props["open_blocks"] = block_views(open_blocks);
             props["answered_blocks"] = block_views(answered_blocks);

+ 9 - 0
src/entrypoints/TasksEntrypoint.vala

@@ -25,6 +25,11 @@ namespace ClankerSaddle {
         public string[] capabilities { get; set; }
         public bool waiting_for_saddle { get; set; }
         public bool unreachable { get; set; }
+        /**
+         * The task earned its 🤠 badge — any of its sessions carries a
+         * `saddle yeehaw` entry (derived, nothing stored for it).
+         */
+        public bool has_yeehaw { get; set; }
     }
 
     /**
@@ -120,6 +125,9 @@ namespace ClankerSaddle {
                 ((!)names).add(name);
             }
             var unreachable_ids = yield task_service.unreachable_task_ids();
+            // The 🤠 badge's batch presence set: every task with a
+            // yeehaw entry, one DISTINCT query for the whole queue.
+            var yeehaw_ids = yield task_service.yeehaw_task_ids();
             var idle_capability_sets = new Vector<Invercargill.DataStructures.HashSet<string>>();
             foreach (var saddle in yield saddle_service.list_idle_saddles()) {
                 var carried = new Invercargill.DataStructures.HashSet<string>();
@@ -184,6 +192,7 @@ namespace ClankerSaddle {
                     view.waiting_for_saddle = !covered && idle_capability_sets.length > 0;
                 }
                 view.unreachable = unreachable_ids.has(task.id.to_string());
+                view.has_yeehaw = yeehaw_ids.has(task.id.to_string());
                 rows.add(view);
             }
 

+ 30 - 0
src/pages/app.css

@@ -256,6 +256,36 @@ summary { cursor: pointer; font-weight: 600; }
 .badge-query { background: #ddf4ff; color: #0969da; }
 .badge-shell-reset { background: var(--line-soft); color: var(--ink-muted); }
 
+/* Agent celebrations (`saddle yeehaw`) — the earned 🤠 badge (task
+   status-card corner, queue rows, dashboard task references and the
+   timeline's own yeehaw kind) plus the task page's festive card. */
+.badge-yeehaw { background: #f8e8b8; color: #7d5b00; }
+
+.yeehaw-card {
+    background: #fffaf0;
+    border: 1px solid #d29922;
+    border-left: 4px solid #d29922;
+    box-shadow: 0 1px 4px #d2992222;
+    margin: var(--grid-2) 0;
+}
+
+.yeehaw-card h3 { color: #7d5b00; margin: 0 0 var(--grid-1); }
+
+.yeehaw-list { list-style: none; margin: 0; padding: 0; }
+
+.yeehaw-list li {
+    border-bottom: 1px dashed #eacf9a;
+    margin: 0;
+    padding: var(--grid-1) 0;
+}
+
+.yeehaw-list li:last-child { border-bottom: none; }
+
+.yeehaw-message { font-weight: 600; margin-right: var(--grid-1); }
+
+/* The status card's corner badge floats right of its heading. */
+.status-corner { float: right; font-size: 0.9rem; }
+
 .muted { color: var(--ink-muted); }
 .error { color: var(--danger); }
 

+ 3 - 1
src/pages/home.html

@@ -239,7 +239,9 @@
                 <tbody>
                     <tr stm-for-item-in="dashboard.activity">
                         <td stm-text="item.when_display">when</td>
-                        <td><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a></td>
+                        <td><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
+                            <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>
                     </tr>

+ 19 - 1
src/pages/task_detail.html

@@ -262,6 +262,23 @@
             <p stm-if="task.progress.length == 0" class="muted">No progress reports yet — the agent reports via
                 <code>saddle progress-update</code>.</p>
 
+            <!-- Agent celebrations (`saddle yeehaw <message>`): the task's
+                 newest entries as one festive card — distinct 🤠 styling,
+                 the message + when, capped at the last 3 with a "+N more"
+                 hint. Absent entirely while the task has none. -->
+            <section class="card yeehaw-card" stm-if="task.yeehaw">
+                <h3>🤠 Yeehaw!</h3>
+                <ul class="yeehaw-list">
+                    <li stm-for-yeehaw-in="task.yeehaws">
+                        <span class="yeehaw-message" stm-text="yeehaw.message">message</span>
+                        <span class="muted" stm-text="yeehaw.when_display">when</span>
+                    </li>
+                </ul>
+                <p class="muted" stm-if="task.yeehaw_more > 0">+<span stm-text="task.yeehaw_more">0</span>
+                    more yeehaw<span stm-text="task.yeehaw_more == 1 ? '' : 's'"></span> on the
+                    <a stm-attribute.href="'/tasks/' + task.id + '/terminal'">terminal log</a></p>
+            </section>
+
             <!-- Status + description side by side in a 50/50 grid (plan
                  11 task D), collapsing to stacked
                  status-then-description on small screens. The
@@ -271,7 +288,8 @@
                  non-terminal; completion is bot-only. -->
             <div class="grid-2">
                 <section class="card status-head">
-                    <h3>Status</h3>
+                    <h3>Status <span class="badge badge-yeehaw status-corner" stm-if="task.yeehaw"
+                          title="The agent celebrated this task (saddle yeehaw)">🤠</span></h3>
                     <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
                         · priority <strong stm-text="task.priority">5</strong></p>
                     <p class="muted">Created <span stm-text="task.created_display">when</span></p>

+ 3 - 1
src/pages/tasks.html

@@ -133,7 +133,9 @@
                     <tr stm-for-task-in="tasks.tasks">
                         <td stm-text="task.id">id</td>
                         <td><a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                            
+                            <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
+                                  title="The agent celebrated this task (saddle yeehaw)">🤠</span>
+
                             <span class="muted" stm-if="task.parent_task_id > 0"> · subtask of
                                 <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span>
                             <span class="muted" stm-if="task.ready"> · ready</span>

+ 109 - 0
test/web_stack_test.py

@@ -1729,6 +1729,115 @@ def bundle_pages(ctx):
           < queue_page.index("<table"),
           "the New Task expander sits above the filter controls and the table")
 
+    print("== yeehaw: ingestion, festive card, badge, timeline ==")
+    # `saddle yeehaw <message>` arrives as a round-results entry with the
+    # message in the output field: it stores like any entry type, earns
+    # the task its 🤠 badge (derived, nothing new stored), renders as the
+    # task page's festive card (last 3, newest first, "+N more") and
+    # surfaces on the dashboard timeline as its own event kind.
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
+                 " values (1, 'Yeehaw task', 'working', 5, strftime('%s','now'))")
+    yeehaw_task = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute("insert into sessions (task_id, saddle_id, started_at)"
+                 " values (?, ?, strftime('%s','now'))", (yeehaw_task, fake_id))
+    yeehaw_session = conn.execute("select max(id) from sessions").fetchone()[0]
+    conn.commit()
+    conn.close()
+    now_epoch = int(time.time())
+
+    def push_yeehaw(entry_id, message, offset=0):
+        """One celebration riding a round-results push, timestamped
+        `offset` seconds from now (distinct epochs keep the timeline's
+        newest-first ordering observable)."""
+        return saddle_api("POST", "/api/saddle/results", fake_token, {
+            "session_id": yeehaw_session, "state": "working",
+            "entries": [{
+                "id": entry_id, "command": "saddle yeehaw", "output": message,
+                "exit_code": 0, "type": "yeehaw",
+                "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ",
+                                           time.gmtime(now_epoch + offset)),
+            }]})[0]
+
+    def yeehaw_rows():
+        conn = db()
+        try:
+            return conn.execute(
+                "select count(*) from session_commands where session_id = ?"
+                " and type = 'yeehaw'", (yeehaw_session,)).fetchone()[0]
+        finally:
+            conn.close()
+
+    check(push_yeehaw(9101, "Shipped the flange") == 200,
+          "yeehaw entries ingest through the results push like any entry type")
+    check(yeehaw_rows() == 1,
+          "the yeehaw entry stores as a session_commands row (message in output)")
+    check(push_yeehaw(9101, "Shipped the flange") == 200 and yeehaw_rows() == 1,
+          "a redelivered yeehaw push is idempotent on the daemon entry id")
+    check(task_state(auth, yeehaw_task)["status"] == "working",
+          "a yeehaw entry never derails the task state")
+
+    push_yeehaw(9102, "Second hoedown", 1)
+    push_yeehaw(9103, "Third hoedown\nsecond line carries the detail", 2)
+    push_yeehaw(9104, "Fourth hoedown", 3)
+    push_yeehaw(9105, "Fifth hoedown", 4)
+    detail = task_state(auth, yeehaw_task)
+    check(detail.get("yeehaw") is True and detail.get("yeehaw_total") == 5,
+          "the detail state flags the badge presence and the uncapped total")
+    check([y["message"] for y in detail.get("yeehaws", [])]
+          == ["Fifth hoedown", "Fourth hoedown",
+              "Third hoedown\nsecond line carries the detail"],
+          "the festive card keeps the last 3 yeehaws, newest first (full messages)")
+    check(detail.get("yeehaw_more") == 2,
+          "the '+N more' hint counts the capped tail")
+    status, page = http("GET", "/tasks/%d" % yeehaw_task, timeout=15)
+    # The template compiler HTML-escapes the emoji (&#129312; = 🤠).
+    check(status == 200 and "yeehaw-card" in page and "&#129312; Yeehaw!" in page,
+          "the task page renders the festive yeehaw card")
+    check(page.index("<h3>Progress</h3>") < page.index("yeehaw-card")
+          < page.index("status-head"),
+          "the card sits between the progress feed and the status grid")
+    check('stm-text="task.yeehaw_more"' in page and "more yeehaw" in page,
+          "the card carries the +N more hint")
+    check("badge-yeehaw" in page and "status-corner" in page,
+          "the status card corner carries the earned 🤠 badge")
+    plain_detail = task_state(auth, layout_task)
+    check(plain_detail.get("yeehaw") is False and plain_detail.get("yeehaw_total") == 0
+          and plain_detail.get("yeehaws", []) == [],
+          "tasks without yeehaw entries carry no card state")
+    status, plain_page = http("GET", "/tasks/%d" % layout_task, timeout=15)
+    check(status == 200 and 'section class="card yeehaw-card" stm-if="task.yeehaw"' in plain_page,
+          "the card template only renders under state gating (stm-if task.yeehaw)")
+
+    queue_rows = slot_public(auth, "/tasks", "tasks")["tasks"]
+    check([t for t in queue_rows if int(t["id"]) == yeehaw_task][0].get("has_yeehaw") is True,
+          "queue rows badge yeehaw tasks")
+    check([t for t in queue_rows if int(t["id"]) == layout_task][0].get("has_yeehaw") is False,
+          "queue rows leave yeehaw-less tasks bare")
+    status, queue_page = http("GET", "/tasks", timeout=15)
+    check(status == 200 and 'stm-if="task.has_yeehaw"' in queue_page,
+          "the queue template renders the row badge")
+
+    dash = slot_public(auth, "/", "dashboard")
+    yeehaw_events = [e for e in dash.get("activity", []) if e["kind"] == "yeehaw"]
+    check(len(yeehaw_events) == 5
+          and all(int(e["task_id"]) == yeehaw_task for e in yeehaw_events),
+          "every yeehaw entry surfaces as its own timeline event kind")
+    check(yeehaw_events[0]["text"] == "🤖 yeehaw'd: Fifth hoedown",
+          "the newest yeehaw event leads the timeline rows")
+    check(all("second line" not in e["text"] for e in yeehaw_events),
+          "multiline yeehaw messages snippet to their first line")
+    referenced = [e for e in dash.get("activity", []) if int(e["task_id"]) == yeehaw_task]
+    check(len(referenced) > 0 and all(e.get("task_yeehaw") is True for e in referenced),
+          "dashboard timeline task references carry the badge flag")
+    plain_refs = [e for e in dash.get("activity", []) if int(e["task_id"]) == layout_task]
+    check(len(plain_refs) > 0 and all(e.get("task_yeehaw") is False for e in plain_refs),
+          "references to yeehaw-less tasks stay bare")
+    status, home_page = http("GET", "/", timeout=15)
+    check(status == 200 and 'stm-if="item.task_yeehaw"' in home_page
+          and "badge-yeehaw" in home_page,
+          "the dashboard renders the badge on timeline task references")
+
     print("== R10: branch_advanced on the /poll verdict channel ==")
     conn = db()
     conn.execute("insert into tasks (project_id, title, status, priority,"