Commit 6e780d36cd0cd9455a627859c7f6de329df435bd

Authored by graphlemon
2 parents 0e43e9bc ad58f67c

graphlemon: merge a8ae4a00-b3eb-431c-99a5-5eb6d927c00d

frontend/app.js
... ... @@ -115,13 +115,36 @@ function registerRouteCleanup(fn) {
115 115 currentRouteCleanup = fn;
116 116 }
117 117  
  118 +// U-047: `#/tasks?status=done`-style query strings on hash routes -- a new
  119 +// capability, not a bug fix (no route previously carried a query string).
  120 +// Hand-rolled instead of `new URLSearchParams(...)` (which mountTaskBoard's
  121 +// own outgoing-request building already uses) because this one also has to
  122 +// run inside frontend/test_*_smoke.js's bare `vm.createContext` sandbox,
  123 +// which has no WHATWG URL globals -- decodeURIComponent is a plain
  124 +// ECMAScript global and works there unmodified.
  125 +function parseHashQuery(hash) {
  126 + const qIndex = hash.indexOf("?");
  127 + if (qIndex === -1) return { path: hash, params: {} };
  128 + const path = hash.slice(0, qIndex);
  129 + const params = {};
  130 + for (const pair of hash.slice(qIndex + 1).split("&")) {
  131 + if (!pair) continue;
  132 + const eqIndex = pair.indexOf("=");
  133 + const key = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
  134 + const value = eqIndex === -1 ? "" : pair.slice(eqIndex + 1);
  135 + params[decodeURIComponent(key)] = decodeURIComponent(value);
  136 + }
  137 + return { path, params };
  138 +}
  139 +
