diff --git a/frontend/app.js b/frontend/app.js
index 1a5b3ec..f026060 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -115,13 +115,36 @@ function registerRouteCleanup(fn) {
currentRouteCleanup = fn;
}
+// U-047: `#/tasks?status=done`-style query strings on hash routes -- a new
+// capability, not a bug fix (no route previously carried a query string).
+// Hand-rolled instead of `new URLSearchParams(...)` (which mountTaskBoard's
+// own outgoing-request building already uses) because this one also has to
+// run inside frontend/test_*_smoke.js's bare `vm.createContext` sandbox,
+// which has no WHATWG URL globals -- decodeURIComponent is a plain
+// ECMAScript global and works there unmodified.
+function parseHashQuery(hash) {
+ const qIndex = hash.indexOf("?");
+ if (qIndex === -1) return { path: hash, params: {} };
+ const path = hash.slice(0, qIndex);
+ const params = {};
+ for (const pair of hash.slice(qIndex + 1).split("&")) {
+ if (!pair) continue;
+ const eqIndex = pair.indexOf("=");
+ const key = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
+ const value = eqIndex === -1 ? "" : pair.slice(eqIndex + 1);
+ params[decodeURIComponent(key)] = decodeURIComponent(value);
+ }
+ return { path, params };
+}
+
function render() {
if (currentRouteCleanup) {
currentRouteCleanup();
currentRouteCleanup = null;
}
- const hash = window.location.hash || "#/";
+ const rawHash = window.location.hash || "#/";
+ const { path: hash, params: queryParams } = parseHashQuery(rawHash);
let route = routes[hash];
let routeArgs = [];
if (!route) {
@@ -154,16 +177,115 @@ function render() {
if (!route) route = { render: () => "
Not found
" + hash + "
" };
document.getElementById("app").innerHTML = route.render(...routeArgs);
- if (route.mount) route.mount(...routeArgs);
-
- for (const a of document.querySelectorAll("#nav a")) {
- a.classList.toggle("active", a.getAttribute("href") === hash);
+ // queryParams always appended after any route-specific captures -- every
+ // mount() so far only declares the params it actually reads (e.g.
+ // mountTaskBoard(queryParams)), so this trailing arg is silently ignored
+ // by the rest (plain JS behavior, not a special case per route).
+ if (route.mount) route.mount(...routeArgs, queryParams);
+
+ // `#nav > a`, not `#nav a`: nav_project_context (U-048) nests its own
+ // links (project name, status-bucket counts) directly inside #nav --
+ // those must never get the same "active" bolding as the fixed route
+ // links above them just because their target route happens to match.
+ for (const a of document.querySelectorAll("#nav > a")) {
+ a.classList.toggle("active", parseHashQuery(a.getAttribute("href")).path === hash);
}
+ refreshNavProjectContext();
}
window.addEventListener("hashchange", render);
window.addEventListener("DOMContentLoaded", render);
+// ---- nav bar "current project" context (U-048) ----
+// All pages share #nav (index.html), so this is wired into render() itself
+// (called once per hashchange/initial load, see above) rather than into any
+// one page's mount() -- "piggybacks on the existing hashchange listener, no
+// new event plumbing needed" per the approved SDD. Reuses
+// `GET /api/tasks?project_id=...` and reduces status counts client-side
+// (one fetch per refresh, not one per bucket) -- a dedicated aggregation
+// endpoint is a documented revisit if a single project ever reaches
+// ~1000-2000 tasks.
+
+// 10 raw task.status values (TASK_STATUSES below, v_task_board) collapsed
+// into the 4 buckets the nav bar shows. `statuses.length === 1` buckets
+// (pending/done) link to "#/tasks?status=" -- v_task_board's status
+// filter is single-select, so multi-status buckets (running/attention) link
+// to the unfiltered "#/tasks" instead of picking one arbitrary status.
+const NAV_STATUS_BUCKETS = [
+ { key: "pending", label: "pending", statuses: ["queued"] },
+ { key: "running", label: "running", statuses: [
+ "planning_brd_prd_sdd", "coding", "validating", "routed_fast_lane", "routed_full_replan",
+ ] },
+ { key: "attention", label: "attention", statuses: [
+ "planning_failed", "coding_failed", "blocked_pending_human",
+ ] },
+ { key: "done", label: "done", statuses: ["done"] },
+];
+
+function navBucketHref(bucket) {
+ return bucket.statuses.length === 1 ? "#/tasks?status=" + bucket.statuses[0] : "#/tasks";
+}
+
+function computeNavStatusBucketCounts(tasks) {
+ const statusToBucketKey = {};
+ for (const bucket of NAV_STATUS_BUCKETS) {
+ for (const status of bucket.statuses) statusToBucketKey[status] = bucket.key;
+ }
+ const counts = {};
+ for (const bucket of NAV_STATUS_BUCKETS) counts[bucket.key] = 0;
+ for (const task of tasks) {
+ const bucketKey = statusToBucketKey[task.status];
+ if (bucketKey) counts[bucketKey]++;
+ }
+ return counts;
+}
+
+function renderNavProjectContextHtml(project, counts) {
+ if (!project) return "";
+ const countsHtml = NAV_STATUS_BUCKETS.map((bucket) =>
+ ''
+ + escapeHtml(bucket.label) + " " + (counts[bucket.key] || 0) + ""
+ ).join("");
+ return (
+ '' + escapeHtml(project.name) + ""
+ + '' + countsHtml + ""
+ );
+}
+
+// Guards against out-of-order responses: if the current project is switched
+// (or this fires again before an earlier fetch resolves), a stale response
+// landing after a newer one must not overwrite the nav with old data.
+let navProjectContextRequestId = 0;
+
+async function refreshNavProjectContext() {
+ const contextEl = document.getElementById("nav_project_context");
+ if (!contextEl) return;
+ const projectId = getCurrentProjectId();
+ const requestId = ++navProjectContextRequestId;
+ if (!projectId) {
+ // Requirement 1: no project selected -> render nothing, not a hint.
+ contextEl.innerHTML = "";
+ return;
+ }
+ try {
+ const [projectResp, tasksResp] = await Promise.all([
+ fetch("/api/projects/" + encodeURIComponent(projectId)),
+ fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)),
+ ]);
+ if (requestId !== navProjectContextRequestId) return; // superseded by a newer refresh
+ if (projectResp.status !== 200) {
+ contextEl.innerHTML = "";
+ return;
+ }
+ const project = await projectResp.json();
+ const tasks = tasksResp.status === 200 ? await tasksResp.json() : [];
+ if (requestId !== navProjectContextRequestId) return;
+ contextEl.innerHTML = renderNavProjectContextHtml(project, computeNavStatusBucketCounts(tasks));
+ } catch (e) {
+ if (requestId === navProjectContextRequestId) contextEl.innerHTML = "";
+ }
+}
+
// ---- v_project_list (2026-08-03): project list + CRUD ----
// 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an
// MVP non-goal -- this page is a deliberate, explicit scope expansion
@@ -885,7 +1007,21 @@ function renderTaskBoard() {
`;
}
-function mountTaskBoard() {
+// U-047/U-048: nav-bar status-bucket links land on #/tasks as
+// "?status=" -- this picks the status to pre-select, on initial
+// mount only (same reasoning as v_task_board's other filters: it's a
+// starting value the user can then change, not re-applied on every route
+// re-render). An unrecognized/stale status value is ignored rather than
+// left in a select the