Explorar o código

Task page <-> event log unification: Overview | Log tabs

Goal 41 T7 (ux-notes structural bet 3 + win 5's flow half): the task
page and its event log become ONE investigation flow.

Design (a), fragment-embed - chosen because the route was clean:

- The log's interactive shell (toolbar, filters, search, feed,
  load-older) moved into a pstm-fragment partial,
  src/pages/task_log_feed.html. Partials are a proven compile-time
  pattern here (progress_feed, goal_branches, breadcrumb), and
  task_log.js was already parameterised by the task_log statum slot
  plus document-level [data-log-*] queries - so the SAME shell and
  the SAME client engine now serve both surfaces.
- task_detail.html mounts Spry's CSS tabs (radios first, then
  .tab-list labels, then .tab-panels): panel 1 "Overview" wraps the
  T5 subnav + Act|Consult columns unchanged; panel 2 "Event log"
  carries the shared partial plus the context line and live badge.
  The Progress cluster's "Event log" control became a <label> for
  the log radio - a same-tab toggle, no navigation at all.
- TaskLogEntrypoint.log_slot_for() is the ONE builder of the
  task_log PAGE slot; TaskDetailEntrypoint now sets it beside the
  task slot (both the normal and the goal-run redirect path), and
  RefreshTaskDataAction re-sets it on every pulse so the embedded
  badge's status words stay truthful after transitions. Additive
  slot fields: none needed - the shape already carried everything.
- The standalone /tasks/{id}/log route KEEPS its URL (deep links,
  the goal page's per-session run links) and its T5 context header;
  it renders the same partial. goal_detail's log links dropped
  target=_blank (new tabs severed the back button).
- SSE lifecycle on tab switches needed no work: the panel never
  unmounts (CSS display toggling), so the EventSource and feed
  state live for the page's lifetime; the feed's 72vh scroller keeps
  deep history navigable inside the panel.
- app.css gains one appended marked T7 section (.task-tabs rhythm,
  .tab-link quiet-link styling, .task-log-panel spacing), every
  value a --spry-* token.
- docs/spry-conversion.md documents the new partial pattern (what a
  page must do to embed the shell).

Tests:

- New bundle test/bundles/t7log.py (registered in BUNDLES_ORDER
  after pages): tab markup + Spry's radio-order contract, the shared
  shell present exactly once on both surfaces (per-hook counts),
  the label toggle and zero /log anchors in the task page markup,
  identical task_log slot fields from both entrypoints, the
  standalone context strip, event content reachable through the
  JSON page + SSE, the pulse refresh rebuilding BOTH slots (drives
  the real sealed refresh action), and the goal page's same-tab run
  links.
- test_task_log_js.py: one honest update - the feed markup moved to
  the partial, so the markup-invariant test reads SHELL
  (task_log_feed.html) instead of the page; all 108 tests green,
  every behaviour (SSE, filters, search, paging, chronology,
  scroll-stick) untouched.
- pages.py/goals.py: the old "opens in a new tab" assertions -
  which pinned exactly the behaviour this task removes - now assert
  the unified same-tab contract.

Verification: build + builddir (canon_deploy) both green; static
suites green (task_log_js 108, template_ascii); targeted bundles
pages 130/0, t7log 34/0, lineage 41/0, replay 38/0; FULL suite
--parallel 8: 1900 passed, 0 failed across all 38 shards.
saddle-ClankerSaddle hai 1 día
pai
achega
6348585a16

+ 17 - 0
docs/spry-conversion.md

@@ -218,3 +218,20 @@ badge section of app.css, never a new hex.
 
 Adoption by detail pages happens in T5/T6/T9 (it needs each page's
 entrypoint to expose a `crumbs` vector — do not fake it client-side).
+
+## The task-log-feed partial (a client-JS component partial)
+
+`src/pages/task_log_feed.html` is the event log's interactive shell
+(toolbar, filters, search, feed, load-older) as a `pstm-fragment`
+partial, included by BOTH the standalone `/tasks/{id}/log` page and
+the task page's "Event log" tab. It binds the including page's
+`task_log` PAGE slot (`exists` / `task_id` / `task_title` /
+`task_status` / `task_url`); `task_log.js` (loaded via `pstm-res-src`
+by whichever page embeds it) mounts everything from its
+`[data-log-*]` hooks. A page embedding it must (a) load the script
+resource, (b) have its entrypoint publish the slot —
+`TaskLogEntrypoint.log_slot_for(state_service, db, task_id)` builds
+it — and (c) re-set the slot from any pulse refresh the page owns
+(see `RefreshTaskDataAction`). Both surfaces share ONE markup and
+ONE engine by construction: never hand-copy the shell.
+

+ 1 - 0
meson.build

@@ -48,6 +48,7 @@ template_deps = files(
     'src/pages/goal_branches.html',
     'src/pages/goal_compare.html',
     'src/pages/progress_feed.html',
+    'src/pages/task_log_feed.html',
     'src/pages/block_answer.html',
     'src/pages/block_reply.html',
     'src/pages/breadcrumb.html',

+ 7 - 1
src/actions/TaskActions.vala

@@ -646,6 +646,11 @@ namespace ClankerSaddle {
      * current data so the three-second client poll shows live comments,
      * blocks, output-driven status changes and completions. Navigates
      * nowhere; a failed reload leaves the client's state untouched.
+     *
+     * Goal 41 T7: the page also embeds the event log's shell (its
+     * "Log" tab), so the refresh rebuilds the `task_log` slot too —
+     * the embedded live badge's status words stay truthful after a
+     * transition without the log shell owning its own poll.
      */
     public class RefreshTaskDataAction : TaskDetailRefreshingAction {
 
@@ -660,7 +665,8 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            return directives().set(yield task_detail_slot(task_id));
+            return directives().set(yield task_detail_slot(task_id))
+                .set(yield TaskLogEntrypoint.log_slot_for(state_service, db, task_id));
         }
     }
 }

