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