// 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;
}
function render() {
if (currentRouteCleanup) {
currentRouteCleanup();
currentRouteCleanup = null;
}
const hash = window.location.hash || "#/";
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);
if (route.mount) route.mount(...routeArgs);
for (const a of document.querySelectorAll("#nav a")) {
a.classList.toggle("active", a.getAttribute("href") === hash);
}
}
window.addEventListener("hashchange", render);
window.addEventListener("DOMContentLoaded", render);
// ---- 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 `
`;
}
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) {
const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode);
return runs.length ? runs[runs.length - 1] : null;
}
// 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)
'
+ 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 `
';
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" ? "" : (
'
';
return;
}
const body = await resp.json();
viewerEl.innerHTML = "";
if (!body.content) {
viewerEl.innerHTML = '
暂无日志内容
';
return;
}
for (const line of body.content.split("\n")) appendLine(line);
} catch (e) {
viewerEl.innerHTML = '
日志暂不可用
';
}
}
if (isTerminalNodeStatus(initialStatus)) {
loadStatic();
return () => {};
}
// live mode still needs to show everything written so far, not just
// chunks appended after this connection opens -- backfill via the same
// static read before tailing new content (mounting is re-triggered on
// every task-level refresh, e.g. from v_task_detail's own WS feed, so
// this also re-populates content a reconnect would otherwise have wiped)
loadStatic();
const socket = connectLogTailSocket(nodeExecId, {
onChunk: (chunk) => { for (const line of chunk.split("\n")) if (line) appendLine(line); },
onClose: () => loadStatic(), // server closes on terminal status (03-C §3.6.2) -- switch to static mode
});
return () => socket.close();
}
const TERMINAL_NODE_STATUSES = new Set(["done", "blocked", "failed"]);
function isTerminalNodeStatus(status) {
return TERMINAL_NODE_STATUSES.has(status);
}
// ---- v_task_detail (02-V §2.6): task detail / process inspection ----
// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明")
// and the v_graph_visualizer DAG view above. The raw-log v_log_viewer is a
// separate Unit (U-027) and shows as a clearly-labeled placeholder here
// until it lands, rather than faking its content.
function renderTaskDetail(taskId) {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
`;
}
function mountTaskDetail(taskId, initialOrgNode) {
const projectId = getCurrentProjectId();
if (!projectId) return;
const content = document.getElementById("td_content");
// Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's
// "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the
// #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and
// updated in place when the inline org-strip below is clicked while
// already on this page, so a WS-triggered refresh() re-render keeps
// pointing at the same node instead of losing focus.
let focusedOrgNode = initialOrgNode || null;
// v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do
// expand -- tracked here so a re-render (WS-triggered refresh) or leaving
// the page closes any live c_ws_log_tail connections instead of leaking them.
const activeLogViewers = new Map();
function closeAllLogViewers() {
for (const close of activeLogViewers.values()) close();
activeLogViewers.clear();
}
function renderTodoTree(run) {
if (!run.graph_manifest) return "";
const nodesById = {};
for (const n of run.nodes) nodesById[n.node_id] = n;
const items = run.graph_manifest.nodes.map((gn) => {
const wn = nodesById[gn.node_id];
const status = wn ? wn.status : "pending";
const detail = wn ? (
'
DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer
'
+ "
"
);
}
function renderDetail(task) {
closeAllLogViewers();
const activeSet = new Set([task.assigned_org_node]);
for (const run of task.work_graph_runs) {
if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node);
}
const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet);
// v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one
// real thing "running" against it at a time -- exactly one of these
// three blocks is shown, keyed off task.status, matching the states
// task_execution.py itself understands (see its module docstring for
// why Pause/Stop are a soft stop reusing blocked_pending_human rather
// than a genuine subprocess kill / new status enum value).
const isUserPauseStop = task.status === "blocked_pending_human"
&& (task.blocked_reason === "user_paused" || task.blocked_reason === "user_stopped");
const execControlsHtml = task.status === "queued" ? (
'
';
// v_goal_list CRUD (2026-08-03): editing/deleting a goal is only safe
// while it's still 'queued' -- once real work has started, artifacts/
// bug_records/work_graph_runs all reference this task_id, and the
// backend (task_query.TaskNotEditableError) rejects both operations
// past that point anyway; hiding the controls here just avoids
// presenting an action that would visibly fail.
const goalCrudHtml = task.status !== "queued" ? "" : (
'
"
// "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by
// focusOrgNodePanel() below, either right after this render (deep
// link/dashboard click already carries an org_node) or on a later
// inline org-strip click. Left as the default hint until then.
+ '
Org 节点详情
'
+ '
点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact
';
return;
}
renderDetail(await resp.json());
}
refresh();
let wasDisconnected = false;
const socket = connectEventSocket(projectId, taskId, {
onEvent: () => refresh(),
onStatusChange: (status) => {
if (status === "disconnected") {
wasDisconnected = true;
} else if (status === "connected" && wasDisconnected) {
// REQ-12 boundary: catch up via a fresh snapshot on reconnect.
wasDisconnected = false;
refresh();
}
},
});
registerRouteCleanup(() => {
socket.close();
closeAllLogViewers();
});
}
// ---- v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD): single Work
// Graph Run detail -- every work_node_execution the run contains, each with
// its own log (live c_ws_log_tail while running, static c_execution_log_get
// once terminal). Reuses renderGraphVisualizerPanel/mountLogViewer
// unmodified, same as v_task_detail's todo tree above; the one DOM-id
// contract mountLogViewer relies on (`#todo-detail-
// .log-autoscroll`) is reproduced here via renderRunNodeListHtml so the
// existing auto-scroll checkbox keeps working without touching
// mountLogViewer itself.
function renderRunNodeListHtml(run) {
if (!run.nodes.length) return '
`;
}
function mountRunDetail(runId) {
const projectId = getCurrentProjectId();
if (!projectId) return;
const content = document.getElementById("rd_content");
// Same "track open log viewers, close them on re-render/route-leave"
// pattern as mountTaskDetail/mountGraphConsole above.
const activeLogViewers = new Map();
function closeAllLogViewers() {
for (const close of activeLogViewers.values()) close();
activeLogViewers.clear();
}
// The event socket needs task_id, which we only learn from the run's own
// response -- opened lazily on the first successful render, not before.
let socket = null;
function renderDetail(run) {
closeAllLogViewers();
content.innerHTML = (
"
' : "";
return nodeHtml + flowEdgeHtml;
}).join("");
// 04-P p_org_loop_route semantics: fast_lane loops back validator->coder,
// full_replan loops back validator->planner. task.route is null until the
// first loop actually happens -- no retry edge drawn in that case.
let retryEdgeHtml = "";
if (route === "fast_lane") {
retryEdgeHtml = '
";
// Each item links to v_artifact_browser (#/artifacts/:artifact_id) --
// click handlers are wired in mountArtifactsOverviewPanel after this HTML
// is inserted into the DOM. This deliberately reuses the existing
// browser/preview view rather than building a second content display.
return '
";
}
// FR-5: "查看全部" for documents/code/tasks intentionally lands on
// #/artifacts / #/tasks with no filter pre-applied -- v_artifact_browser
// and v_task_board don't parse URL query params today, and adding that is
// out of scope for this page (SDD §2.5's explicit, recorded trade-off).
// Graph run rows: U-046 fixed the previous shortcut (switching this same
// page's own task switcher via onSwitchToTask, because there was no
// separate "run detail" route) to navigate to the real v_run_detail page
// (#/runs/:run_id) instead.
async function mountArtifactsOverviewPanel(container, projectId) {
container.innerHTML = '
"
);
const runsListEl = document.getElementById("gc_runs_list");
if (runsListEl) {
for (const li of runsListEl.querySelectorAll("li[data-run-id]")) {
li.addEventListener("click", () => {
window.location.hash = "#/runs/" + encodeURIComponent(li.dataset.runId);
});
}
}
// Doc/code list items: jump to the existing v_artifact_browser detail
// view (#/artifacts/:artifact_id) pre-selecting this artifact. "查看全部"
// links above stay pointed at unfiltered #/artifacts per the FR-5 note.
for (const li of container.querySelectorAll("li[data-artifact-id]")) {
li.addEventListener("click", () => {
window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId);
});
}
}
function renderGraphConsole() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
Graph 控制台
Org Graph / Work Graph 拓扑与执行状态 · 点击 Work Graph 节点查看 Agent 日志
Org Graph
加载中…
加载中…
加载中…
`;
}
function mountGraphConsole() {
const projectId = getCurrentProjectId();
if (!projectId) return;
const taskSelect = document.getElementById("gc_task_select");
const orgTopologyEl = document.getElementById("gc_org_topology");
const workGraphPanelsEl = document.getElementById("gc_work_graph_panels");
const artifactsOverviewEl = document.getElementById("gc_artifacts_overview");
let currentTaskId = null;
// Same "track open log viewers, close them all on re-render/route-leave"
// pattern as mountTaskDetail's activeLogViewers/closeAllLogViewers, so a
// task switch or WS-triggered refresh doesn't leak c_ws_log_tail sockets.
const activeLogViewers = new Map();
function closeAllLogViewers() {
for (const close of activeLogViewers.values()) close();
activeLogViewers.clear();
}
function onWorkNodeClick(nodeExecId, status, nodeEl) {
const slot = nodeEl.querySelector(".log-viewer-slot");
if (!slot) return;
if (activeLogViewers.has(nodeExecId)) {
activeLogViewers.get(nodeExecId)();
activeLogViewers.delete(nodeExecId);
slot.innerHTML = "";
nodeEl.classList.remove("expanded");
return;
}
// Wrapping element id `todo-detail-` reuses the same
// selector mountLogViewer already looks for (`#todo-detail-
// .log-autoscroll`) -- lets the existing auto-scroll checkbox work here
// too without touching mountLogViewer's own implementation at all.
slot.innerHTML = '
block for every artifact_type --
// 02-V asks for Markdown rendering / syntax highlighting, but pulling in a
// markdown/highlighting library would be this frontend's first external
// dependency (05 §5.7's "no build step" ethos extends to keeping the
// console vanilla-JS-only); a monospace raw-text preview is a deliberate,
// documented simplification, not a missed requirement.
const ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark", "bug_record", "code_diff", "test_report"];
function renderArtifactBrowser() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
Artifact 浏览器
选择左侧一个 Artifact 查看预览
`;
}
function mountArtifactBrowser(initialArtifactId) {
const projectId = getCurrentProjectId();
if (!projectId) return;
const listEl = document.getElementById("ab_list");
const previewEl = document.getElementById("ab_preview");
const typeFilterEl = document.getElementById("ab_type_filter");
const taskFilterEl = document.getElementById("ab_task_filter");
// Seeded from the #/artifacts/:artifact_id route (v_graph_console's
// Planner 产出总览 list links here). refreshList()'s existing fallback
// below already handles an id that turns out invalid/filtered-out --
// falls back to the first artifact in the (possibly filtered) list.
let selectedArtifactId = initialArtifactId || null;
fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
.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) + ")";
taskFilterEl.appendChild(option);
}
});
async function showPreview(artifactId) {
selectedArtifactId = artifactId;
for (const li of listEl.querySelectorAll("li")) {
li.classList.toggle("active", li.dataset.artifactId === artifactId);
}
previewEl.innerHTML = '
`;
}
function mountSkillAuditLog(skillId) {
const listEl = document.getElementById("skill_audit_list");
const errorEl = document.getElementById("skill_audit_error");
const contentEl = document.getElementById("skill_content");
// Independent of the iteration-history panel below (a skill can have real
// content with zero skill_iteration rows -- that's the normal post-
// migration state) -- fetched and rendered separately, side by side, not
// as an either/or with refresh()'s own list.
fetch("/api/skills/" + encodeURIComponent(skillId) + "/content").then(async (resp) => {
if (resp.status !== 200) {
const body = await resp.json();
contentEl.innerHTML = '
';
return;
}
// newest first for display, oldest-first order from the API gives each its #N
listEl.innerHTML = iterations.slice().reverse().map((it, i) => {
const number = iterations.length - i;
return (
'