// 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: () => "

Not found

" + 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=" -- 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 // beyond that, requested directly by the user. Creation still goes through // v_project_setup's existing flow (pick a folder, optionally git-init it, // project_scaffold.create_project() already handles a folder that already // has real code in it correctly -- it only ever adds .graphlemon/ inside, // nothing about "importing" needs to be built separately). This page adds // the missing read/update/archive half: list every project, rename, // archive/reactivate, and select one as "current" to work in. function renderProjectList() { return `

项目

+ 新建 / 导入项目
名称目标目录状态Goal 数创建时间
`; } function mountProjectList() { const rowsEl = document.getElementById("pl_rows"); async function refresh() { const resp = await fetch("/api/projects"); const projects = await resp.json(); if (projects.length === 0) { rowsEl.innerHTML = '还没有项目,创建一个'; return; } rowsEl.innerHTML = projects.map((p) => ( '' + '' + escapeHtml(p.name) + "" + '" + "" + escapeHtml(p.target_folder_path) + "" + '' + p.status + "" + "" + p.goal_count + "" + "" + escapeHtml(p.created_at) + "" + "" + ' ' + ' ' + '" + "" + "" )).join(""); for (const btn of rowsEl.querySelectorAll('button[data-action="select"]')) { btn.addEventListener("click", () => { setCurrentProjectId(btn.dataset.projectId); window.location.hash = "#/tasks"; }); } for (const btn of rowsEl.querySelectorAll('button[data-action="rename"]')) { btn.addEventListener("click", () => { const id = btn.dataset.projectId; rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = "none"; rowsEl.querySelector('.pl-name-edit[data-project-id="' + id + '"]').style.display = "inline-block"; }); } for (const span of rowsEl.querySelectorAll(".pl-name-edit")) { const id = span.dataset.projectId; span.querySelector('[data-action="cancel-rename"]').addEventListener("click", () => { span.style.display = "none"; rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = ""; }); span.querySelector('[data-action="save-rename"]').addEventListener("click", () => { const newName = span.querySelector(".pl-name-input").value.trim(); if (!newName) return; fetch("/api/projects/" + encodeURIComponent(id), { method: "PUT", body: JSON.stringify({ name: newName }), }).then(refresh); }); } for (const btn of rowsEl.querySelectorAll('button[data-action="toggle-status"]')) { btn.addEventListener("click", () => { const nextStatus = btn.dataset.currentStatus === "active" ? "archived" : "active"; fetch("/api/projects/" + encodeURIComponent(btn.dataset.projectId), { method: "PUT", body: JSON.stringify({ status: nextStatus }), }).then(refresh); }); } } refresh(); } // ---- v_project_setup (02-V §2.1): create/switch project ---- function renderProjectSetup() { return `
← 返回仪表盘

创建 / 切换项目

选择一个本地文件夹作为目标项目的代码空间
点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)
`; } function mountProjectSetup() { const folderInput = document.getElementById("folder_path"); const folderHint = document.getElementById("folder_path_hint"); const nameInput = document.getElementById("project_name"); const errorBox = document.getElementById("setup_error"); const confirmBanner = document.getElementById("confirm_banner"); function showError(message) { errorBox.textContent = message; errorBox.style.display = "block"; } function clearError() { errorBox.style.display = "none"; } document.getElementById("btn_pick_folder").addEventListener("click", async () => { clearError(); const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" }); const body = await resp.json(); if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") { folderInput.removeAttribute("readonly"); folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径"; return; } if (body.selected_path) { folderInput.value = body.selected_path; } // user canceled the native dialog -> folder_path stays as it was (02-V §2.1) }); async function createProject(initGitIfMissing) { clearError(); confirmBanner.style.display = "none"; const folderPath = folderInput.value.trim(); if (!folderPath) { showError("请先选择目标文件夹"); return; } const resp = await fetch("/api/projects", { method: "POST", body: JSON.stringify({ target_folder_path: folderPath, name: nameInput.value.trim() || undefined, init_git_if_missing: initGitIfMissing, }), }); const body = await resp.json(); if (resp.status === 200) { setCurrentProjectId(body.project_id); window.location.hash = "#/"; return; } if (body.error === "GIT_INIT_DECLINED") { confirmBanner.style.display = "block"; return; } showError(body.error + (body.detail ? ":" + body.detail : "")); } document.getElementById("btn_create").addEventListener("click", () => createProject(false)); document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true)); document.getElementById("btn_cancel_init").addEventListener("click", () => { confirmBanner.style.display = "none"; }); } // ---- shared: pages that need a "current project" to already exist ---- function renderNoProjectSelected() { return `
还没有选择项目。请先创建或切换项目。
`; } // ---- v_goal_composer (02-V §2.3): propose a new goal ---- const GOAL_TEXT_MAX_LEN = 4000; function renderGoalComposer() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `
← 返回仪表盘

