// U-008: hash-based client-side router (no History API / server catch-all // needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V // §2.1); U-023 adds #/goals/new and #/bugs/new; U-025 adds the real #/ // (v_dashboard), #/tasks (v_task_board), #/tasks/:task_id (v_task_detail). // 02-V's console has no multi-project switcher UI (decision 2), but // c_task_create/c_bug_report_create both require project_id (03-C §3.1; // §3.1.1 doesn't list it, but needs it too -- see bug_record.py's // create_bug_report docstring). Until v_dashboard carries "current project" // state through some richer UI, the id picked/created in v_project_setup is // stashed here and read back by the pages that need it. const CURRENT_PROJECT_KEY = "graphlemon_project_id"; function getCurrentProjectId() { return localStorage.getItem(CURRENT_PROJECT_KEY); } function setCurrentProjectId(projectId) { localStorage.setItem(CURRENT_PROJECT_KEY, projectId); } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; } // c_ws_subscribe (03-C §3.14, U-024): opens a live event feed for a project // (optionally scoped to one task_id) and auto-reconnects on drop -- 02-V // §2.2's "实时连接已断开,正在重连" exception state. REQ-12's deeper // reconnect-catch-up semantics are U-024.5's job (03-C §3.14 itself routes // that through REST snapshots, not WS replay); this only re-opens the // socket and reports connection status back to the caller. function connectEventSocket(projectId, taskId, { onEvent, onStatusChange }) { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; let url = wsProtocol + "//" + window.location.host + "/api/ws/projects/" + encodeURIComponent(projectId); if (taskId) url += "?task_id=" + encodeURIComponent(taskId); let closedByCaller = false; let socket = null; function open() { socket = new WebSocket(url); socket.onopen = () => onStatusChange("connected"); socket.onmessage = (event) => onEvent(JSON.parse(event.data)); socket.onclose = () => { if (closedByCaller) return; onStatusChange("disconnected"); setTimeout(open, 2000); }; socket.onerror = () => socket.close(); } open(); return { close: () => { closedByCaller = true; if (socket) socket.close(); }, }; } // c_ws_log_tail (03-C §3.6.2, U-027): live tail for one Work Node's raw log. // No auto-reconnect here, unlike connectEventSocket -- 03-C is explicit that // the server closes this connection on purpose once the node reaches a // terminal status, and the caller (mountLogViewer) is expected to switch to // c_execution_log_get's static mode on close, not treat it as a dropped // connection to retry. function connectLogTailSocket(nodeExecId, { onChunk, onClose }) { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = wsProtocol + "//" + window.location.host + "/api/ws/work-nodes/" + encodeURIComponent(nodeExecId) + "/log"; const socket = new WebSocket(url); socket.onmessage = (event) => onChunk(JSON.parse(event.data).appended_content); socket.onclose = () => onClose(); socket.onerror = () => socket.close(); return { close: () => socket.close() }; } const routes = { "#/": { render: renderDashboard, mount: mountDashboard }, "#/projects": { render: renderProjectList, mount: mountProjectList }, "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, "#/graph": { render: renderGraphConsole, mount: mountGraphConsole }, "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, "#/deployment": { render: renderDeploymentStatus, mount: mountDeploymentStatus }, "#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings }, "#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings }, "#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings }, "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, }; // #/tasks/:task_id (v_task_detail), #/skills/:skill_id/audit // (v_skill_audit_log), and #/artifacts/:artifact_id (v_artifact_browser, // pre-selecting one artifact) are the only dynamic routes -- not worth a // full path-matching router for three patterns. const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and // v_task_detail's org-strip nodes route here. `:org_node` is restricted to // the three real values on purpose -- anything else (typo, stray path // segment) falls through to the "Not found" fallback in render() instead of // reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would // otherwise need its own defensive checks further down. const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/; // v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`) // previously had nowhere real to send a click -- it just called // onSwitchToTask to switch v_graph_console's own task selector. This is the // actual run-detail page/route that closes that gap. const RUN_DETAIL_RE = /^#\/runs\/([^/]+)$/; let currentRouteCleanup = null; 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 rawHash = window.location.hash || "#/"; const { path: hash, params: queryParams } = parseHashQuery(rawHash); let route = routes[hash]; let routeArgs = []; if (!route) { const taskMatch = hash.match(TASK_DETAIL_RE); const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE); const skillMatch = hash.match(SKILL_AUDIT_RE); const artifactMatch = hash.match(ARTIFACT_DETAIL_RE); const runMatch = hash.match(RUN_DETAIL_RE); if (orgNodeMatch) { // Reuses v_task_detail itself (same render/mount) with the org_node // carried through as a second arg -- no separate view/route handler. route = { render: renderTaskDetail, mount: mountTaskDetail }; routeArgs = [orgNodeMatch[1], orgNodeMatch[2]]; } else if (taskMatch) { route = { render: renderTaskDetail, mount: mountTaskDetail }; routeArgs = [taskMatch[1]]; } else if (skillMatch) { route = { render: renderSkillAuditLog, mount: mountSkillAuditLog }; routeArgs = [skillMatch[1]]; } else if (artifactMatch) { // Reuses v_artifact_browser itself (same render/mount as "#/artifacts") // with the artifact_id pre-selected -- no separate detail view. route = { render: renderArtifactBrowser, mount: mountArtifactBrowser }; routeArgs = [artifactMatch[1]]; } else if (runMatch) { route = { render: renderRunDetail, mount: mountRunDetail }; routeArgs = [runMatch[1]]; } } if (!route) route = { render: () => "
" + hash + "
" }; document.getElementById("app").innerHTML = route.render(...routeArgs); // 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=" + body.bug_id + ")。"
+ '返回仪表盘';
successBox.style.display = "block";
});
}
// ---- v_dashboard (02-V §2.2): real-time landing page ----
const ORG_NODE_ORDER = ["planner", "coder", "validator"];
// ---- Org Graph 三节点条点击可达详情 (2026-08-06 SDD): shared by
// v_dashboard's `#org_strip` and v_task_detail's own inline org-strip below
// -- both mark each `.org-node` with `data-org-node`, both route through
// `#/tasks/:task_id/org/:org_node`, both pick "最新一次 work_graph_run" for
// that org_node the same way (C6: plain array filtering, no new backend
// query params). DAG/log/artifact display itself stays entirely inside
// renderGraphVisualizerPanel/renderTodoTree/mountLogViewer/v_artifact_browser
// (C3-C5) -- this block only does the "接线": which run is "latest", and
// where on the already-rendered page to scroll/highlight.
function getLatestRunForOrgNode(task, orgNode) {
// 2026-08-12 (Org Graph unification Phase 2c §2.1): a run's single
// org_node field is gone -- a merged run genuinely spans several
// org_nodes now (Planner/Coder/Validator can all append nodes into the
// SAME run), so "the run for this org_node" is membership in
// org_nodes_involved (a list, one entry per distinct subagent_type
// among the run's nodes), not equality against a single value.
const runs = task.work_graph_runs.filter((r) => (r.org_nodes_involved || []).includes(orgNode));
return runs.length ? runs[runs.length - 1] : null;
}
// Display label for a run's org_nodes_involved list -- everywhere this
// file used to show the single old org_node value directly.
function orgNodesLabel(run) {
const nodes = run.org_nodes_involved || [];
return nodes.length ? nodes.join(" + ") : "(no nodes yet)";
}
// v_dashboard's org-strip: role + hint text, only ever links out to
// v_task_detail (a genuine page change, so plain hash assignment is fine).
function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) {
return ORG_NODE_ORDER.map((node, i) => {
const isActive = activeOrgNode === node;
const runStatus = runStatusByOrgNode[node];
const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
const arrow = i < ORG_NODE_ORDER.length - 1 ? '