+ 10 - 2
src/entrypoints/TaskDetailEntrypoint.vala

@@ -565,6 +565,13 @@ namespace ClankerSaddle {
                 project_service, saddle_service, rule_service, capability_service,
                 action_registry, poll_state, push_service, event_service, db,
                 me != null ? ((!)me).user_id : 0, task_id);
+            // Goal 41 T7 (structural bet 3): the task page embeds the
+            // event log's shell as its "Log" tab (the task-log-feed
+            // partial); the slot the shell binds hydrates alongside
+            // the task slot — same shape the standalone log page
+            // publishes, one build (TaskLogEntrypoint.log_slot_for).
+            var log_state = yield TaskLogEntrypoint.log_slot_for(
+                state_service, db, task_id);
             // Plan 14 B1: the goal page fully replaces the goal-run
             // task's page — deep links (dashboard, notifications,
             // timeline) redirect to the goal. The task state still
@@ -573,11 +580,12 @@ namespace ClankerSaddle {
             var detail = yield task_service.get_task(task_id);
             if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
                     && ((!)detail).task.goal_id > 0) {
-                return directives().set(state)
+                return directives().set(state).set(log_state)
                     .navigate(@"/goals/$(((!)detail).task.goal_id)")
                     .subscribe_global(StatePulse.SLOT_TYPE);
             }
-            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
+            return directives().set(state).set(log_state)
+                .subscribe_global(StatePulse.SLOT_TYPE);
         }
     }
 }

+ 18 - 2
src/entrypoints/TaskLogEntrypoint.vala

@@ -28,6 +28,11 @@ namespace ClankerSaddle {
      * control pages backwards through GET /tasks/&lt;id&gt;/events in
      * batches of 30. Unauthenticated like the terminal page before
      * it (the task id is the capability).
+     *
+     * Goal 41 T7 (structural bet 3): the same {@link TaskLogPublic}
+     * slot now hydrates the task page's embedded "Log" tab — both
+     * surfaces share {@link log_slot_for} so the shape (and the
+     * task_log.js contract over it) is identical by construction.
      */
     public class TaskLogEntrypoint : StatumEntrypoint {
 
@@ -35,6 +40,17 @@ namespace ClankerSaddle {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             int64 task_id = int64.parse(request.route("task_id") ?? "0");
+            return directives().set(yield log_slot_for(state_service, db, task_id));
+        }
+
+        /**
+         * Builds the `task_log` PAGE slot for task `task_id`: the
+         * shared shell (the task-log-feed partial) binds exactly
+         * these fields. `exists` is false for a missing id — both
+         * pages render their own not-found note from it.
+         */
+        public static async Statum.Slot log_slot_for(StateService state_service,
+                InvercargillSql.Orm.OrmSession db, int64 task_id) throws GLib.Error {
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(task_id)))
                 .first_async();
@@ -48,10 +64,10 @@ namespace ClankerSaddle {
                 pub.task_title = ((!)task).title;
                 pub.task_status = ((!)task).status;
             }
-            return directives().set(state_service.new_slot(Statum.Scope.PAGE, new Statum.State() {
+            return state_service.new_slot(Statum.Scope.PAGE, new Statum.State() {
                 type_name = "task_log",
                 public_data = GObjectMapping.to_properties(pub)
-            }));
+            });
         }
     }
 }