提出新目标

用自然语言描述你想让 graphlemon 完成的目标
`; } function mountGoalComposer() { if (!getCurrentProjectId()) return; const textArea = document.getElementById("goal_text"); const errorBox = document.getElementById("goal_error"); const form = document.getElementById("goal_form"); const confirmBanner = document.getElementById("goal_confirm_banner"); const confirmSummary = document.getElementById("goal_confirm_summary"); document.getElementById("btn_submit_goal").addEventListener("click", () => { errorBox.style.display = "none"; const goalText = textArea.value.trim(); if (!goalText) { errorBox.textContent = "目标描述不能为空"; errorBox.style.display = "block"; return; } confirmSummary.textContent = "即将创建任务:" + goalText; form.style.display = "none"; confirmBanner.style.display = "block"; }); document.getElementById("btn_edit_goal").addEventListener("click", () => { confirmBanner.style.display = "none"; form.style.display = "block"; }); document.getElementById("btn_confirm_goal").addEventListener("click", async () => { const resp = await fetch("/api/tasks", { method: "POST", body: JSON.stringify({ project_id: getCurrentProjectId(), goal_text: textArea.value.trim(), task_type: "goal", created_via: "human_console", }), }); const body = await resp.json(); if (resp.status !== 200) { confirmBanner.style.display = "none"; form.style.display = "block"; errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorBox.style.display = "block"; return; } // 02-V §2.3: 成功跳转 v_task_detail window.location.hash = "#/tasks/" + body.task_id; }); } // ---- v_bug_feedback (02-V §2.4): report a bug ---- function renderBugFeedback() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `
← 返回仪表盘

反馈 Bug

