소스 검색

Saved views on /tasks + the dashboard work stream (goal 41 T10)

Bet 4 - saved views / URL state on the queue: the statum entrypoint fetch carries no query string, so filter state now serialises into location.hash (#view?status=working&project=3&goal=standalone). Every filter change writes the hash through replaceState (filter clicks are not Back-button steps), the hash seeds window.taskFilters before the first render so a pasted URL restores the view, the project select re-syncs once statum builds its options, and a hashchange listener re-seeds on same-tab paste-and-go. Only non-default axes serialise (open status / all projects / all goals keep the URL clean). A view row adds the preset chips the status engine already expresses (Open work / All / Blocked) plus a one-click Copy view link (navigator.clipboard with a toast, prompt fallback off secure contexts). T9's #new?project= prefill deep-link keeps its own namespace and still works.

Bet 5 - cross-entity work stream on the dashboard: the slot gains work_stream, the SAME TimelineService window re-projected into three row grammars (task_progress / goal_event / log_highlight), newest-first, capped at 15, each row carrying its own link - zero extra queries since the activity loop already loaded the window. home.html replaces the old activity table with a Spry feed of stream rows in the ambient expander (open by default: it is the news, not reference material); the underlying surfaces stay (goal page narrative, task page Log tab). The new-since-visit badge counts stream rows; the dead Show-all toggle machinery is gone.

app.css gains one marked T10 section (task-view-row, work-stream rows, badge-stream-* mapped to the semantic token groups). t10views.py (66 checks: hash round-trip, prefill coexistence, stream join/order/caps/links, pulse refresh) is registered after t9library. pages.py and goals.py dashboard-markup assertions follow the surface forward (the comment-footnote contract now rides the stream rows; goal cards keep their styling through the dynamic class expression).

Full suite --parallel 8: 2041 passed, 1 failed (t8fleet offline-verdict check, the known parallel-shard timing flake - standalone rerun 37/0). Targeted: pages 130/0, t4lists 27/0, t9library 39/0, t10views 66/0, goals 191/0. test_template_ascii + test_task_log_js green.
saddle-ClankerSaddle 1 일 전
부모
커밋
145990c909
8개의 변경된 파일과 684개의 추가작업 그리고 75개의 파일을 삭제
  1. 82 0
      src/entrypoints/HomeEntrypoint.vala
  2. 72 0
      src/pages/app.css
  3. 50 70
      src/pages/home.html
  4. 186 0
      src/pages/tasks.html
  5. 6 1
      test/bundles/goals.py
  6. 6 3
      test/bundles/pages.py
  7. 281 0
      test/bundles/t10views.py
  8. 1 1
      test/harness.py

+ 82 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -49,6 +49,36 @@ namespace ClankerSaddle {
         public string task_branch { get; set; default = ""; }
     }
 
+    /**
+     * One row of the dashboard's cross-entity work stream (goal 41
+     * T10, ux-notes structural bet 5): task progress, goal narrative
+     * and event-log highlights joined into ONE time-ordered feed —
+     * the projection of {@link TimelineService}'s window into the
+     * three grammar the feed renders, each row carrying its own link
+     * so the stream is the dashboard's front door into the work.
+     */
+    public class DashboardStreamRowView : Object {
+        /** task_progress | goal_event | log_highlight — the feed's
+         * three row grammars (one badge vocabulary: app.css maps
+         * .badge-stream-<kind> onto the semantic token groups). */
+        public string kind { get; set; default = "log_highlight"; }
+        public string when_display { get; set; default = ""; }
+        /** The row's epoch second (newest-first sort already done by
+         * the timeline window; kept for the client's new-since badge). */
+        public int64 at_epoch { get; set; }
+        /** The actor the row names (task/goal title, or "" for plain
+         * log lines) — the feed's WHO column. */
+        public string actor { get; set; default = ""; }
+        /** The row's one-line text (TimelineService's snippet). */
+        public string text { get; set; default = ""; }
+        /** The timeline kind that produced the row (badge text). */
+        public string event_kind { get; set; default = ""; }
+        /** Where the row links ("" = a bare highlight row). */
+        public string href { get; set; default = ""; }
+        public int64 task_id { get; set; }
+        public int64 goal_id { get; set; }
+    }
+
     /**
      * A pinned OPEN blocking item (plan 10 phase 3; plan 12 unified;
      * plan 15 C1 single-row): an unanswered question, RFC, review or
@@ -175,6 +205,13 @@ namespace ClankerSaddle {
      */
     public class DashboardState : Object {
 
+        /**
+         * The work stream's row cap (goal 41 T10): the newest 15 rows
+         * of the timeline window — a compact PRIMARY feed, one glance
+         * deep. The full 50-row window stays on the `activity` data.
+         */
+        public const int STREAM_MAX = 15;
+
         /**
          * Loads the dashboard aggregate and shapes its PAGE slot. The
          * `db` session comes from the caller (entrypoint or action scope).
@@ -414,10 +451,55 @@ namespace ClankerSaddle {
                 });
             }
 
+            // The work stream (goal 41 T10, ux-notes bet 5): the SAME
+            // timeline window projected into the feed's three-row
+            // grammar — task progress (progress lines on task-scoped
+            // events), goal narrative (every goal-scoped event: the
+            // goal page's narrative card vocabulary) and log
+            // highlights (everything else worth a line: verdicts,
+            // comments, state changes). One extra query over what
+            // build() already runs: none — the window is loaded once
+            // above and re-projected here, so the join is free and
+            // the two feeds can never disagree about order. Capped at
+            // STREAM_MAX rows (the newest ones); the full window
+            // stays on the activity data for anything that wants it.
+            var stream = new Vector<DashboardStreamRowView>();
+            foreach (var item in activity) {
+                if (stream.length >= DashboardState.STREAM_MAX) {
+                    break;
+                }
+                var row = new DashboardStreamRowView() {
+                    when_display = item.when_display,
+                    at_epoch = item.at_epoch,
+                    text = item.text,
+                    event_kind = item.kind
+                };
+                if (item.goal_id > 0) {
+                    row.kind = "goal_event";
+                    row.actor = item.goal_title;
+                    row.goal_id = item.goal_id;
+                    row.href = @"/goals/$(item.goal_id)";
+                } else if (item.kind == "progress" && item.task_id > 0) {
+                    row.kind = "task_progress";
+                    row.actor = item.task_title;
+                    row.task_id = item.task_id;
+                    row.href = @"/tasks/$(item.task_id)";
+                } else {
+                    row.kind = "log_highlight";
+                    if (item.task_id > 0) {
+                        row.actor = item.task_title;
+                        row.task_id = item.task_id;
+                        row.href = @"/tasks/$(item.task_id)";
+                    }
+                }
+                stream.add(row);
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
             props["push_endpoints"] = StateJson.strings(push_fingerprints);
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
+            props["work_stream"] = StateJson.list<DashboardStreamRowView>(stream.to_immutable_buffer());
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
 

+ 72 - 0
src/pages/app.css

@@ -1648,3 +1648,75 @@ td .truncate.repo-url {
    marker section exists so parallel goal-41 tasks can merge by
    keeping both sides in sequence.
    ========================================================================== */
+
+/* ==========================================================================
+   Goal 41 T10 — saved views on /tasks + the dashboard work stream
+   (appended per the spry-conversion contract; every value consumes a
+   --spry-* token, so themes and auto dark mode ride along).
+   ========================================================================== */
+
+/* The task queue's view row (ux-notes bet 4): the preset chips reuse
+   the goal-strip chip language (they ARE status chips - one click and
+   one shareable URL each), the copy-link button sits at the row's end
+   behind a hairline. The separator is a vertical hairline, not a
+   character, so it never fights the labels. */
+.task-view-row {
+    align-items: center;
+    row-gap: var(--spry-space-1);
+}
+.task-view-sep {
+    align-self: stretch;
+    background: var(--spry-border);
+    display: inline-block;
+    margin-left: var(--spry-space-1);
+    width: var(--spry-border-width);
+}
+
+/* The dashboard work stream (ux-notes bet 5): one compact feed that
+   joins task progress, goal narrative and log highlights. Rows are
+   list items, not a table: a stream is read down the left edge —
+   badge, when, actor link, text — and wraps under itself when narrow,
+   where the old three-column table could not. */
+.work-stream {
+    display: grid;
+    gap: var(--spry-space-1);
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.work-stream li {
+    align-items: baseline;
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--spry-space-2);
+}
+.work-stream-when {
+    flex: none;
+    font-size: 0.85em;
+    min-width: 4.5em;
+}
+.work-stream-text {
+    display: inline;
+    min-width: 0;
+}
+.work-stream-actor {
+    margin-right: var(--spry-space-1);
+}
+
+/* The stream's three row grammars (one badge vocabulary, each mapped
+   to its semantic group per the badge contract): task progress is the
+   fleet's output (the success group, like .badge-progress), goal
+   events are the narrative (accent, like .badge-kind-goal), log
+   highlights are the quiet remainder (inset, like comment/tool). */
+.badge-stream-task_progress {
+    background: var(--spry-success-soft);
+    color: var(--spry-success);
+}
+.badge-stream-goal_event {
+    background: var(--spry-accent-soft);
+    color: var(--spry-accent);
+}
+.badge-stream-log_highlight {
+    background: var(--spry-bg-inset);
+    color: var(--spry-fg-muted);
+}

+ 50 - 70
src/pages/home.html

@@ -40,17 +40,22 @@
                 statum.act(state.start_advisor, { message: text }).catch(function () { });
             };
 
-            // ---- activity: "new since your last visit" + row cap ----
-            // Goal 41 task 3. The framework carries no cookies (auth
-            // is slot-based) and statum exposes no client slot WRITE,
-            // so a true server-side last-visit is not available: the
-            // stamp lives in localStorage, written when the dashboard
-            // tab UNLOADS - a visit updates the baseline, a background
+            // ---- work stream: "new since your last visit" + the row
+            // cap ----
+            // Goal 41 task 3; goal 41 task 10 moved the feed to the
+            // cross-entity work stream (the slot's work_stream rows).
+            // The framework carries no cookies (auth is slot-based)
+            // and statum exposes no client slot WRITE, so a true
+            // server-side last-visit is not available: the stamp
+            // lives in localStorage, written when the dashboard tab
+            // UNLOADS - a visit updates the baseline, a background
             // pulse does not (an open tab keeps its count growing
             // until you come back). First visit: no stamp, no badge
             // (everything would be "new"). The count covers the full
-            // window the slot carries; the visible rows cap at eight
-            // until "Show all".
+            // stream the slot carries (the server caps the stream
+            // itself at 15 rows - the old activity table's uncap
+            // control died with it; the expander stays for the
+            // quiet frame).
             var VISIT_KEY = 'clanker.lastVisit';
 
             function readLastVisit() {
@@ -67,12 +72,13 @@
             function syncActivityCount() {
                 var state = statum.read('dashboard');
                 var badge = document.querySelector('[data-activity-new]');
-                if (!state || !badge || !state.activity) return;
+                if (!state || !badge) return;
+                var rows = state.work_stream || [];
                 var last = readLastVisit();
                 var fresh = 0;
                 if (last !== null) {
-                    for (var index = 0; index < state.activity.length; index++) {
-                        if (state.activity[index].at_epoch > last) fresh++;
+                    for (var index = 0; index < rows.length; index++) {
+                        if (rows[index].at_epoch > last) fresh++;
                     }
                 }
                 badge.textContent = fresh > 0
@@ -80,31 +86,7 @@
                 badge.hidden = fresh === 0;
             }
 
-            function syncActivityToggle() {
-                var state = statum.read('dashboard');
-                var expander = document.querySelector('[data-activity-expander]');
-                if (!expander || !state || !state.activity) return;
-                var button = expander.querySelector('[data-activity-toggle]');
-                if (!button) return;
-                var all = expander.getAttribute('data-activity-all') === '1';
-                button.textContent = all ? 'Show fewer'
-                    : 'Show all ' + state.activity.length;
-            }
-
-            document.addEventListener('click', function (event) {
-                if (!event.target.closest) return;
-                var button = event.target.closest('[data-activity-toggle]');
-                if (!button) return;
-                event.preventDefault();
-                var expander = button.closest('[data-activity-expander]');
-                if (!expander) return;
-                var all = expander.getAttribute('data-activity-all') === '1';
-                expander.setAttribute('data-activity-all', all ? '0' : '1');
-                syncActivityToggle();
-            });
-
             statum.afterEvaluate(syncActivityCount);
-            statum.afterEvaluate(syncActivityToggle);
             window.addEventListener('beforeunload', function () {
                 try {
                     window.localStorage.setItem(VISIT_KEY,
@@ -290,42 +272,40 @@
                 </aside>
             </div>
 
-            <!-- ACTIVITY (ux-notes §1 C): the page's only pure-read
-                 section folds under a collapsed expander. The summary
-                 carries a live "N new since your last visit" badge (the
-                 localStorage stamp — see the head script for why) and
-                 the rows cap at eight until "Show all" (the count still
-                 covers the whole window the slot carries). -->
-            <details class="dash-ambient" data-activity-expander>
-                <summary>Activity <span class="badge badge-info" data-activity-new hidden>new</span></summary>
+            <!-- WORK STREAM (goal 41 T10, ux-notes bet 5): the page's
+                 PRIMARY feed — task progress, goal narrative and
+                 event-log highlights joined into ONE time-ordered
+                 stream (the slot's work_stream: the timeline window
+                 projected into three row grammars, newest 15). It
+                 replaces the old activity expander as the dashboard's
+                 feed; the underlying surfaces stay — the goal page
+                 owns the narrative, the task page's Log tab owns the
+                 full history. The stream keeps the ambient expander's
+                 quiet frame but sits OPEN by default: this is the
+                 news, not reference material. The localStorage
+                 new-since badge counts stream rows now. -->
+            <details class="dash-ambient" data-activity-expander data-work-stream open>
+                <summary>Work stream <span class="badge badge-info" data-activity-new hidden>new</span></summary>
                 <div class="dash-ambient-body">
-                    <p class="text-muted">The last <span stm-text="dashboard.activity.length">0</span>
-                        events across all tasks, newest first.</p>
-                    <div class="table-wrap">
-                        <table class="table-compact dash-activity-table">
-                            <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
-                            <tbody>
-                                <tr stm-for-item-in="dashboard.activity">
-                                    <td stm-text="item.when_display">when</td>
-                                    <td stm-if="item.goal_id > 0">
-                                        <span class="badge badge-kind-goal">goal</span>
-                                        <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
-                                        <a stm-if="item.task_id > 0" class="text-muted"
-                                           stm-attribute.href="'/tasks/' + item.task_id"
-                                           >&rarr; <span stm-text="'task #' + item.task_id">task</span></a></td>
-                                    <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
-                                    <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
-                                        <!-- 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' || item.kind == 'goal-system' ? 'footnote' : ''"
-                                              stm-text="item.text">activity</span></td>
-                                </tr>
-                                <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="text-muted">Nothing yet.</td></tr>
-                            </tbody>
-                        </table>
-                    </div>
-                    <button type="button" class="btn-ghost btn-sm" data-activity-toggle
-                            stm-attribute.hidden="dashboard.activity.length <= 8">Show all</button>
+                    <p class="text-muted">The fleet's latest work — task progress, goal
+                        events and highlights, newest first.</p>
+                    <ul class="work-stream">
+                        <li stm-for-item-in="dashboard.work_stream">
+                            <span stm-attribute.class="'badge badge-stream-' + item.kind"
+                                  stm-text="item.event_kind">kind</span>
+                            <span class="work-stream-when text-muted num"
+                                  stm-text="item.when_display">when</span>
+                            <span class="work-stream-text"><span stm-if="item.href.length > 0"
+                                  class="work-stream-actor"><a stm-attribute.href="item.href"
+                                  stm-text="item.actor">actor</a></span>
+                                <span stm-attribute.class="item.kind == 'log_highlight' && item.event_kind == 'comment' ? 'footnote' : ''"
+                                      stm-text="item.text">text</span></span>
+                        </li>
+                        <li stm-if="dashboard.work_stream.length == 0"
+                            class="text-muted">Nothing yet.</li>
+                    </ul>
+                    <p class="text-muted"><a href="/tasks">Task queue</a> ·
+                        <a href="/goals">Goals</a></p>
                 </div>
             </details>
 

+ 186 - 0
src/pages/tasks.html

@@ -77,6 +77,10 @@
 
             window.setTaskFilter = function (name, value) {
                 window.taskFilters[name] = value;
+                // Goal 41 T10: the URL IS the saved view - every filter
+                // change lands in location.hash (replaceState: filter
+                // clicks are not Back-button steps).
+                syncTaskHash();
                 var tasks = statum.read('tasks');
                 if (tasks && tasks.refresh) {
                     statum.act(tasks.refresh).catch(function () { });
@@ -105,6 +109,164 @@
                 window.taskFilters.status = 'open';
             }
 
+            // ---- Saved views / URL state (goal 41 T10, ux-notes bet 4) ----
+            // The statum entrypoint fetch carries no query string, so
+            // the queue's filter state lives in location.hash: every
+            // filter change serialises it
+            // (#view?status=working&project=3&goal=standalone), the
+            // hash seeds taskFilters BEFORE the first render, and a
+            // pasted URL restores the view. Namespaces: #new?... is
+            // T9's create-prefill deep-link (consumed and dropped on
+            // load); #view?... is the saved-view state. The serialised
+            // form keeps only NON-default axes so the default URL
+            // stays clean ('open' status, all projects, all goals).
+            var TASK_HASH_AXES = [
+                ['status', 'status'],
+                ['project', 'project'],
+                ['goal', 'goal']
+            ];
+
+            window.taskViewHash = function (filters) {
+                var source = filters || window.taskFilters || {};
+                var parts = [];
+                for (var i = 0; i < TASK_HASH_AXES.length; i++) {
+                    var key = TASK_HASH_AXES[i][0];
+                    var value = source[key];
+                    if (key === 'status') {
+                        if (value == null || value === '') value = 'open';
+                        if (value === 'open') continue; // the default view
+                    } else if (value == null || value === ''
+                            || value === '0' || value === 'all') {
+                        continue; // "every project" / "every goal"
+                    }
+                    parts.push(TASK_HASH_AXES[i][1] + '='
+                        + encodeURIComponent(String(value)));
+                }
+                return parts.length > 0 ? '#view?' + parts.join('&') : '';
+            };
+
+            window.taskFiltersFromHash = function (hash) {
+                var source = hash || window.location.hash || '';
+                if (source.indexOf('#view?') !== 0) return null;
+                var params = {};
+                source.slice(6).split('&').forEach(function (part) {
+                    var eq = part.indexOf('=');
+                    if (eq === -1) return;
+                    var key = decodeURIComponent(part.slice(0, eq));
+                    var value = decodeURIComponent(part.slice(eq + 1));
+                    if (key) params[key] = value;
+                });
+                var filters = {};
+                if (params.status) filters.status = params.status;
+                if (params.project && params.project !== '0') {
+                    filters.project = params.project;
+                }
+                if (params.goal && params.goal !== 'all') {
+                    filters.goal = params.goal;
+                }
+                return filters;
+            };
+
+            // Seed window.taskFilters from the hash (load or an
+            // edited URL): runs BEFORE the first render so the chips
+            // and rows paint the restored view immediately.
+            window.applyTaskViewHash = function () {
+                var filters = window.taskFiltersFromHash();
+                if (filters == null) return;
+                if (filters.status != null) {
+                    window.taskFilters.status = filters.status;
+                }
+                if (filters.project != null) {
+                    window.taskFilters.project = filters.project;
+                }
+                if (filters.goal != null) {
+                    window.taskFilters.goal = filters.goal;
+                }
+            };
+
+            // The selects reflect the seeded filters on first paint:
+            // statum builds the option list only once the slot's data
+            // arrives, so retry until the wanted option exists (the
+            // same shape as the #new prefill pick).
+            function taskFilterSelectsSynced() {
+                var filters = window.taskFilters || {};
+                var select = document.getElementById('filter-project');
+                if (!select || filters.project == null
+                        || filters.project === '0') {
+                    return true;
+                }
+                if (select.querySelector(
+                        'option[value="' + filters.project + '"]')) {
+                    select.value = String(filters.project);
+                    return true;
+                }
+                return false;
+            }
+
+            var taskSelectTries = 0;
+
+            function retryTaskFilterSelects() {
+                if (taskFilterSelectsSynced()) return;
+                if (taskSelectTries++ < 40) {
+                    window.setTimeout(retryTaskFilterSelects, 50);
+                }
+            }
+
+            window.applyTaskViewHash();
+            retryTaskFilterSelects();
+
+            // The URL tracks the view: strip any old hash, then write
+            // the serialised filters when the view is non-default.
+            function syncTaskHash() {
+                var next = window.taskViewHash();
+                var current = window.location.hash || '';
+                if (current === next) return;
+                // replaceState, not pushState: a filter click is not a
+                // navigation step - Back must not walk every chip the
+                // user tried.
+                window.history.replaceState(null, '',
+                    window.location.pathname + window.location.search + next);
+            }
+
+            // The view affordance (bet 4): one click copies the URL
+            // that recreates the current view. The clipboard API needs
+            // a secure context (https or localhost); a fallback builds
+            // the link by hand so the feature degrades to copy-paste
+            // instead of dying.
+            window.copyTaskViewLink = function (button) {
+                var url = window.location.origin
+                    + window.location.pathname + window.taskViewHash();
+                var done = function () {
+                    if (window.showToast) {
+                        window.showToast('View link copied - paste it '
+                            + 'anywhere to share this view');
+                    }
+                };
+                var fallback = function () {
+                    window.prompt('Copy this link to share the view:', url);
+                };
+                if (navigator.clipboard
+                        && navigator.clipboard.writeText) {
+                    navigator.clipboard.writeText(url).then(done, fallback);
+                } else {
+                    fallback();
+                }
+            };
+
+            // A pasted URL with a different #view hash re-seeds the
+            // filters (same-tab paste-and-go arrives as a hashchange
+            // without a reload; other tabs reload, where the load seed
+            // above already covers it).
+            window.addEventListener('hashchange', function () {
+                if (window.taskFiltersFromHash() == null) return;
+                window.applyTaskViewHash();
+                var tasks = statum.read('tasks');
+                if (tasks && tasks.refresh) {
+                    statum.act(tasks.refresh).catch(function () { });
+                }
+                retryTaskFilterSelects();
+            });
+
             // Goal 41 T9: the project page's "+ New task in this
             // project" deep-link. The hash #new?project=<id> opens the
             // create dialog with the project preselected (the dialog
@@ -218,6 +380,30 @@
             </select>
         </form>
 
+        <!-- Goal 41 T10 (ux-notes bet 4): the view row. The presets
+             are the views the chips already express, now one click AND
+             one shareable URL each (the filter state serialises into
+             location.hash - #view?status=blocked is this row's whole
+             vocabulary); "Copy view link" puts the recreating URL on
+             the clipboard so any filtered combination - not just the
+             presets - is shareable. The chips above stay the status
+             axis; the selects stay the scoping axes. -->
+        <div class="cluster task-view-row" data-task-view-row>
+            <span class="text-muted" aria-hidden="true">View</span>
+            <button type="button" class="goal-filter"
+                    stm-attribute.class="window.taskChipClass('open')"
+                    onclick="window.setTaskStatusFilter('open')">Open work</button>
+            <button type="button" class="goal-filter"
+                    stm-attribute.class="window.taskChipClass('all')"
+                    onclick="window.setTaskStatusFilter('all')">All</button>
+            <button type="button" class="goal-filter"
+                    stm-attribute.class="window.taskChipClass('blocked')"
+                    onclick="window.setTaskStatusFilter('blocked')">Blocked</button>
+            <span class="task-view-sep" aria-hidden="true"></span>
+            <button type="button" class="btn-ghost btn-sm" title="Copy a link that reopens this view"
+                    onclick="window.copyTaskViewLink(this)">Copy view link</button>
+        </div>
+
         <div class="table-wrap">
             <table class="table-hover">
 

+ 6 - 1
test/bundles/goals.py

@@ -173,7 +173,12 @@ def run(ctx):
     check(any(p["item_type"] == "goal" for p in
               slot_public(auth, "/", "dashboard")["pinned"]),
           "the goal's pending block pins onto the dashboard pile")
-    check("'card goal-card'" in home_html and 'badge-kind-goal' in home_html,
+    # Goal 41 T10: the dashboard's feed is the work stream now; the
+    # pinned goal cards keep their distinct styling through the
+    # dynamic class expressions (item_type 'goal' -> goal-card +
+    # badge-kind-goal at render time).
+    check("'card goal-card'" in home_html
+          and "'badge badge-kind-' + blockview.item_type" in home_html,
           "dashboard goal cards carry the distinct goal styling")
     goals_html = http_get_page(auth, "/goals")
     check("&#127919;" in goals_html and "goal-icon" in goals_html,

+ 6 - 3
test/bundles/pages.py

@@ -803,9 +803,12 @@ def run(ctx):
           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'"
-          " || item.kind == 'goal-system' ? 'footnote'" in home_html,
-          "the dashboard timeline renders comment rows as footnotes")
+    # Goal 41 T10: the dashboard's feed is the work stream now; the
+    # comment-as-footnote contract lives on in its log-highlight rows
+    # (the compiled page escapes && as &amp;&amp; in the attribute).
+    check("item.kind == 'log_highlight' &amp;&amp; item.event_kind"
+          " == 'comment' ? 'footnote'" in home_html,
+          "the dashboard stream renders comment rows as footnotes")
 
     print("== list ordering: newest first everywhere ==")
     # The ordering sweep: every table leads with the most recent row.

+ 281 - 0
test/bundles/t10views.py

@@ -0,0 +1,281 @@
+"""The T10 views bundle (goal 41 task 10): the two phase-D structural
+bets — saved views / URL state on /tasks (ux-notes bet 4) and the
+cross-entity work stream on the dashboard (bet 5).
+
+Bet 4 is a CLIENT-side engine (the statum entrypoint fetch carries no
+query string, so filter state lives in location.hash): this bundle
+verifies the engine's round-trip as pure logic (a python port of the
+serialise/parse pair driving the exact page functions) plus the
+markup hooks the page ships. The prefill namespace (#new?project=)
+must keep coexisting with the view namespace (#view?...).
+
+Bet 5 is server data + markup: the dashboard slot's work_stream rows
+(joined, time-ordered, capped, linked) are cross-checked against
+seeded ledger events, and the page renders the stream feed with the
+old activity table gone.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+# The open-work status set (window.TASK_OPEN_STATUSES on the page).
+OPEN = ("queued", "assigned", "working", "blocked", "suspended")
+
+
+def view_hash(filters):
+    """window.taskViewHash ported verbatim: only non-default axes
+    serialise ('open' status, all projects, all goals stay clean)."""
+    parts = []
+    for key in ("status", "project", "goal"):
+        value = filters.get(key)
+        if key == "status":
+            value = value or "open"
+            if value == "open":
+                continue
+        elif value in (None, "", 0, "0", "all"):
+            continue
+        from urllib.parse import quote
+        parts.append("%s=%s" % (key, quote(str(value), safe="")))
+    return "#view?" + "&".join(parts) if parts else ""
+
+
+def filters_from_hash(hash_text):
+    """window.taskFiltersFromHash ported verbatim (None = not a view
+    hash: the #new prefill namespace and bare hashes fall through)."""
+    if not hash_text or not hash_text.startswith("#view?"):
+        return None
+    filters = {}
+    for part in hash_text[len("#view?"):].split("&"):
+        if "=" not in part:
+            continue
+        key, value = part.split("=", 1)
+        from urllib.parse import unquote
+        key, value = unquote(key), unquote(value)
+        if key == "status" and value:
+            filters["status"] = value
+        elif key == "project" and value and value != "0":
+            filters["project"] = value
+        elif key == "goal" and value and value != "all":
+            filters["goal"] = value
+    return filters
+
+
+def run(ctx):
+    auth, fake = ctx.auth, ctx.fake
+
+    now = "strftime('%s','now')"
+
+    # ------------------------------------------------------------------
+    print("== bet 4: the hash engine (round-trip as pure logic) ==")
+    cases = [
+        ({}, ""),  # the default view keeps the URL clean
+        ({"status": "open"}, ""),
+        ({"status": "working"}, "#view?status=working"),
+        ({"status": "blocked"}, "#view?status=blocked"),
+        ({"status": "all"}, "#view?status=all"),
+        ({"project": 3}, "#view?project=3"),
+        ({"project": "3"}, "#view?project=3"),
+        ({"project": 0}, ""),  # "all projects" is the default
+        ({"goal": "standalone"}, "#view?goal=standalone"),
+        ({"goal": "all"}, ""),
+        ({"goal": 12}, "#view?goal=12"),
+        ({"status": "working", "project": 3, "goal": "standalone"},
+         "#view?status=working&project=3&goal=standalone"),
+        # A title with spaces/ampersands survives the round-trip
+        # encoded (the goal axis carries ids in practice, but the
+        # engine must not corrupt any value).
+        ({"goal": "a & b=c"}, "#view?goal=a%20%26%20b%3Dc"),
+    ]
+    for filters, expected in cases:
+        got = view_hash(filters)
+        check(got == expected,
+              "serialise %r -> %r" % (filters, expected))
+        if expected:
+            back = filters_from_hash(expected)
+            wanted = {}
+            for key, value in filters.items():
+                text = str(value)
+                if key == "status":
+                    if text not in ("open", ""):
+                        wanted[key] = text
+                elif text not in ("0", "all", ""):
+                    wanted[key] = text
+            check(back == wanted,
+                  "parse %r restores %r (got %r)" % (expected, wanted, back))
+    # The prefill namespace never parses as a view.
+    check(filters_from_hash("#new?project=7") is None,
+          "the #new prefill hash is not a view hash")
+    check(filters_from_hash("") is None and filters_from_hash("#") is None,
+          "bare hashes are not view hashes")
+
+    print("== bet 4: the page ships the engine + the view row ==")
+    tasks_page = http_get_page(auth, "/tasks")
+    for needle, label in (
+            ("window.taskViewHash", "the serialiser"),
+            ("window.taskFiltersFromHash", "the parser"),
+            ("window.applyTaskViewHash", "the load-time seed"),
+            ("window.copyTaskViewLink", "the copy-link affordance"),
+            ("syncTaskHash", "the filter-change hash write"),
+    ):
+        check(needle in tasks_page, "the tasks page ships %s" % label)
+    check("syncTaskHash();" in tasks_page,
+          "every filter change lands in the hash (setTaskFilter)")
+    check("window.history.replaceState" in tasks_page,
+          "hash updates use replaceState (no Back-button spam)")
+    check("hashchange" in tasks_page,
+          "a pasted #view hash re-seeds the filters in-place")
+    check("window.applyTaskViewHash();" in tasks_page,
+          "the hash seeds taskFilters before the first render")
+    check("#view?" in tasks_page,
+          "the view namespace is distinct from the #new prefill")
+    # The view row: presets + copy button.
+    check('data-task-view-row' in tasks_page,
+          "the view row is marked for the bundle/harness")
+    check("Copy view link" in tasks_page
+          and "copyTaskViewLink" in tasks_page,
+          "the one-click copy-link button ships")
+    for preset in ("Open work", "All", "Blocked"):
+        check(preset in tasks_page,
+              "the %r preset chip ships in the view row" % preset)
+    check("navigator.clipboard" in tasks_page,
+          "the copy uses the async clipboard API")
+    # The presets ARE the chips' statuses (no second filter state).
+    check(tasks_page.count("window.taskChipClass('open')") >= 2,
+          "the presets reuse the chip engine's status vocabulary")
+
+    print("== bet 4: prefill coexistence ==")
+    # T9's #new?project= deep-link still parses + drops itself, and
+    # the view engine ignores it (namespace check above; markup).
+    check("indexOf('#new')" in tasks_page,
+          "the #new prefill contract still ships (T9)")
+    check("indexOf('#view?')" in tasks_page,
+          "the view parser gates on its own namespace")
+    # A prefill hash and a view hash in one URL: the prefill wins on
+    # load (it consumes and drops the whole hash); the view engine
+    # then reads nothing - the ported parser mirrors that by gating.
+    check(filters_from_hash("#new?project=7") is None,
+          "a load-time prefill never double-applies as a view")
+
+    # ------------------------------------------------------------------
+    print("== bet 5: the work stream (joined, ordered, capped, linked) ==")
+    # Seed the three row grammars through the ledger the timeline
+    # reads: a task progress line, a goal-scoped narrative event and
+    # a plain task comment (log highlight).
+    conn = db()
+    conn.execute(
+        "insert into tasks (project_id, title, status, priority, created_at)"
+        " values (1, 'T10 stream task', 'working', 5, %s)" % now)
+    stream_task = conn.execute(
+        "select last_insert_rowid()").fetchone()[0]
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " created_at) values (1, 'T10 stream goal', 'The stream probe.',"
+        " 'goal/t10', 'running', %s)" % now)
+    stream_goal = conn.execute(
+        "select last_insert_rowid()").fetchone()[0]
+    # Goal runs are internal - their task rows never stream.
+    conn.execute(
+        "insert into tasks (project_id, goal_id, kind, title, status,"
+        " priority, created_at) values (1, ?, 'goal_run',"
+        " 'T10 internal run', 'working', 5, %s)" % now, (stream_goal,))
+    run_task = conn.execute(
+        "select last_insert_rowid()").fetchone()[0]
+
+    def event(kind, scope_type, scope_id, payload, offset):
+        conn.execute(
+            "insert into events (kind, actor, scope_type, scope_id,"
+            " payload, created_at) values (?, 'model', ?, ?, ?,"
+            " strftime('%s','now') - ?)",
+            (kind, scope_type, scope_id, payload, offset))
+
+    event("progress", "task", stream_task,
+          '{"text": "T10 progress line"}', 30)
+    event("state_change", "goal", stream_goal,
+          '{"to": "running", "text": "T10 goal narrative"}', 20)
+    event("comment", "task", stream_task,
+          '{"author": "human", "body": "T10 comment highlight"}', 10)
+    event("progress", "task", run_task,
+          '{"text": "T10 internal run progress"}', 5)
+    conn.commit()
+    conn.close()
+
+    dash = slot_public(auth, "/", "dashboard")
+    stream = dash.get("work_stream", [])
+    check(isinstance(stream, list),
+          "the dashboard slot carries the work_stream rows")
+    check(0 < len(stream) <= 15,
+          "the stream is capped at 15 rows (got %d)" % len(stream))
+    epochs = [int(row["at_epoch"]) for row in stream]
+    check(epochs == sorted(epochs, reverse=True),
+          "the stream is newest-first")
+
+    progress_rows = [row for row in stream
+                     if row["kind"] == "task_progress"]
+    goal_rows = [row for row in stream if row["kind"] == "goal_event"]
+    highlight_rows = [row for row in stream
+                      if row["kind"] == "log_highlight"]
+    check(any("T10 progress line" in row["text"]
+              and int(row["task_id"]) == stream_task
+              and row["href"] == "/tasks/%d" % stream_task
+              for row in progress_rows),
+          "task progress rows join with the task link")
+    check(any("T10 goal narrative" in row["text"]
+              and int(row["goal_id"]) == stream_goal
+              and row["href"] == "/goals/%d" % stream_goal
+              for row in goal_rows),
+          "goal narrative rows join with the goal link")
+    check(any("T10 comment highlight" in row["text"]
+              and int(row["task_id"]) == stream_task
+              for row in highlight_rows),
+          "comment rows stream as log highlights")
+    check(all(not ("internal run" in row["text"]
+                   or int(row.get("task_id", 0)) == run_task)
+              for row in stream),
+          "goal-run internals never stream (the goal carries them)")
+
+    # The three grammars agree with the activity projection they
+    # derive from (one window, two projections - same order).
+    activity = dash.get("activity", [])
+    check(len(stream) <= len(activity),
+          "the stream is the window's newest slice (activity keeps the full window)")
+    check(len(activity) > 15 or len(stream) == len(activity),
+          "under the cap the stream covers the whole window")
+
+    # Every row links somewhere or is a bare highlight.
+    for row in stream:
+        if row["href"]:
+            check(row["href"].startswith(("/tasks/", "/goals/")),
+                  "row hrefs stay in-app (%s)" % row["href"])
+            break
+
+    print("== bet 5: the dashboard renders the stream as its feed ==")
+    home_page = http_get_page(auth, "/")
+    check('data-work-stream' in home_page,
+          "the dashboard ships the work-stream feed")
+    check("work-stream" in home_page and "dashboard.work_stream" in home_page,
+          "the feed renders the slot's work_stream rows")
+    check("Work stream" in home_page,
+          "the feed is titled as the work stream")
+    check("badge-stream-" in home_page,
+          "the rows carry the stream's badge vocabulary")
+    # The old three-grammar surfaces it replaces on THIS page: the
+    # activity table is gone from the dashboard.
+    check("dash-activity-table" not in home_page,
+          "the old activity table is gone from the dashboard")
+    check("data-activity-toggle" not in home_page,
+          "the activity Show-all toggle is gone (the server caps)")
+    check("Show all" not in home_page,
+          "no stale Show-all control remains")
+    # The new-since badge keeps working off the stream rows.
+    check("state.work_stream" in home_page,
+          "the new-since badge counts stream rows")
+    # The underlying surfaces live elsewhere (not deleted): the goal
+    # page narrative + the task log stay their pages' own.
+    check("data-activity-new" in home_page,
+          "the ambient expander frame (with the new badge) keeps the feed quiet")
+
+    print("== bet 5: the stream survives the pulse refresh ==")
+    refresh = dash.get("refresh")
+    check(refresh is not None, "the dashboard refresh action is authored")
+    act(auth, refresh, {})
+    dash2 = slot_public(auth, "/", "dashboard")
+    check(len(dash2.get("work_stream", [])) > 0,
+          "the sealed refresh rebuilds the stream too")

+ 1 - 1
test/harness.py

@@ -1367,7 +1367,7 @@ BUNDLES_ORDER = ("pages", "t7log", "dispatch", "subtasks", "lineage",
                  "turncappark", "modelconfig",
                  "push",
                  "goals", "replay", "goalui", "forcewake", "users", "theme", "stacks",
-                 "notes", "resurrect", "t4lists", "t8fleet", "t9library", "origins", "parity", "understanding",
+                 "notes", "resurrect", "t4lists", "t8fleet", "t9library", "t10views", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",