+ 46 - 0
src/pages/app.css

@@ -1573,3 +1573,49 @@ td .truncate.repo-url {
        the strip scroll with the page under the breakpoint. */
     .goal-subnav { position: static; }
 }
+
+/* ==========================================================================
+   Goal 41 T7 — task page <-> event log unification (append-only section
+   per the spry-conversion contract; every value consumes a --spry-* token
+   so themes and auto dark mode need zero extra rules).
+   ========================================================================== */
+
+/* The task page's Overview|Log tabs: Spry's .tabs already owns the
+   radio/label/panel mechanics; the task page just widens the strip's
+   presence a touch (the tab row sits directly under the breadcrumb,
+   above the sub-nav it now frames). */
+.task-tabs {
+    margin-top: var(--spry-space-2);
+}
+
+/* The Progress cluster's "Event log" control is a <label> for the log
+   radio, not an anchor: clicking it flips the page into the log view
+   in place (ux-notes structural bet 3 - no navigation, no new tab).
+   It borrows the quiet link language so it reads as the toggle it
+   is, not a form control. */
+.tab-link {
+    color: var(--spry-accent);
+    cursor: pointer;
+    font-size: 0.95rem;
+    text-decoration: underline;
+    text-decoration-color: var(--spry-border);
+    text-underline-offset: 0.2em;
+}
+.tab-link:hover { text-decoration-color: var(--spry-accent); }
+
+/* The embedded log panel keeps the standalone page's rhythm: the
+   context line + live badge cluster above the shell, and the shell's
+   own sticky toolbar does its work inside the panel. The feed's
+   max-height contract (72vh, its own scroller) is untouched - deep
+   history stays navigable without scrolling the whole page. */
+.task-log-panel .log-shell { margin-top: var(--spry-space-2); }
+.task-log-panel .log-toolbar {
+    /* Inside the tab panel the toolbar parks under the topbar like
+       the standalone page; the subnav strip is Overview-only. */
+    top: var(--topbar-bottom, 3.1rem);
+}
+
+/* The sub-nav strip belongs to the Overview surface; the Log panel
+   frames itself with the context cluster instead. Keep the strip's
+   spacing from leaking into the log panel's top margin. */
+.task-overview-panel > .subnav { margin-top: 0; }

+ 6 - 5
src/pages/goal_detail.html

@@ -714,19 +714,20 @@
                 </div>
             </section>
 
-            <!-- Goal agent runs: every round with its host saddle; the
-                 event log links open in a new tab. -->
+            <!-- Goal agent runs: every round with its host saddle. Goal
+                 41 T7: the run links navigate SAME-TAB to the log (a
+                 new tab severed the back button; the task page's Log
+                 tab and this route are one feed). -->
             <section class="card" stm-if="goal.runs.length > 0">
                 <h3>Goal agent runs</h3>
-                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/log'"
-                      target="_blank" rel="noopener">Event log</a>
+                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/log'">Event log</a>
                     <span class="text-muted">— run task
                         #<span stm-text="goal.run_task_id">id</span></span></p>
                 <table>
                     <thead><tr><th>Run</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
                     <tbody>
                         <tr stm-for-run-in="goal.runs">
-                            <td><a stm-attribute.href="run.terminal_url" target="_blank" rel="noopener"
+                            <td><a stm-attribute.href="run.terminal_url"
                                    stm-text="'session ' + run.id">run</a></td>
                             <td class="text-muted" stm-text="run.saddle_name">saddle</td>
                             <td class="text-muted" stm-text="run.started_display">when</td>

+ 57 - 6
src/pages/task_detail.html

@@ -3,6 +3,12 @@
 <head>
     <pstm-uri>/tasks/{task_id}</pstm-uri>
     <pstm-template>main</pstm-template>