人工反馈,与 Validator 自动生成的 bug_record 复用同一张表(source=human)
`; } function mountBugFeedback() { if (!getCurrentProjectId()) return; const descriptionArea = document.getElementById("bug_description"); const reproStepsArea = document.getElementById("bug_repro_steps"); const relatedTaskIdSelect = document.getElementById("bug_related_task_id"); const errorBox = document.getElementById("bug_error"); const successBox = document.getElementById("bug_success"); const form = document.getElementById("bug_form"); fetch("/api/tasks?project_id=" + encodeURIComponent(getCurrentProjectId())) .then((resp) => resp.json()) .then((tasks) => { for (const task of tasks) { const option = document.createElement("option"); option.value = task.task_id; option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")"; relatedTaskIdSelect.appendChild(option); } }); document.getElementById("btn_submit_bug").addEventListener("click", async () => { errorBox.style.display = "none"; const description = descriptionArea.value.trim(); if (!description) { errorBox.textContent = "问题描述不能为空"; errorBox.style.display = "block"; return; } const relatedTaskId = relatedTaskIdSelect.value || undefined; const resp = await fetch("/api/bug-reports", { method: "POST", body: JSON.stringify({ project_id: getCurrentProjectId(), description: description, repro_steps: reproStepsArea.value.trim() || undefined, related_task_id: relatedTaskId, }), }); const body = await resp.json(); if (resp.status !== 200) { errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorBox.style.display = "block"; return; } // 02-V §2.4: 跳转到该 bug 的详情视图,MVP 复用 v_task_detail 的 bug 展示区 // -- only possible when the report was tied to a task; otherwise there's // no task detail page to land on, so show the confirmation inline. if (relatedTaskId) { window.location.hash = "#/tasks/" + relatedTaskId; return; } form.style.display = "none"; successBox.innerHTML = "已提交,Validator 将进行归因确认(bug " + 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 ? '
→
' : ""; return '
' + node + '
' + escapeHtml(hint) + "
" + arrow; }).join(""); } // v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint // split), `activeSet` already carries different semantics than the // dashboard's single activeOrgNode -- kept as its own function rather than // forcing the two call sites to share one shape. function renderTaskDetailOrgStripHtml(activeSet) { return ORG_NODE_ORDER.map((node, i) => { const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; return '
' + node + "
" + arrow; }).join(""); } function mountOrgStripClicks(container, taskId) { if (!taskId) return; for (const el of container.querySelectorAll(".org-node[data-org-node]")) { el.addEventListener("click", () => { window.location.hash = "#/tasks/" + taskId + "/org/" + el.dataset.orgNode; }); } } // "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the // artifact list's own markup pattern (li[data-artifact-id], same as // renderArtifactSummaryList) filtered down to this run's own artifacts -- // linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact // content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here // -- they're already rendered once, further down this same page, by the // unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function // only describes which run that is and lists its artifacts. function renderOrgComboPanelContent(task, orgNode) { const run = getLatestRunForOrgNode(task, orgNode); if (!run) { return '
' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)
"; } const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0; const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id); const artifactsHtml = artifacts.length ? ( '" ) : '
暂无 Artifact
'; return ( '
最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")" + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "
" + '

该 run 产出的 Artifact

' + artifactsHtml ); } function wireOrgComboArtifactClicks(scopeEl) { const combo = scopeEl.querySelector("#org_combo_content"); if (!combo) return; for (const li of combo.querySelectorAll("li[data-artifact-id]")) { li.addEventListener("click", () => { window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId); }); } } // Called both on initial mount (deep link / dashboard click landed here with // an org_node already known) and from the inline org-strip's own click // handler (already on this page -- 02-V happy path 2.2 explicitly wants an // in-page scroll, not another full page render). Scrolls to the DAG panel // first (renders whenever graph_manifest exists), falls back to the to-do // tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成 // Work Graph" empty states -- never throws if a target is missing. function focusOrgNodePanel(content, task, orgNode) { for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode); } const comboContent = content.querySelector("#org_combo_content"); if (comboContent) { comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode); wireOrgComboArtifactClicks(content); } const run = getLatestRunForOrgNode(task, orgNode); let target = run ? document.getElementById("graph-panel-" + run.run_id) : null; if (!target && run) target = document.getElementById("todo-tree-" + run.run_id); if (!target) target = content.querySelector("#org_combo_panel"); if (target && target.scrollIntoView) { target.scrollIntoView({ behavior: "smooth", block: "start" }); target.classList.add("org-highlight-flash"); setTimeout(() => target.classList.remove("org-highlight-flash"), 1600); } } function renderDashboard() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `

仪表盘

Org Graph 三节点状态

当前任务概要

预算用量

最近事件

