// 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\/([^/]+)$/;
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 skillMatch = hash.match(SKILL_AUDIT_RE);
const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
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]];
}
}
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 `
';
quotaBanner.style.display = "none";
return;
}
// 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) {
const projectId = getCurrentProjectId();
if (!projectId) return;
const content = document.getElementById("td_content");
// 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 = ORG_NODE_ORDER.map((node, i) => {
const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : "";
return '
' + node + "
" + arrow;
}).join("");
// 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" ? "" : (
'
' : "";
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 are the one exception: clicking a row switches this same
// page's own task switcher, since there's no separate "run detail" route.
async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTask }) {
container.innerHTML = '
"
);
const runsListEl = document.getElementById("gc_runs_list");
if (runsListEl) {
for (const li of runsListEl.querySelectorAll("li[data-run-task-id]")) {
li.addEventListener("click", () => onSwitchToTask(li.dataset.runTaskId));
}
}
// 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 = '
';
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 (
'