+    <!-- Goal 41 T7: the page's Log tab mounts the SAME client engine
+         the standalone log page runs (SSE feed, filters, search,
+         paging) - it binds the [data-log-*] hooks the task-log-feed
+         partial ships, so loading the resource here is all the
+         wiring the embed needs. -->
+    <script pstm-res-src="task_log.js"></script>
 <script>
     // Live updates: the page subscribes to the global pulse (see the
     // entrypoint); whenever task or saddle state changes anywhere in
@@ -205,6 +211,27 @@
                  context now rides the crumb chain itself. -->
             <pstm-fragment name="breadcrumb" crumbs="task.crumbs"/>
 
+            <!-- Goal 41 T7 (ux-notes structural bet 3): the task page
+                 and its event log are ONE investigation flow, not two
+                 URLs and a new tab. Spry's CSS tabs (radios first,
+                 .tab-list labels, .tab-panels) mount the whole
+                 Overview surface as panel 1 and the event log's shell
+                 (the SAME task-log-feed partial the standalone
+                 /tasks/{id}/log page renders) as panel 2 — the page's
+                 entrypoint publishes both slots. The log link beside
+                 Progress toggles the tab in place (a <label> for the
+                 radio, not an anchor); the standalone route keeps its
+                 URL for deep links and the forensic full-window view. -->
+            <div class="tabs task-tabs">
+                <input type="radio" name="task-view" id="task-view-overview" checked>
+                <input type="radio" name="task-view" id="task-view-log">
+                <div class="tab-list">
+                    <label for="task-view-overview">Overview</label>
+                    <label for="task-view-log">Event log</label>
+                </div>
+                <div class="tab-panels">
+                    <section class="task-overview-panel" aria-label="Overview">
+
             <!-- Goal 41 T5: the anchor sub-nav - the page's map. Pure
                  anchors into the [data-subnav-section] regions below;
                  the page script owns jumps, in-view highlighting and
@@ -235,14 +262,15 @@
                         <!-- Progress feed leads the page (plan 11 task
                              D): the agent's newest line first, its
                              history behind the "Show history"
-                             expander. ux-notes D: the event log link
-                             is per-task, not per-session - it rides
-                             here at top level instead of a Sessions
-                             table row. -->
+                             expander. ux-notes D + goal 41 T7: the
+                             event log is per-task, not per-session -
+                             it rides here at top level, and T7 makes
+                             it a same-tab toggle: the label checks the
+                             log radio, flipping the whole page into
+                             the log view without a navigation. -->
                         <div class="cluster cluster-between">
                             <h3>Progress</h3>
-                            <a stm-attribute.href="'/tasks/' + task.id + '/log'"
-                               target="_blank" rel="noopener">Event log</a>
+                            <label class="tab-link" for="task-view-log">Event log</label>
                         </div>
                         <pstm-fragment name="progress-feed" entries="task.progress"/>
                         <p stm-if="task.progress.length == 0" class="text-muted">No progress reports yet — the agent reports via
@@ -615,6 +643,29 @@
                         </details>
                     </section>
                 </div>
+            </div>
+                    </section>
+
+                    <!-- Goal 41 T7: the LOG panel - the shared shell.
+                         The partial binds the page's `task_log` slot
+                         (the entrypoint publishes it beside `task`);
+                         task_log.js mounts the SSE feed from the same
+                         [data-log-*] hooks it uses on the standalone
+                         page. The standalone route keeps its URL; the
+                         tab IS the same feed, so the natural loop
+                         (task -> log -> task) never leaves the page.
+                         The feed's internal 72vh scroller keeps deep
+                         history navigable inside the panel. -->
+                    <section class="task-log-panel" aria-label="Event log">
+                        <div class="cluster cluster-between">
+                            <p class="text-muted">the unified event ledger · newest at the bottom ·
+                                loads older history on demand</p>
+                            <span class="log-live" data-log-live><span class="live-dot"></span><span
+                                    data-live-label>live</span></span>
+                        </div>
+                        <pstm-fragment name="task-log-feed"/>
+                    </section>
+                </div>
             </div>
         </div>
     </section>

+ 9 - 28
src/pages/task_log.html

@@ -12,7 +12,14 @@
              title (the script fills data-log-title from the slot) and
              the live badge sit in ONE row - the reader always knows
              whose transcript this is and how to get back to its