`; } function mountDashboard() { const projectId = getCurrentProjectId(); if (!projectId) return; const orgStrip = document.getElementById("org_strip"); const taskSummary = document.getElementById("task_summary"); const budgetSummary = document.getElementById("budget_summary"); const eventTimeline = document.getElementById("event_timeline"); const quotaBanner = document.getElementById("quota_info_banner"); const wsBanner = document.getElementById("ws_disconnected_banner"); const recentEvents = []; // Set right before every renderOrgStrip() call below -- org-strip clicks // need to know which task_id to route to (#/tasks/:task_id/org/:org_node). let orgStripTaskId = null; function renderOrgStrip(activeOrgNode, runStatusByOrgNode) { orgStrip.innerHTML = renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode); mountOrgStripClicks(orgStrip, orgStripTaskId); } async function refreshTasksAndOrgGraph() { const resp = await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)); const tasks = await resp.json(); const active = tasks.find((t) => t.status !== "done") || tasks[0]; if (!active) { orgStripTaskId = null; orgStrip.innerHTML = '
暂无运行中任务
'; taskSummary.innerHTML = '
暂无运行中任务 · 提出第一个目标 →
'; quotaBanner.style.display = "none"; return; } orgStripTaskId = active.task_id; // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set // only makes sense on v_task_detail (they need the task's own status // context to pick which one to show) -- the dashboard only ever needs // "queued and nothing has started yet", so it gets just Start. const startRowHtml = active.status !== "queued" ? "" : ( '
' + '' + "
" ); taskSummary.innerHTML = ( '
Goal' + escapeHtml(active.goal_text) + "
" + '
Loop Round' + active.loop_round + "
" + '
Status' + escapeHtml(active.status) + "
" + startRowHtml ); const dbStartBtn = document.getElementById("db_start_task"); if (dbStartBtn) { dbStartBtn.addEventListener("click", async () => { dbStartBtn.disabled = true; await fetch("/api/tasks/" + active.task_id + "/execute", { method: "POST" }); refreshTasksAndOrgGraph(); }); } const detailResp = await fetch("/api/tasks/" + active.task_id); const detail = await detailResp.json(); const runStatusByOrgNode = {}; let waitingNode = null; for (const run of detail.work_graph_runs) { for (const orgNode of run.org_nodes_involved || []) runStatusByOrgNode[orgNode] = run.status; if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota"); } renderOrgStrip(active.assigned_org_node, runStatusByOrgNode); if (waitingNode) { quotaBanner.innerHTML = "🕐 有任务正在等待 Claude Code 额度恢复 · " + '查看详情'; quotaBanner.style.display = "flex"; } else { quotaBanner.style.display = "none"; } } async function refreshBudget() { const resp = await fetch("/api/settings/budget"); const configs = await resp.json(); if (configs.length === 0) { budgetSummary.innerHTML = '
预算护栏未配置
'; return; } const c = configs[0]; const pct = c.limit_value > 0 ? Math.min(100, Math.round((c.current_usage / c.limit_value) * 100)) : 0; budgetSummary.innerHTML = ( '
' + c.current_usage.toLocaleString() + " / " + c.limit_value.toLocaleString() + " " + escapeHtml(c.limit_type) + "
" + '
' ); } function prependEvent(event) { recentEvents.unshift(event); recentEvents.length = Math.min(recentEvents.length, 20); eventTimeline.innerHTML = recentEvents.map((e) => '
' + escapeHtml(e.event_type) + '
' + escapeHtml(e.created_at || "") + "
" ).join("") || '
暂无事件
'; } refreshTasksAndOrgGraph(); refreshBudget(); eventTimeline.innerHTML = '
暂无事件
'; let wasDisconnected = false; const socket = connectEventSocket(projectId, null, { onEvent: (event) => { prependEvent(event); refreshTasksAndOrgGraph(); if (event.event_type === "budget_updated") refreshBudget(); }, onStatusChange: (status) => { wsBanner.style.display = status === "disconnected" ? "block" : "none"; if (status === "disconnected") { wasDisconnected = true; } else if (status === "connected" && wasDisconnected) { // REQ-12 boundary: reconnect doesn't replay every missed event, but // must at least catch up on current state via a fresh snapshot // fetch (03-C §3.14's own documented reconnect story). wasDisconnected = false; refreshTasksAndOrgGraph(); refreshBudget(); } }, }); registerRouteCleanup(() => socket.close()); } // ---- v_task_board (02-V §2.5): task list with filters ---- const TASK_STATUSES = [ "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed", "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done", ]; function renderTaskBoard() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `

任务看板

+ 提出新目标
task_idgoal_textstatusassigned_org_nodeloop_roundupdated_at
`; } // 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