@tea-agent/loop-agent 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -0,0 +1,85 @@
1
+ /** Hash routing for Observe UI (R5). */
2
+
3
+ export function parseHashRoute(hashValue) {
4
+ const hash = (hashValue.replace(/^#/, "") || "/").split("?")[0] || "/";
5
+ const batchMatch = /^\/batch\/([^/]+)$/.exec(hash);
6
+ if (batchMatch) {
7
+ return { view: "batch", batchRunId: decodeURIComponent(batchMatch[1]) };
8
+ }
9
+ const runMatch = /^\/run\/([^/]+)$/.exec(hash);
10
+ if (runMatch) {
11
+ return { view: "run", workerRunId: decodeURIComponent(runMatch[1]) };
12
+ }
13
+ const dagMatch = /^\/dag\/([^/]+)$/.exec(hash);
14
+ if (dagMatch) {
15
+ return { view: "dag", dagRunId: decodeURIComponent(dagMatch[1]) };
16
+ }
17
+ const taskMatch = /^\/task\/([^/]+)$/.exec(hash);
18
+ if (taskMatch) {
19
+ return { view: "task", taskId: decodeURIComponent(taskMatch[1]) };
20
+ }
21
+ const featureMatch = /^\/feature\/([^/]+)$/.exec(hash);
22
+ if (featureMatch) {
23
+ return {
24
+ view: "feature",
25
+ featureId: decodeURIComponent(featureMatch[1]),
26
+ };
27
+ }
28
+ if (hash === "/pool") {
29
+ return { view: "pool" };
30
+ }
31
+ if (hash === "/failures") {
32
+ return { view: "failures" };
33
+ }
34
+ if (hash === "/dags") {
35
+ return { view: "dashboard", scrollTo: "dags" };
36
+ }
37
+ return { view: "dashboard" };
38
+ }
39
+
40
+ /** Active (non-terminal) statuses sort first; terminals by updatedAt desc. */
41
+
42
+ export function parseRoute() {
43
+ return parseHashRouteWithFilters(location.hash);
44
+ }
45
+
46
+ export function navigate(path) {
47
+ location.hash = path.startsWith("#") ? path.slice(1) : path;
48
+ }
49
+
50
+ /**
51
+ * Parse optional filter query after a hash path: #/pool?status=failed
52
+ * Returns { filters: { status?, q? } } merged into route; unknown keys ignored.
53
+ * Base parseHashRoute shape is preserved for all existing views.
54
+ */
55
+ export function parseHashFilters(hashValue) {
56
+ const raw = String(hashValue ?? "").replace(/^#/, "");
57
+ const qIndex = raw.indexOf("?");
58
+ if (qIndex < 0) return {};
59
+ const qs = raw.slice(qIndex + 1);
60
+ const params = new URLSearchParams(qs);
61
+ const filters = {};
62
+ const status = params.get("status");
63
+ const q = params.get("q");
64
+ if (status) filters.status = status;
65
+ if (q) filters.q = q;
66
+ return filters;
67
+ }
68
+
69
+ export function parseHashRouteWithFilters(hashValue) {
70
+ const raw = String(hashValue ?? "");
71
+ const withoutQuery = raw.replace(/^#/, "").split("?")[0];
72
+ const base = parseHashRoute("#" + (withoutQuery || "/"));
73
+ const filters = parseHashFilters(raw);
74
+ if (Object.keys(filters).length === 0) return base;
75
+ return { ...base, filters };
76
+ }
77
+
78
+ export function buildHashPath(path, filters = {}) {
79
+ const base = path.startsWith("#") ? path.slice(1) : path;
80
+ const params = new URLSearchParams();
81
+ if (filters.status) params.set("status", filters.status);
82
+ if (filters.q) params.set("q", filters.q);
83
+ const qs = params.toString();
84
+ return qs ? `${base}?${qs}` : base;
85
+ }
@@ -0,0 +1,148 @@
1
+ /** Run step/command/artifact processing and task history rows. */
2
+ import {
3
+ STEP_LABELS,
4
+ ARTIFACT_LABELS,
5
+ STEP_ORDER,
6
+ ARTIFACT_KEYS,
7
+ } from "./constants.js";
8
+ import { badge } from "./format.js";
9
+ import { navigate } from "./router.js";
10
+
11
+ export function stepDisplayLabel(label) {
12
+ return STEP_LABELS[label] ?? label;
13
+ }
14
+
15
+ export function buildStepStates(events) {
16
+ const states = new Map();
17
+ for (const ev of events) {
18
+ if (ev.type === "step.started") {
19
+ states.set(ev.label, { status: "running", startedAt: ev.at });
20
+ } else if (ev.type === "step.finished") {
21
+ states.set(ev.label, {
22
+ status: ev.status === "failed" ? "failed" : "succeeded",
23
+ durationMs: ev.durationMs,
24
+ });
25
+ }
26
+ }
27
+
28
+ const ordered = [...STEP_ORDER];
29
+ for (const label of states.keys()) {
30
+ if (!ordered.includes(label)) ordered.push(label);
31
+ }
32
+
33
+ return ordered.map((label) => {
34
+ const state = states.get(label);
35
+ return {
36
+ label,
37
+ display: stepDisplayLabel(label),
38
+ status: state?.status ?? "pending",
39
+ durationMs: state?.durationMs,
40
+ };
41
+ });
42
+ }
43
+
44
+ export function stepMarker(status) {
45
+ switch (status) {
46
+ case "succeeded":
47
+ return "✓";
48
+ case "running":
49
+ return "●";
50
+ case "failed":
51
+ return "✗";
52
+ default:
53
+ return "○";
54
+ }
55
+ }
56
+
57
+ export function getCurrentCommand(events) {
58
+ const active = new Map();
59
+ for (const ev of events) {
60
+ if (ev.type === "command.started") {
61
+ active.set(ev.label, ev);
62
+ } else if (ev.type === "command.finished") {
63
+ active.delete(ev.label);
64
+ }
65
+ }
66
+ let latest = null;
67
+ for (const cmd of active.values()) {
68
+ if (!latest || cmd.at > latest.at) latest = cmd;
69
+ }
70
+ if (latest?.at) {
71
+ const elapsed = Date.now() - Date.parse(latest.at);
72
+ latest = { ...latest, elapsedMs: Number.isFinite(elapsed) ? elapsed : 0 };
73
+ }
74
+ return latest;
75
+ }
76
+
77
+ export function getOutputTail(events, commandLabel) {
78
+ if (!commandLabel) return "";
79
+ const chunks = [];
80
+ for (const ev of events) {
81
+ if (
82
+ ev.type === "command.output" &&
83
+ ev.label === commandLabel &&
84
+ ev.outputPreview
85
+ ) {
86
+ chunks.push(ev.outputPreview);
87
+ }
88
+ }
89
+ return chunks.join("");
90
+ }
91
+
92
+ export function collectArtifacts(task, events) {
93
+ const arts = new Map();
94
+ const add = (label, artifactPath) => {
95
+ if (artifactPath && !arts.has(label)) arts.set(label, artifactPath);
96
+ };
97
+
98
+ if (task?.artifactRefs) {
99
+ for (const key of ARTIFACT_KEYS) {
100
+ add(ARTIFACT_LABELS[key] ?? key, task.artifactRefs[key]);
101
+ }
102
+ }
103
+
104
+ for (const ev of events) {
105
+ if (!ev.artifactRefs) continue;
106
+ for (const [key, artifactPath] of Object.entries(ev.artifactRefs)) {
107
+ add(ARTIFACT_LABELS[key] ?? key, artifactPath);
108
+ }
109
+ }
110
+
111
+ return arts;
112
+ }
113
+
114
+ export function findBatchRunIdForTask(snapshot, task) {
115
+ if (!snapshot || !task) return null;
116
+ const workerRunId = task.workerRunId;
117
+ if (!workerRunId) return null;
118
+ for (const batch of snapshot.batches ?? []) {
119
+ for (const t of batch.tasks ?? []) {
120
+ if (t.workerRunId === workerRunId) return batch.batchRunId;
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export function taskRunHistoryRow(run) {
127
+ let runLink = run.workerRunId;
128
+ if (run.workerRunId) {
129
+ const a = document.createElement("a");
130
+ a.href = `#/run/${encodeURIComponent(run.workerRunId)}`;
131
+ a.textContent = run.workerRunId;
132
+ a.addEventListener("click", (e) => {
133
+ e.preventDefault();
134
+ navigate(`#/run/${encodeURIComponent(run.workerRunId)}`);
135
+ });
136
+ runLink = a;
137
+ }
138
+ return {
139
+ cells: [
140
+ runLink,
141
+ badge(run.status),
142
+ run.recordedAt ?? run.finishedAt ?? "—",
143
+ run.batchRunId ?? "—",
144
+ run.retryOfWorkerRunId ?? "—",
145
+ run.failureCategory ?? "—",
146
+ ],
147
+ };
148
+ }
@@ -0,0 +1,68 @@
1
+ /** Shell chrome: view switching, breadcrumb, header refresh, relation bar. */
2
+ import { UI_TEXT } from "./constants.js";
3
+ import { clearNode, el } from "./dom.js";
4
+ import { formatAgo } from "./format.js";
5
+ import { navigate } from "./router.js";
6
+ import { buildObjectRelations, renderObjectRelationBar } from "./relations.js";
7
+ import { closeDagInspector } from "./views/dag-inspector.js";
8
+
9
+ export function showView(name) {
10
+ document.body.dataset.view = name;
11
+ if (name !== "dag") closeDagInspector();
12
+ document.querySelectorAll("[data-view]").forEach((section) => {
13
+ section.hidden = section.dataset.view !== name;
14
+ });
15
+ let activeHref = "#/";
16
+ if (name === "failures") activeHref = "#/failures";
17
+ else if (name === "pool" || name === "task") activeHref = "#/pool";
18
+ else if (name === "feature") activeHref = "#/";
19
+ document.querySelectorAll(".nav-link").forEach((link) => {
20
+ link.classList.toggle(
21
+ "nav-link-active",
22
+ link.getAttribute("href") === activeHref,
23
+ );
24
+ });
25
+ }
26
+
27
+ export function setBreadcrumb(parts) {
28
+ const nav = document.getElementById("breadcrumb");
29
+ nav.hidden = parts.length <= 1;
30
+ clearNode(nav);
31
+ parts.forEach((part, i) => {
32
+ if (i > 0) nav.appendChild(document.createTextNode(" / "));
33
+ if (part.href) {
34
+ const a = document.createElement("a");
35
+ a.href = part.href;
36
+ a.textContent = part.label;
37
+ a.addEventListener("click", (e) => {
38
+ e.preventDefault();
39
+ navigate(part.href);
40
+ });
41
+ nav.appendChild(a);
42
+ } else {
43
+ nav.appendChild(document.createTextNode(part.label));
44
+ }
45
+ });
46
+ }
47
+
48
+ export function updateHeaderRefresh(generatedAt) {
49
+ const elRefresh = document.getElementById("header-refresh");
50
+ if (!elRefresh) return;
51
+ if (!generatedAt) {
52
+ elRefresh.textContent = "";
53
+ return;
54
+ }
55
+ elRefresh.textContent = `${formatAgo(generatedAt)} · ${UI_TEXT.nearRealtime}`;
56
+ }
57
+
58
+ export function mountRelationBar(containerId, ids, snapshot) {
59
+ const elBar = document.getElementById(containerId);
60
+ if (!elBar) return;
61
+ clearNode(elBar);
62
+ try {
63
+ const relations = buildObjectRelations(ids, snapshot);
64
+ elBar.appendChild(renderObjectRelationBar(relations, snapshot));
65
+ } catch {
66
+ elBar.appendChild(el("p", "empty", "关联投影暂时不可用。"));
67
+ }
68
+ }
@@ -0,0 +1,253 @@
1
+ /** Shared mutable UI state singleton (Observe UI R5). */
2
+ import {
3
+ ACTIVE_DETAIL_POLL_MS,
4
+ HIDDEN_POLL_MS,
5
+ DASHBOARD_POLL_MS,
6
+ TERMINAL_RUN_STATUSES,
7
+ OUTPUT_FOLLOW_LATEST_PX,
8
+ } from "./constants.js";
9
+ import { clearNode, el } from "./dom.js";
10
+
11
+ /**
12
+ * Single mutable ownership bag. Import `{ uiState }` and mutate properties.
13
+ * Never assign through `import * as ns` live bindings (Chrome TypeError).
14
+ */
15
+ export const uiState = {
16
+ dashboardTimer: null,
17
+ runPollTimer: null,
18
+ dagPollTimer: null,
19
+ poolTimer: null,
20
+ taskPollTimer: null,
21
+ runEventOffset: 0,
22
+ runEvents: [],
23
+ currentRunId: null,
24
+ currentBatchRunId: null,
25
+ currentDagRunId: null,
26
+ selectedDagNodeId: null,
27
+ sessionEventOffset: 0,
28
+ sessionEvents: [],
29
+ lastSnapshot: null,
30
+ dagInspectorOpen: false,
31
+ dagInspectorTab: "output",
32
+ dagInspectorWidth: null,
33
+ dagGraphViewportState: null,
34
+ dagTimelineViewportState: null,
35
+ dagNodeOutputViewportState: null,
36
+ runOutputViewportState: null,
37
+ pollingGeneration: 0,
38
+ };
39
+
40
+ /** Per-dagRunId graph viewport restore map (stable Map instance). */
41
+ export const dagGraphViewportStates = new Map();
42
+
43
+ export function isPageVisible() {
44
+ return (
45
+ typeof document === "undefined" || document.visibilityState !== "hidden"
46
+ );
47
+ }
48
+
49
+ export function detailPollDelay(status, isVisible) {
50
+ const normalizedStatus = (status ?? "").toLowerCase().replace(/-/g, "_");
51
+ if (TERMINAL_RUN_STATUSES.has(normalizedStatus)) return null;
52
+ return isVisible ? ACTIVE_DETAIL_POLL_MS : HIDDEN_POLL_MS;
53
+ }
54
+
55
+ export function dashboardPollDelay() {
56
+ return isPageVisible() ? DASHBOARD_POLL_MS : HIDDEN_POLL_MS;
57
+ }
58
+
59
+ export function updateDagGraphViewportState(
60
+ _previous,
61
+ dagRunId,
62
+ scrollLeft,
63
+ scrollTop,
64
+ ) {
65
+ return {
66
+ dagRunId,
67
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
68
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
69
+ };
70
+ }
71
+
72
+ export function captureDagGraphViewportState(previous, dagRunId, viewport) {
73
+ if (!viewport || viewport.dataset?.dagRunId !== dagRunId) return previous;
74
+ return updateDagGraphViewportState(
75
+ previous,
76
+ dagRunId,
77
+ viewport.scrollLeft,
78
+ viewport.scrollTop,
79
+ );
80
+ }
81
+
82
+ export function updateDagTimelineViewportState(
83
+ _previous,
84
+ dagRunId,
85
+ nodeId,
86
+ scrollLeft,
87
+ scrollTop,
88
+ followLatest,
89
+ ) {
90
+ return {
91
+ dagRunId,
92
+ nodeId,
93
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
94
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
95
+ followLatest: Boolean(followLatest),
96
+ };
97
+ }
98
+
99
+ // Unified follow-latest helper for DAG node output and Worker output scroll
100
+ // regions. Returns true only when the user is parked within `threshold` px of
101
+ // the bottom, mirroring the existing process-timeline behaviour.
102
+ export function computeFollowLatest(
103
+ scrollTop,
104
+ scrollHeight,
105
+ clientHeight,
106
+ threshold = OUTPUT_FOLLOW_LATEST_PX,
107
+ ) {
108
+ const top = Number(scrollTop);
109
+ const height = Number(scrollHeight);
110
+ const client = Number(clientHeight);
111
+ if (
112
+ !Number.isFinite(top) ||
113
+ !Number.isFinite(height) ||
114
+ !Number.isFinite(client)
115
+ ) {
116
+ return false;
117
+ }
118
+ if (height <= 0 || client <= 0) return false;
119
+ return height - top - client < Math.max(0, Number(threshold) || 0);
120
+ }
121
+
122
+ // Generalised viewport-state record keyed by `regionKey`, so the same helper
123
+ // serves the DAG node inspector output ("dag:<runId>:<nodeId>:output") and the
124
+ // Worker run output ("run:<runId>:output").
125
+ export function updateDagOutputViewportState(
126
+ _previous,
127
+ regionKey,
128
+ scrollLeft,
129
+ scrollTop,
130
+ followLatest,
131
+ ) {
132
+ return {
133
+ regionKey,
134
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
135
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
136
+ followLatest: Boolean(followLatest),
137
+ };
138
+ }
139
+
140
+ // Poll-render decision helpers. Initial paints always rebuild the shell; a
141
+ // non-initial refresh reuses the existing viewport/inspector DOM identity so
142
+ // active pointer-capture targets (scrollbar / resize handle) survive.
143
+ export function shouldReuseDagDetailShell(initial, hasExistingViewport) {
144
+ return !initial && Boolean(hasExistingViewport);
145
+ }
146
+
147
+ export function shouldPreserveDiagnosticsOpen(initial, existingOpen) {
148
+ if (initial) return false;
149
+ return Boolean(existingOpen);
150
+ }
151
+
152
+ // Detects an in-progress resize/scroll gesture so the poll renderer can defer
153
+ // tearing down the interactive shell until the gesture releases.
154
+ export function isInteractiveGestureActive(bodyClassList) {
155
+ const list = bodyClassList;
156
+ if (!list || typeof list.contains !== "function") return false;
157
+ return (
158
+ list.contains("is-resizing-dag-inspector") ||
159
+ list.contains("is-resizing-dag-graph")
160
+ );
161
+ }
162
+
163
+ export function stopTimers() {
164
+ uiState.pollingGeneration += 1;
165
+ if (uiState.dashboardTimer) {
166
+ clearTimeout(uiState.dashboardTimer);
167
+ uiState.dashboardTimer = null;
168
+ }
169
+ if (uiState.runPollTimer) {
170
+ clearTimeout(uiState.runPollTimer);
171
+ uiState.runPollTimer = null;
172
+ }
173
+ if (uiState.dagPollTimer) {
174
+ clearTimeout(uiState.dagPollTimer);
175
+ uiState.dagPollTimer = null;
176
+ }
177
+ if (uiState.poolTimer) {
178
+ clearTimeout(uiState.poolTimer);
179
+ uiState.poolTimer = null;
180
+ }
181
+ if (uiState.taskPollTimer) {
182
+ clearTimeout(uiState.taskPollTimer);
183
+ uiState.taskPollTimer = null;
184
+ }
185
+ uiState.currentRunId = null;
186
+ uiState.currentBatchRunId = null;
187
+ uiState.currentDagRunId = null;
188
+ uiState.selectedDagNodeId = null;
189
+ uiState.sessionEventOffset = 0;
190
+ uiState.sessionEvents = [];
191
+ uiState.runEventOffset = 0;
192
+ uiState.runEvents = [];
193
+ }
194
+
195
+ /** Pure descriptor for loading / empty / degraded / error (REQ-R5-05). */
196
+ export function viewStateDescriptor(kind, message, options = {}) {
197
+ const classByKind = {
198
+ loading: "view-state view-state-loading",
199
+ empty: "view-state view-state-empty empty",
200
+ degraded: "view-state view-state-degraded projection-fault",
201
+ error: "view-state view-state-error",
202
+ };
203
+ const role =
204
+ kind === "error" || kind === "degraded"
205
+ ? "alert"
206
+ : kind === "loading"
207
+ ? "status"
208
+ : null;
209
+ return {
210
+ kind,
211
+ className: classByKind[kind] ?? "view-state",
212
+ role,
213
+ ariaLive: kind === "loading" ? "polite" : null,
214
+ message: message ?? "",
215
+ detail: options.detail ?? null,
216
+ messageClass:
217
+ kind === "empty" ? "empty" : kind === "loading" ? "muted" : null,
218
+ };
219
+ }
220
+
221
+ /** Distinguishable loading / empty / degraded / error panel states (REQ-R5-05). */
222
+ export function renderViewState(kind, message, options = {}) {
223
+ const desc = viewStateDescriptor(kind, message, options);
224
+ const node = el("div", desc.className);
225
+ if (desc.role) node.setAttribute("role", desc.role);
226
+ if (desc.ariaLive) node.setAttribute("aria-live", desc.ariaLive);
227
+ node.appendChild(el("p", desc.messageClass, desc.message));
228
+ if (desc.detail) {
229
+ node.appendChild(el("p", "muted", desc.detail));
230
+ }
231
+ return node;
232
+ }
233
+
234
+ export function mountViewState(container, kind, message, options) {
235
+ if (!container) return null;
236
+ clearNode(container);
237
+ const node = renderViewState(kind, message, options);
238
+ container.appendChild(node);
239
+ return node;
240
+ }
241
+
242
+ export function setLastSnapshot(snapshot) {
243
+ uiState.lastSnapshot = snapshot;
244
+ }
245
+
246
+ export function setPollingGeneration(value) {
247
+ uiState.pollingGeneration = value;
248
+ }
249
+
250
+ export function bumpPollingGeneration() {
251
+ uiState.pollingGeneration += 1;
252
+ return uiState.pollingGeneration;
253
+ }