-             action surface. -->
+             action surface.
+
+             Goal 41 T7 (structural bet 3): the interactive shell
+             itself moved into the task-log-feed partial - the SAME
+             shell now rides the task page's "Log" tab, so this route
+             keeps its URL (deep links, the timeline's log links)
+             while both surfaces stay one markup. The standalone page
+             remains the forensic full-window view of the same feed. -->
         <header class="log-header">
             <div class="log-title">
                 <p class="log-back"><a stm-attribute.href="task_log.task_url">&larr; Task page</a></p>
@@ -25,33 +32,7 @@
                         data-live-label>live</span></span>
             </div>
         </header>
-        <div stm-if="!task_log.exists" class="text-muted">No task exists with this id.</div>
-        <div stm-if="task_log.exists">
-            <section class="log-shell">
-                <!-- Goal 41 T5 (ux-notes event log A): the toolbar
-                     (filters + search) is STICKY inside the shell, so
-                     the controls survive deep scrolling through the
-                     feed - the position work lives in app.css's
-                     goal-41 log section; the DOM (and every filter
-                     behaviour) is unchanged. The jump-to-latest
-                     button rides the toolbar: the feed's own
-                     at-bottom/pinned state drives it from task_log.js. -->
-                <div class="log-toolbar">
-                    <span class="log-toolbar-label">Show</span>
-                    <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
-                    <button type="button" class="log-filter active" data-log-filter="results">results</button>
-                    <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
-                    <button type="button" class="log-filter active" data-log-filter="transitions">transitions</button>
-                    <button type="button" class="log-filter active" data-log-filter="usage">usage</button>
-                    <input type="search" data-log-search placeholder="Search events…">
-                    <button type="button" class="log-jump" data-log-jump hidden>Jump to latest</button>
-                </div>
-                <div class="log-feed" data-log-feed>
-                    <button type="button" class="log-more" data-log-more hidden>Load older events</button>
-                    <p class="text-muted log-empty">Waiting for the first events…</p>
-                </div>
-            </section>
-        </div>
+        <pstm-fragment name="task-log-feed"/>
     </div>
 </body>
 </html>

+ 51 - 0
src/pages/task_log_feed.html

@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <!-- A partial (goal 41 T7, ux-notes structural bet 3): the event
+         log's interactive shell - sticky toolbar, live feed, filters,
+         search, load-older - shared by TWO surfaces:
+
+         * the standalone /tasks/{task_id}/log page (task_log.html),
+           whose entrypoint publishes the `task_log` slot;
+         * the task page's embedded "Log" tab panel (task_detail.html),
+           whose entrypoint publishes the SAME slot shape alongside
+         `task`.
+
+         The including page supplies nothing: the shell binds the page's
+         `task_log` slot (exists / task_id / task_title / task_status /
+         task_url) and task_log.js - loaded once per page via
+         pstm-res-src - mounts everything from [data-log-*] hooks. The
+         surrounding page owns the context header (the standalone page
+         draws its own; the task page's tabs already name the task). -->
+    <pstm-name>task-log-feed</pstm-name>
+</head>
+<body>
+    <div stm-if="!task_log.exists" class="text-muted">No task exists with this id.</div>
+    <div stm-if="task_log.exists">
+        <section class="log-shell">
+            <!-- Goal 41 T5 (ux-notes event log A): the toolbar
+                 (filters + search) is STICKY inside the shell, so
+                 the controls survive deep scrolling through the
+                 feed - the position work lives in app.css's
+                 goal-41 log section; the DOM (and every filter
+                 behaviour) is unchanged. The jump-to-latest
+                 button rides the toolbar: the feed's own
+                 at-bottom/pinned state drives it from task_log.js. -->
+            <div class="log-toolbar">
+                <span class="log-toolbar-label">Show</span>
+                <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
+                <button type="button" class="log-filter active" data-log-filter="results">results</button>
+                <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
+                <button type="button" class="log-filter active" data-log-filter="transitions">transitions</button>
+                <button type="button" class="log-filter active" data-log-filter="usage">usage</button>
+                    <input type="search" data-log-search placeholder="Search events…">
+                <button type="button" class="log-jump" data-log-jump hidden>Jump to latest</button>
+            </div>
+            <div class="log-feed" data-log-feed>
+                <button type="button" class="log-more" data-log-more hidden>Load older events</button>
+                <p class="text-muted log-empty">Waiting for the first events…</p>
+            </div>
+        </section>
+    </div>
+</body>
+</html>

+ 2 - 1
test/bundles/__init__.py

@@ -5,10 +5,11 @@ order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
                lifecycle, notes, origins, pages, push, replay, resurrect,
-               stacks, subtasks, t4lists, users)
+               stacks, subtasks, t4lists, t7log, users)
 
 BUNDLES = {
     "pages": pages,
+    "t7log": t7log,
     "dispatch": dispatch,
     "subtasks": subtasks,
     "lineage": lineage,

+ 6 - 2
test/bundles/goals.py

@@ -484,8 +484,12 @@ def run(ctx):
           and 'stm-markdown="event.text"' in goal_page
           and 'stm-markdown="block.body"' in goal_page,
           "every long-form goal body renders through stm-markdown")