118 140 function render() {
119 141 if (currentRouteCleanup) {
120 142 currentRouteCleanup();
121 143 currentRouteCleanup = null;
122 144 }
123 145  
124   - const hash = window.location.hash || "#/";
  146 + const rawHash = window.location.hash || "#/";
  147 + const { path: hash, params: queryParams } = parseHashQuery(rawHash);
125 148 let route = routes[hash];
126 149 let routeArgs = [];
127 150 if (!route) {
... ... @@ -154,16 +177,115 @@ function render() {
154 177 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
155 178  
156 179 document.getElementById("app").innerHTML = route.render(...routeArgs);
157   - if (route.mount) route.mount(...routeArgs);
158   -
159   - for (const a of document.querySelectorAll("#nav a")) {
160   - a.classList.toggle("active", a.getAttribute("href") === hash);
  180 + // queryParams always appended after any route-specific captures -- every
  181 + // mount() so far only declares the params it actually reads (e.g.
  182 + // mountTaskBoard(queryParams)), so this trailing arg is silently ignored
  183 + // by the rest (plain JS behavior, not a special case per route).
  184 + if (route.mount) route.mount(...routeArgs, queryParams);
  185 +
  186 + // `#nav > a`, not `#nav a`: nav_project_context (U-048) nests its own
  187 + // <a> links (project name, status-bucket counts) directly inside #nav --
  188 + // those must never get the same "active" bolding as the fixed route
  189 + // links above them just because their target route happens to match.
  190 + for (const a of document.querySelectorAll("#nav > a")) {
  191 + a.classList.toggle("active", parseHashQuery(a.getAttribute("href")).path === hash);
161 192 }
  193 + refreshNavProjectContext();
162 194 }
163 195  
164 196 window.addEventListener("hashchange", render);
165 197 window.addEventListener("DOMContentLoaded", render);
166 198  
  199 +// ---- nav bar "current project" context (U-048) ----
  200 +// All pages share #nav (index.html), so this is wired into render() itself
  201 +// (called once per hashchange/initial load, see above) rather than into any
  202 +// one page's mount() -- "piggybacks on the existing hashchange listener, no
  203 +// new event plumbing needed" per the approved SDD. Reuses
  204 +// `GET /api/tasks?project_id=...` and reduces status counts client-side
  205 +// (one fetch per refresh, not one per bucket) -- a dedicated aggregation
  206 +// endpoint is a documented revisit if a single project ever reaches
  207 +// ~1000-2000 tasks.
  208 +
  209 +// 10 raw task.status values (TASK_STATUSES below, v_task_board) collapsed
  210 +// into the 4 buckets the nav bar shows. `statuses.length === 1` buckets
  211 +// (pending/done) link to "#/tasks?status=<value>" -- v_task_board's status
  212 +// filter is single-select, so multi-status buckets (running/attention) link
  213 +// to the unfiltered "#/tasks" instead of picking one arbitrary status.
  214 +const NAV_STATUS_BUCKETS = [
  215 + { key: "pending", label: "pending", statuses: ["queued"] },
  216 + { key: "running", label: "running", statuses: [
  217 + "planning_brd_prd_sdd", "coding", "validating", "routed_fast_lane", "routed_full_replan",
  218 + ] },
  219 + { key: "attention", label: "attention", statuses: [
  220 + "planning_failed", "coding_failed", "blocked_pending_human",
  221 + ] },
  222 + { key: "done", label: "done", statuses: ["done"] },
  223 +];
  224 +
  225 +function navBucketHref(bucket) {
  226 + return bucket.statuses.length === 1 ? "#/tasks?status=" + bucket.statuses[0] : "#/tasks";
  227 +}
  228 +
  229 +function computeNavStatusBucketCounts(tasks) {
  230 + const statusToBucketKey = {};
  231 + for (const bucket of NAV_STATUS_BUCKETS) {
  232 + for (const status of bucket.statuses) statusToBucketKey[status] = bucket.key;
  233 + }
  234 + const counts = {};
  235 + for (const bucket of NAV_STATUS_BUCKETS) counts[bucket.key] = 0;
  236 + for (const task of tasks) {
  237 + const bucketKey = statusToBucketKey[task.status];
  238 + if (bucketKey) counts[bucketKey]++;
  239 + }
  240 + return counts;
  241 +}
  242 +
  243 +function renderNavProjectContextHtml(project, counts) {
  244 + if (!project) return "";
  245 + const countsHtml = NAV_STATUS_BUCKETS.map((bucket) =>
  246 + '<a class="nav-count nav-count-' + bucket.key + '" href="' + navBucketHref(bucket) + '">'
  247 + + escapeHtml(bucket.label) + " " + (counts[bucket.key] || 0) + "</a>"
  248 + ).join("");
  249 + return (
  250 + '<a id="nav_project_name" href="#/projects">' + escapeHtml(project.name) + "</a>"
  251 + + '<span class="nav-counts">' + countsHtml + "</span>"
  252 + );
  253 +}
  254 +
  255 +// Guards against out-of-order responses: if the current project is switched
  256 +// (or this fires again before an earlier fetch resolves), a stale response
  257 +// landing after a newer one must not overwrite the nav with old data.
  258 +let navProjectContextRequestId = 0;
  259 +
  260 +async function refreshNavProjectContext() {
  261 + const contextEl = document.getElementById("nav_project_context");
  262 + if (!contextEl) return;
  263 + const projectId = getCurrentProjectId();
  264 + const requestId = ++navProjectContextRequestId;
  265 + if (!projectId) {
  266 + // Requirement 1: no project selected -> render nothing, not a hint.
  267 + contextEl.innerHTML = "";
  268 + return;
  269 + }
  270 + try {
  271 + const [projectResp, tasksResp] = await Promise.all([
  272 + fetch("/api/projects/" + encodeURIComponent(projectId)),
  273 + fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)),
  274 + ]);
  275 + if (requestId !== navProjectContextRequestId) return; // superseded by a newer refresh
  276 + if (projectResp.status !== 200) {
  277 + contextEl.innerHTML = "";
  278 + return;
  279 + }
  280 + const project = await projectResp.json();
  281 + const tasks = tasksResp.status === 200 ? await tasksResp.json() : [];
  282 + if (requestId !== navProjectContextRequestId) return;
  283 + contextEl.innerHTML = renderNavProjectContextHtml(project, computeNavStatusBucketCounts(tasks));
  284 + } catch (e) {
  285 + if (requestId === navProjectContextRequestId) contextEl.innerHTML = "";
  286 + }
  287 +}
  288 +
167 289 // ---- v_project_list (2026-08-03): project list + CRUD ----
168 290 // 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an
169 291 // MVP non-goal -- this page is a deliberate, explicit scope expansion
... ... @@ -885,7 +1007,21 @@ function renderTaskBoard() {
885 1007 `;
886 1008 }
887 1009  
888   -function mountTaskBoard() {
  1010 +// U-047/U-048: nav-bar status-bucket links land on #/tasks as
  1011 +// "?status=<value>" -- this picks the status to pre-select, on initial
  1012 +// mount only (same reasoning as v_task_board's other filters: it's a
  1013 +// starting value the user can then change, not re-applied on every route
  1014 +// re-render). An unrecognized/stale status value is ignored rather than
  1015 +// left in a select the <option> list doesn't actually have. Split out as
  1016 +// its own pure function so it's testable without a real <select> element.
  1017 +function pickInitialTaskBoardStatus(queryParams) {
  1018 + if (queryParams && queryParams.status && TASK_STATUSES.includes(queryParams.status)) {
  1019 + return queryParams.status;
  1020 + }
  1021 + return null;
  1022 +}
  1023 +
  1024 +function mountTaskBoard(queryParams) {
889 1025 const projectId = getCurrentProjectId();
890 1026 if (!projectId) return;
891 1027 const rowsEl = document.getElementById("tb_rows");
... ... @@ -894,6 +1030,9 @@ function mountTaskBoard() {
894 1030 const orgNodeEl = document.getElementById("tb_org_node");
895 1031 const sortEl = document.getElementById("tb_sort");
896 1032  
  1033 + const initialStatus = pickInitialTaskBoardStatus(queryParams);
  1034 + if (initialStatus) statusEl.value = initialStatus;
  1035 +
897 1036 async function refresh() {
898 1037 const params = new URLSearchParams({ project_id: projectId });
899 1038 if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim());
... ...
frontend/index.html
... ... @@ -15,6 +15,7 @@
15 15 <a href="#/skills">Skills</a>
16 16 <a href="#/deployment">Deployment</a>
17 17 <a href="#/settings/concurrency">Settings</a>
  18 + <span id="nav_project_context" class="nav-project-context"></span>
18 19 </nav>
19 20 <main id="app"></main>
20 21 <script src="/app.js"></script>
... ...
frontend/style.css
1 1 /* U-008: minimal shell styling, nothing to design-review yet -- real look
2 2 and feel is PH3, starting from docs/wireframes/*.html. */
3 3 body { font-family: system-ui, sans-serif; margin: 0; }
4   -#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; }
  4 +#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; align-items: center; }
5 5 #nav a { text-decoration: none; color: #333; }
6 6 #nav a.active { font-weight: bold; }
7 7 #app { padding: 1rem; }
8 8  
  9 +/* U-048: nav-bar "current project" context (name + task status summary) --
  10 + pinned to the right of the fixed route links via margin-left:auto, empty
  11 + (renders nothing) whenever no project is currently selected. Reuses the
  12 + --status-* tokens defined below rather than inventing new colors. */
  13 +#nav_project_context { margin-left: auto; display: flex; align-items: center; gap: 10px; font-size: 13px; }
  14 +#nav_project_name { font-weight: 600; color: #111827; }
  15 +.nav-counts { display: flex; gap: 8px; }
  16 +.nav-count { color: #4b5563; }
  17 +.nav-count-pending { color: var(--status-pending); }
  18 +.nav-count-running { color: var(--status-running); }
  19 +.nav-count-attention { color: var(--status-blocked); }
  20 +.nav-count-done { color: var(--status-done); }
  21 +
9 22 /* U-022/U-023: v_project_setup/v_goal_composer/v_bug_feedback -- class
10 23 names match docs/wireframes/v_*.html */
11 24 .top-back a { color: #6b7280; text-decoration: none; }
... ...
frontend/test_nav_project_context_smoke.js 0 → 100644
  1 +// U-047/U-048 (2026-08-07 SDD) smoke test: nav-bar "current project" context.
  2 +// Same no-framework/no-fixture convention as the other frontend/test_*_smoke.js
  3 +// files (see .claude/agent-memory/validator/feedback_no_js_test_framework.md)
  4 +// -- loads real app.js in a bare vm.Context and asserts on generated HTML
  5 +// strings / regex matches / real async control flow, not mocks of the logic
  6 +// under test.
  7 +//
  8 +// Covers:
  9 +// - U-047: parseHashQuery -- happy path, no-query hash, empty value,
  10 +// multiple params; and the regression this was explicitly designed not
  11 +// to break: TASK_DETAIL_RE must still match the *path* half of a hash
  12 +// that now carries a query string.
  13 +// - U-048 nav highlighting: a nav link whose href has no query string and
  14 +// the current hash's query-stripped path must be recognized as the same
  15 +// route (this is exactly what render()'s "#nav > a" active-toggling
  16 +// loop relies on).
  17 +// - U-048 bucket mapping: computeNavStatusBucketCounts groups all 10 raw
  18 +// task.status values into the 4 nav buckets (pending/running/attention/
  19 +// done), and navBucketHref only appends ?status=<value> for the two
  20 +// single-status buckets (pending/done) -- multi-status buckets
  21 +// (running/attention) link to the unfiltered #/tasks, since
  22 +// v_task_board's status filter is single-select.
  23 +// - U-048 DOM contract: renderNavProjectContextHtml emits #nav_project_name
  24 +// (linking to #/projects) and one .nav-count-<bucket> per bucket, and
  25 +// renders nothing at all when no project is given (unselected-project
  26 +// state, requirement 1).
  27 +// - U-048 refreshNavProjectContext (real async function, real Promise
  28 +// control, no reimplementation): single-fetch-per-refresh (one call to
  29 +// /api/projects/:id, one to /api/tasks?project_id=... -- not one fetch
  30 +// per bucket), stale/out-of-order response protection when the current
  31 +// project changes mid-flight, and the unselected-project case doing zero
  32 +// fetches.
  33 +// - mountTaskBoard: pre-selects tb_status from an initial ?status= query
  34 +// param, ignoring an unrecognized value instead of setting it blindly.
  35 +//
  36 +// Run: node frontend/test_nav_project_context_smoke.js
  37 +
  38 +"use strict";
  39 +const fs = require("fs");
  40 +const path = require("path");
  41 +const vm = require("vm");
  42 +const assert = require("assert");
  43 +
  44 +function makeFakeDiv() {
  45 + let text = "";
  46 + return {
  47 + set textContent(v) { text = v == null ? "" : String(v); },
  48 + get textContent() { return text; },
  49 + get innerHTML() {
  50 + return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  51 + },
  52 + };
  53 +}
  54 +
  55 +// Unlike makeFakeDiv (models a text node via textContent -> escaped
  56 +// innerHTML), nav_project_context needs a plain settable/gettable string --
  57 +// app.js writes a real HTML string there, not text that gets escaped.
  58 +function makeRawEl() {
  59 + let html = "";
  60 + return {
  61 + set innerHTML(v) { html = v; },
  62 + get innerHTML() { return html; },
  63 + };
  64 +}
  65 +
  66 +let currentProjectId = null;
  67 +let fetchCalls = [];
  68 +let pendingFetches = [];
  69 +function fakeFetch(url) {
  70 + fetchCalls.push(url);
  71 + return new Promise((resolve) => { pendingFetches.push({ url, resolve }); });
  72 +}
  73 +function resolveFetch(entry, status, body) {
  74 + entry.resolve({ status, json: async () => body });
  75 +}
  76 +
  77 +const navContextEl = makeRawEl();
  78 +const elementsById = {
  79 + nav_project_context: navContextEl,
  80 +};
  81 +
  82 +const sandbox = {
  83 + document: {
  84 + createElement: () => makeFakeDiv(),
  85 + getElementById: (id) => elementsById[id] || null,
  86 + querySelectorAll: () => [],
  87 + addEventListener: () => {},
  88 + },
  89 + window: {
  90 + location: { protocol: "http:", host: "localhost", hash: "" },
  91 + addEventListener: () => {},
  92 + },
  93 + localStorage: { getItem: () => currentProjectId, setItem: (k, v) => { currentProjectId = v; } },
  94 + fetch: fakeFetch,
  95 + console,
  96 +};
  97 +vm.createContext(sandbox);
  98 +
  99 +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
  100 +// Top-level `const` regexes/arrays stay lexically scoped to the script and
  101 +// never reach the sandbox object (same Annex B quirk the other smoke tests
  102 +// already document) -- re-export what this test needs.
  103 +const augmentedSrc = src + "\nglobalThis.TASK_DETAIL_RE = TASK_DETAIL_RE;\nglobalThis.NAV_STATUS_BUCKETS = NAV_STATUS_BUCKETS;\n";
  104 +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
  105 +
  106 +let assertionCount = 0;
  107 +function check(condition, message) {
  108 + assertionCount++;
  109 + assert.ok(condition, message);
  110 +}
  111 +
  112 +async function main() {
  113 + // ---- 1. parseHashQuery: happy / edge cases ----
  114 + {
  115 + const noQuery = sandbox.parseHashQuery("#/tasks");
  116 + check(noQuery.path === "#/tasks" && Object.keys(noQuery.params).length === 0, "no '?' -> whole string is the path, empty params");
  117 +
  118 + const single = sandbox.parseHashQuery("#/tasks?status=done");
  119 + check(single.path === "#/tasks" && single.params.status === "done", "single key=value param parsed correctly");
  120 +
  121 + const multi = sandbox.parseHashQuery("#/tasks?status=done&org_node=coder");
  122 + check(multi.path === "#/tasks" && multi.params.status === "done" && multi.params.org_node === "coder", "multiple params all parsed");
  123 +
  124 + const trailing = sandbox.parseHashQuery("#/tasks?");
  125 + check(trailing.path === "#/tasks" && Object.keys(trailing.params).length === 0, "trailing '?' with nothing after it -> empty params, not a crash");
  126 +
  127 + const emptyValue = sandbox.parseHashQuery("#/tasks?status=");
  128 + check(emptyValue.params.status === "", "key with empty value still produces a key, not dropped");
  129 +
  130 + const encoded = sandbox.parseHashQuery("#/tasks?keyword=a%20b");
  131 + check(encoded.params.keyword === "a b", "percent-encoded values are decoded");
  132 + }
  133 +
  134 + // ---- 2. Regression: TASK_DETAIL_RE must still match the query-stripped path ----
  135 + {
  136 + const withQuery = sandbox.parseHashQuery("#/tasks/abc-123?foo=bar");
  137 + const match = withQuery.path.match(sandbox.TASK_DETAIL_RE);
  138 + check(match && match[1] === "abc-123", "TASK_DETAIL_RE must match the path half of a hash carrying a query string");
  139 +
  140 + const withoutQuery = sandbox.parseHashQuery("#/tasks/abc-123");
  141 + check(withoutQuery.path.match(sandbox.TASK_DETAIL_RE)[1] === "abc-123", "no-query case keeps working exactly as before (no regression)");
  142 + }
  143 +
  144 + // ---- 3. Nav highlighting: query-stripped paths must compare equal ----
  145 + {
  146 + const currentHashPath = sandbox.parseHashQuery("#/tasks?status=queued").path;
  147 + const plainNavLinkPath = sandbox.parseHashQuery("#/tasks").path;
  148 + check(currentHashPath === plainNavLinkPath, "a plain '#/tasks' nav link must still be recognized as 'active' when the real hash carries a status query string");
  149 + }
  150 +
  151 + // ---- 4. computeNavStatusBucketCounts: all 10 statuses land in exactly one of the 4 buckets ----
  152 + {
  153 + const allStatuses = [
  154 + "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed",
  155 + "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done",
  156 + ];
  157 + const tasks = allStatuses.map((status, i) => ({ task_id: "t" + i, status }));
  158 + const counts = sandbox.computeNavStatusBucketCounts(tasks);
  159 + const total = Object.values(counts).reduce((a, b) => a + b, 0);
  160 + check(total === allStatuses.length, "every raw status must be counted into exactly one bucket, none dropped/double-counted");
  161 + check(counts.pending === 1 && counts.done === 1, "pending/done are single-status buckets (queued/done respectively)");
  162 + check(counts.running === 5, "running bucket: planning_brd_prd_sdd, coding, validating, routed_fast_lane, routed_full_replan");
  163 + check(counts.attention === 3, "attention bucket: planning_failed, coding_failed, blocked_pending_human");
  164 +
  165 + const empty = sandbox.computeNavStatusBucketCounts([]);
  166 + check(Object.values(empty).every((n) => n === 0), "zero tasks -> every bucket is 0, not undefined/NaN");
  167 + }
  168 +
  169 + // ---- 5. navBucketHref: single-status buckets carry ?status=, multi-status ones don't ----
  170 + {
  171 + for (const bucket of sandbox.NAV_STATUS_BUCKETS) {
  172 + const href = sandbox.navBucketHref(bucket);
  173 + if (bucket.statuses.length === 1) {
  174 + check(href === "#/tasks?status=" + bucket.statuses[0], bucket.key + " (single-status) must link with its exact status filter");
  175 + } else {
  176 + check(href === "#/tasks", bucket.key + " (multi-status) must link to the unfiltered board, v_task_board's filter is single-select");
  177 + }
  178 + }
  179 + }
  180 +
  181 + // ---- 6. renderNavProjectContextHtml: DOM contract + unselected-project state ----
  182 + {
  183 + const html = sandbox.renderNavProjectContextHtml(
  184 + { project_id: "p1", name: "My <Project>" },
  185 + { pending: 3, running: 2, attention: 1, done: 12 },
  186 + );
  187 + check(html.includes('id="nav_project_name"'), "must carry #nav_project_name");
  188 + check(html.includes('href="#/projects"'), "project name must link to #/projects (switch mechanism, no rebuild)");
  189 + check(html.includes("My &lt;Project&gt;"), "project name must be HTML-escaped");
  190 + check(html.includes('nav-count-pending') && html.includes("pending 3"), "pending bucket count rendered");
  191 + check(html.includes('nav-count-running') && html.includes("running 2"), "running bucket count rendered");
  192 + check(html.includes('nav-count-attention') && html.includes("attention 1"), "attention bucket count rendered");
  193 + check(html.includes('nav-count-done') && html.includes("done 12"), "done bucket count rendered");
  194 +
  195 + check(sandbox.renderNavProjectContextHtml(null, {}) === "", "no project selected -> render nothing (requirement 1)");
  196 + }
  197 +
  198 + // ---- 7. refreshNavProjectContext: unselected-project state does zero fetches ----
  199 + {
  200 + currentProjectId = null;
  201 + fetchCalls = [];
  202 + await sandbox.refreshNavProjectContext();
  203 + check(fetchCalls.length === 0, "no current project -> must not fetch anything");
  204 + check(navContextEl.innerHTML === "", "no current project -> nav context area must be empty");
  205 + }
  206 +
  207 + // ---- 8. refreshNavProjectContext: single-fetch-per-refresh + stale/out-of-order protection ----
  208 + {
  209 + fetchCalls = [];
  210 + pendingFetches = [];
  211 +
  212 + currentProjectId = "proj-A";
  213 + const promiseA = sandbox.refreshNavProjectContext();
  214 + currentProjectId = "proj-B";
  215 + const promiseB = sandbox.refreshNavProjectContext();
  216 +
  217 + check(pendingFetches.length === 4, "two refreshes -> exactly 2 fetches each (project + tasks), never per-bucket (single-fetch-per-refresh)");
  218 + check(fetchCalls[0].includes("/api/projects/proj-A") && fetchCalls[1].includes("project_id=proj-A"), "call A fetches proj-A's project + tasks");
  219 + check(fetchCalls[2].includes("/api/projects/proj-B") && fetchCalls[3].includes("project_id=proj-B"), "call B fetches proj-B's project + tasks");
  220 +
  221 + // Resolve B (the newer, current request) first...
  222 + resolveFetch(pendingFetches[2], 200, { project_id: "proj-B", name: "Project B" });
  223 + resolveFetch(pendingFetches[3], 200, [{ task_id: "t1", status: "done" }]);
  224 + await promiseB;
  225 + check(navContextEl.innerHTML.includes("Project B"), "current (B) response must be rendered");
  226 +
  227 + // ...then resolve A's (older, now-superseded) response arriving late.
  228 + resolveFetch(pendingFetches[0], 200, { project_id: "proj-A", name: "Project A" });
  229 + resolveFetch(pendingFetches[1], 200, [{ task_id: "t2", status: "queued" }]);
  230 + await promiseA;
  231 + check(navContextEl.innerHTML.includes("Project B"), "a stale response for a since-superseded project must not overwrite the current (B) nav state");
  232 + check(!navContextEl.innerHTML.includes("Project A"), "project A's late response must be discarded entirely");
  233 + }
  234 +
  235 + // ---- 9. pickInitialTaskBoardStatus: mountTaskBoard's ?status= pre-select logic ----
  236 + // (mountTaskBoard itself isn't called directly here -- it also wires up a
  237 + // real WebSocket + several other DOM elements this bare sandbox doesn't
  238 + // stub, same reasoning documented in feedback_no_js_test_framework.md for
  239 + // why mount* functions aren't exercised end-to-end elsewhere in this repo's
  240 + // frontend smoke tests either.)
  241 + {
  242 + check(sandbox.pickInitialTaskBoardStatus({ status: "coding" }) === "coding", "a recognized status query param is picked");
  243 + check(sandbox.pickInitialTaskBoardStatus({ status: "not_a_real_status" }) === null, "an unrecognized status value must be ignored, not blindly returned");
  244 + check(sandbox.pickInitialTaskBoardStatus({}) === null, "no status query param at all -> null (filter stays untouched)");
  245 + check(sandbox.pickInitialTaskBoardStatus(undefined) === null, "missing queryParams entirely (defensive) -> null, no throw");
  246 + }
  247 +
  248 + console.log("frontend/test_nav_project_context_smoke.js: all " + assertionCount + " assertions passed");
  249 +}
  250 +
  251 +main().catch((e) => {
  252 + console.error(e);
  253 + process.exit(1);
  254 +});
... ...