-    check('stm-attribute.href="run.terminal_url" target="_blank" rel="noopener"' in goal_page,
-          "the run table's terminal links open in a new tab")
+    # Goal 41 T7: the run table's log links navigate SAME-TAB (a new
+    # tab severed the back button; the log is one back-click away and
+    # the task page embeds the same feed as its Log tab).
+    check('stm-attribute.href="run.terminal_url"' in goal_page
+          and 'target="_blank"' not in goal_page.split("run.terminal_url")[1][:200],
+          "the run table's terminal links navigate in the same tab")
     check("progress-latest" in goal_page and "Agent progress" in goal_page,
           "the green progress feed renders on the goal page")
     # Plan revision history keeps its verdicts (approved + rejected).

+ 16 - 2
test/bundles/pages.py

@@ -731,9 +731,23 @@ def run(ctx):
     task_page_html = http_get_page(auth, "/tasks/%d" % dash_run)
     check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
           "the task page frames the description as the mandate")
-    check(re.search(r"'/tasks/' \+ task\.id \+ '/log'\"\s+target=\"_blank\"",
+    # Goal 41 T7 (ux-notes structural bet 3): the event log is the
+    # task page's second tab, not a new-tab navigation - the toggle is
+    # a <label> for the log radio, the shared shell rides the panel,
+    # and no log link on the page opens a new tab any more.
+    check('id="task-view-log"' in task_page_html and 'class="tabs task-tabs"' in task_page_html,
+          "the task page mounts the Overview|Log tabs")
+    check(re.search(r'<label class="tab-link" for="task-view-log">Event log</label>',
                     task_page_html) is not None,
-          "the task page's event log links open in a new tab")
+          "the task page's event log control toggles the log tab in place")
+    check(re.search(r"/log'\s*\"\s*target=\"_blank\"", task_page_html) is None,
+          "the task page no longer opens the event log in a new tab")
+    check('class="log-shell"' in task_page_html,
+          "the task page embeds the shared log shell (compiled in from the partial)")
+    task_log_state = slot_public(auth, "/tasks/%d" % dash_run, "task_log")
+    check(task_log_state.get("exists") is True
+          and int(task_log_state["task_id"]) == dash_run,
+          "the task page state also publishes the embedded log slot")
     log_state = slot_public(auth, "/tasks/%d/log" % dash_run, "task_log")
     check(log_state.get("exists") is True and int(log_state["task_id"]) == dash_run,
           "the event log page state names the run task")

+ 185 - 0
test/bundles/t7log.py

@@ -0,0 +1,185 @@
+"""The t7log bundle (goal 41 T7, ux-notes structural bet 3): the task
+page <-> event log unification. The two surfaces are ONE investigation
+flow: the task page mounts the log as its "Event log" tab (the same
+task-log-feed partial the standalone /tasks/{id}/log route renders),
+the entrypoint publishes both slots, and no link to the log opens a
+new tab any more.
+
+Fixture-driven like the pages bundle: a task + session are inserted as
+rows, events land through the push wire (/api/saddle/chunks), and the
+assertions read page HTML, entrypoint slot state and the SSE stream.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+# =============================================================================
+# Bundle: t7log — task page <-> event log unification.
+# =============================================================================
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== B1: the task page embeds the log as its second tab ==")
+    # A working task with one session, then a real event through the
+    # push wire (the same path the daemon writes).
+    conn = db()
+    conn.execute(
+        "insert into tasks (project_id, title, status, priority, created_at)"
+        " values (1, 'T7 unified log task', 'working', 5,"
+        " strftime('%s','now'))")
+    task_id = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute(
+        "insert into sessions (task_id, saddle_id, started_at)"
+        " values (?, ?, strftime('%s','now'))", (task_id, fake_id))
+    conn.commit()
+    session_id = conn.execute("select max(id) from sessions").fetchone()[0]
+    conn.close()
+
+    status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
+        "session_id": session_id,
+        "events": [{"seq": 101, "kind": "comment", "actor": "user:admin",
+                    "payload": {"body": "T7 probe comment",
+                                "author": "user:admin"}}]})
+    check(status == 200 and int(body.get("stored") or 0) == 1,
+          "the fixture event stores through the push wire")
+
+    # The tab markup: Spry's CSS tabs (radios first, then .tab-list
+    # labels, then .tab-panels), Overview checked by default.
+    task_page = http_get_page(auth, "/tasks/%d" % task_id)
+    check('class="tabs task-tabs"' in task_page,
+          "the task page carries the tab strip")
+    check(task_page.index('id="task-view-overview"')
+          < task_page.index('class="tab-list"')
+          < task_page.index('class="tab-panels"'),
+          "the radios lead the tab markup (Spry's nth-of-type contract)")
+    check('id="task-view-overview" checked' in task_page,
+          "Overview is the default (checked) tab")
+    check('<label for="task-view-overview">Overview</label>' in task_page
+          and '<label for="task-view-log">Event log</label>' in task_page,
+          "the tab list names Overview and Event log")
+    check('class="task-overview-panel"' in task_page
+          and 'class="task-log-panel"' in task_page,
+          "the panels carry the overview and the log")
+
+    print("== B2: one shared shell, both surfaces ==")
+    # The SAME partial ships both pages: the interactive hooks the
+    # client engine binds appear exactly once on each, and the
+    # compiled shell markup (the partial's) rides both.
+    task_page = http_get_page(auth, "/tasks/%d" % task_id)
+    log_page = http_get_page(auth, "/tasks/%d/log" % task_id)
+    for surface, name in ((task_page, "the task page"),
+                          (log_page, "the log page")):
+        check('class="log-shell"' in surface and "data-log-filter" in surface,
+              "%s renders the shared log shell" % name)
+    # Hook multiplicity: each interactive hook appears exactly as
+    # many times as the ONE shared shell carries it (5 filter
+    # buttons, 1 feed/more/search/jump) — proving neither surface
+    # ships a second, hand-copied shell.
+    for hook, times in (("data-log-feed", 1), ("data-log-more", 1),
+                        ("data-log-filter", 5), ("data-log-search", 1),
+                        ("data-log-jump", 1)):
+        check(task_page.count(hook) == times
+              and log_page.count(hook) == times,
+              "the %s hook appears exactly %d time(s) on both surfaces"
+              % (hook, times))
+
+    print("== B3: no log link opens a new tab any more ==")
+    # The Progress cluster's control is a <label> toggling the radio
+    # in place; no link element on the page navigates to the log
+    # route any more (comments may mention it, markup may not).
+    check('<label class="tab-link" for="task-view-log">Event log</label>'
+          in task_page,
+          "the Progress cluster's Event log control toggles the tab in place")
+    markup = task_page.split("<script>")[0] + "".join(
+        part.split("</script>", 1)[-1] for part in task_page.split("<script>")[1:])
+    check(re.search(r"<a[^>]+(\n[^>]*)*?/log", markup) is None,
+          "the task page markup carries no anchor to the log route")
+    check(re.search(r"/log['\"]", markup) is None,
+          "no log URL appears in any attribute (the tab is the only door)")
+
+    print("== B4: both slots publish from the task entrypoint ==")
+    # The embedded shell binds the page's task_log slot; it must name
+    # the same task the standalone route's slot does.
+    embedded = slot_public(auth, "/tasks/%d" % task_id, "task_log")
+    standalone = slot_public(auth, "/tasks/%d/log" % task_id, "task_log")
+    check(embedded.get("exists") is True
+          and int(embedded["task_id"]) == task_id,
+          "the task page entrypoint publishes the embedded log slot")
+    for field in ("exists", "task_id", "task_title", "task_status", "task_url"):
+        check(standalone.get(field) == embedded.get(field),
+              "the %s field is identical on both surfaces" % field)
+    check(embedded.get("task_title") == "T7 unified log task",
+          "the embedded slot names the task")
+    check(embedded.get("task_status") == "working",
+          "the embedded slot carries the live status the badge reads")
+
+    print("== B5: the standalone page keeps its context strip ==")
+    # The forensic route renders the T5 context header (back chevron +
+    # task title + live badge) and the not-found guard still works.
+    check('class="log-header"' in log_page
+          and 'data-log-title' in log_page
+          and 'data-log-live' in log_page,
+          "the standalone page keeps its one-row context header")
+    check('stm-attribute.href="task_log.task_url"' in log_page,
+          "the back chevron binds the task page URL from the slot")
+    missing = slot_public(auth, "/tasks/99999999/log", "task_log")
+    check(missing.get("exists") is False,
+          "a missing task's log slot says exists=false (both surfaces render their own note)")
+
+    print("== B6: the feed is reachable through both paths ==")
+    # The JSON page + SSE stream are task-scoped, not route-scoped:
+    # the embedded tab reads the same endpoints the standalone page
+    # does (one engine, one wire).
+    status, page_json = http("GET", "/tasks/%d/events" % task_id, timeout=15)
+    rows = json.loads(page_json)
+    check(status == 200
+          and any(e.get("payload", {}).get("body") == "T7 probe comment"
+                  for e in rows.get("events", [])),
+          "the events endpoint serves the fixture event")
+    request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % task_id)
+    with urllib.request.urlopen(request, timeout=15) as stream:
+        probe = None
+        deadline = time.monotonic() + 8
+        while time.monotonic() < deadline and probe is None:
+            line = stream.readline().decode().strip()
+            if not line.startswith("data: ") or line == "data: ping":
+                continue
+            event = json.loads(line[6:])
+            if (event.get("kind") == "comment"
+                    and "T7 probe comment" in
+                    (event.get("payload", {}).get("body") or "")):
+                probe = event
+        check(probe is not None,
+              "the SSE stream carries the fixture event to either surface")
+
+    print("== B7: the pulse refresh rebuilds BOTH slots ==")
+    # The refresh action (the page's sealed pulse) rebuilds the task
+    # slot AND the embedded task_log slot (the badge's status words
+    # stay truthful after a transition without its own poll). Drive
+    # it through the real action wire and read both back.
+    task_slot = task_state(auth, task_id)
+    refresh = task_slot.get("refresh")
+    check(refresh is not None, "the task slot carries its sealed refresh action")
+    if refresh:
+        act(auth, refresh, {})
+    # Flip the round's state through the push wire, refresh again,
+    # and the embedded slot must name the NEW status.
+    status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
+                           {"session_id": session_id, "state": "done",
+                            "summary": "T7 round settled"})
+    check(status == 200, "the round settles through the results push")
+    act(auth, task_state(auth, task_id)["refresh"], {})
+    settled = task_state(auth, task_id)
+    refreshed_log = slot_public(auth, "/tasks/%d" % task_id, "task_log")
+    check(refreshed_log.get("task_status") == settled.get("status"),
+          "the embedded log slot's status tracks the task slot after a pulse refresh")
+
+    print("== B8: the goal surface's run links navigate same-tab ==")
+    # goal_detail's run rows and header link dropped target=_blank
+    # (the goal page is T6's; only the log-link shape is T7's concern).
+    goal_page_shape = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                                        os.pardir, os.pardir,
+                                        "src", "pages", "goal_detail.html"),
+                           encoding="utf-8").read()
+    check('run.terminal_url" target="_blank"' not in goal_page_shape,
+          "the goal page's run links no longer open a new tab")

+ 1 - 1
test/harness.py

@@ -1362,7 +1362,7 @@ def start_bundle(name):
 
 
 
-BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
+BUNDLES_ORDER = ("pages", "t7log", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "answerwake", "ghostworking",
                  "turncappark", "modelconfig",
                  "push",

+ 12 - 3
test/test_task_log_js.py

@@ -34,6 +34,11 @@ 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")
+# Goal 41 T7: the interactive shell (toolbar + feed) lives in the
+# task-log-feed partial - shared by this page and the task page's
+# Log tab - so markup invariants are asserted against it.
+SHELL = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                     os.pardir, "src", "pages", "task_log_feed.html")
 
 
 def script_body():
@@ -97,10 +102,14 @@ class ScriptSourceTests(unittest.TestCase):
     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
+        # the pane. Search the shell 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]
+        # Goal 41 T7: the shell moved into the task-log-feed
+        # partial (task_log_feed.html) - the SAME markup the task
+        # page's Log tab embeds - so the invariant is asserted
+        # there.
+        with open(SHELL, encoding="utf-8") as handle:
+            markup = handle.read().split("</script>", 1)[-1]
         feed = markup.index("data-log-feed")
         more = markup.index("data-log-more", feed)
         close = markup.index("</div>", feed)