@tea-agent/loop-agent 0.32.1 → 0.33.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 (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/executors/model-routing.js +14 -4
  3. package/dist/governance/manifest-types.js +34 -7
  4. package/dist/worker/console/chat/model-resolver.js +114 -34
  5. package/dist/worker/console/chat/workspace-landing.js +58 -22
  6. package/dist/worker/console/doctor.js +1 -0
  7. package/dist/worker/console/night-aux-ticker.js +5 -0
  8. package/dist/worker/console/pi-readiness.js +26 -17
  9. package/dist/worker/console/server.js +2 -0
  10. package/dist/worker/console/static/assets/index-3R-GT3a_.js +29 -0
  11. package/dist/worker/console/static/assets/index-B0EQt_yq.css +1 -0
  12. package/dist/worker/console/static/index.html +2 -2
  13. package/dist/worker/console/static-src/night/useNightBoard.js +0 -31
  14. package/dist/worker/observability/read-model.js +106 -0
  15. package/dist/worker/observe/routes.js +19 -1
  16. package/dist/worker/observe/static/api.js +42 -3
  17. package/dist/worker/observe/static/app.js +4 -0
  18. package/dist/worker/observe/static/constants.js +10 -0
  19. package/dist/worker/observe/static/custom-select.js +567 -0
  20. package/dist/worker/observe/static/index.html +47 -6
  21. package/dist/worker/observe/static/router.js +54 -1
  22. package/dist/worker/observe/static/state.js +20 -0
  23. package/dist/worker/observe/static/styles.css +618 -30
  24. package/dist/worker/observe/static/views/dag-inspector.js +20 -17
  25. package/dist/worker/observe/static/views/dag.js +136 -59
  26. package/dist/worker/observe/static/views/dags.js +877 -0
  27. package/dist/worker/observe/static/views/dashboard.js +15 -4
  28. package/dist/workflows/dag/init-hybrid.js +58 -29
  29. package/docs/templates/harness.schema.json +5 -0
  30. package/harness.json +4 -4
  31. package/package.json +1 -1
  32. package/dist/worker/console/static/assets/index-Bpa2qrc-.js +0 -29
  33. package/dist/worker/console/static/assets/index-BqfFDdnG.css +0 -1
@@ -0,0 +1,567 @@
1
+ /** Observe UI — shared custom select (trigger + listbox).
2
+ *
3
+ * UI-only component: holds ephemeral open/active state, never writes state.js
4
+ * or persistence, and never fetches. Callers own business callbacks.
5
+ */
6
+
7
+ const MENU_GAP_PX = 7;
8
+ const VIEWPORT_PAD_PX = 8;
9
+ const MAX_VISIBLE_OPTIONS = 6;
10
+ const OPTION_HEIGHT_PX = 38;
11
+
12
+ /** @type {null | { close: (opts?: { restoreFocus?: boolean }) => void }} */
13
+ let openInstance = null;
14
+
15
+ let idSeq = 0;
16
+ function nextId(prefix) {
17
+ idSeq += 1;
18
+ return `${prefix}-${idSeq}`;
19
+ }
20
+
21
+ function safeAddListener(target, type, handler, options) {
22
+ if (!target || typeof target.addEventListener !== "function") return () => {};
23
+ target.addEventListener(type, handler, options);
24
+ return () => {
25
+ if (typeof target.removeEventListener === "function") {
26
+ target.removeEventListener(type, handler, options);
27
+ }
28
+ };
29
+ }
30
+
31
+ function optionLabel(options, value) {
32
+ const hit = options.find((o) => String(o.value) === String(value));
33
+ return hit ? String(hit.label) : value == null ? "" : String(value);
34
+ }
35
+
36
+ function clamp(n, min, max) {
37
+ return Math.min(max, Math.max(min, n));
38
+ }
39
+
40
+ /**
41
+ * @param {{
42
+ * ariaLabel: string,
43
+ * value: string,
44
+ * options: Array<{ value: string, label: string }>,
45
+ * onChange: (value: string) => void,
46
+ * className?: string,
47
+ * disabled?: boolean,
48
+ * id?: string,
49
+ * }} config
50
+ * @returns {HTMLElement}
51
+ */
52
+ export function createCustomSelect(config) {
53
+ const {
54
+ ariaLabel,
55
+ options: rawOptions = [],
56
+ onChange,
57
+ className = "",
58
+ disabled = false,
59
+ id,
60
+ } = config;
61
+ const options = Array.isArray(rawOptions) ? rawOptions.slice() : [];
62
+ let value =
63
+ config.value != null && config.value !== ""
64
+ ? String(config.value)
65
+ : options[0]
66
+ ? String(options[0].value)
67
+ : "";
68
+
69
+ const root = document.createElement("div");
70
+ root.className = ["custom-select", className].filter(Boolean).join(" ");
71
+ root.dataset.customSelect = "1";
72
+ if (id) root.id = id;
73
+
74
+ const listboxId = nextId("custom-select-listbox");
75
+ const trigger = document.createElement("button");
76
+ trigger.type = "button";
77
+ trigger.className = "custom-select-trigger";
78
+ trigger.setAttribute("aria-haspopup", "listbox");
79
+ trigger.setAttribute("aria-expanded", "false");
80
+ trigger.setAttribute("aria-controls", listboxId);
81
+ if (ariaLabel) trigger.setAttribute("aria-label", ariaLabel);
82
+ trigger.disabled = Boolean(disabled);
83
+
84
+ const valueEl = document.createElement("span");
85
+ valueEl.className = "custom-select-value";
86
+ valueEl.textContent = optionLabel(options, value);
87
+
88
+ const chevron = document.createElement("i");
89
+ chevron.className = "ri-arrow-down-s-line custom-select-chevron";
90
+ chevron.setAttribute("aria-hidden", "true");
91
+
92
+ trigger.appendChild(valueEl);
93
+ trigger.appendChild(chevron);
94
+ root.appendChild(trigger);
95
+
96
+ const menu = document.createElement("div");
97
+ menu.className = "custom-select-menu";
98
+ menu.id = listboxId;
99
+ menu.setAttribute("role", "listbox");
100
+ if (ariaLabel) menu.setAttribute("aria-label", ariaLabel);
101
+ menu.hidden = true;
102
+ menu.tabIndex = -1;
103
+ root.appendChild(menu);
104
+
105
+ /** @type {HTMLElement[]} */
106
+ let optionNodes = [];
107
+ /** @type {number} */
108
+ let activeIndex = 0;
109
+ /** @type {Array<() => void>} */
110
+ let detachGlobal = [];
111
+ let isOpen = false;
112
+
113
+ function rebuildOptions() {
114
+ while (menu.firstChild) menu.removeChild(menu.firstChild);
115
+ optionNodes = [];
116
+ for (let i = 0; i < options.length; i++) {
117
+ const opt = options[i];
118
+ const optionEl = document.createElement("div");
119
+ optionEl.className = "custom-select-option";
120
+ optionEl.setAttribute("role", "option");
121
+ optionEl.dataset.value = String(opt.value);
122
+ // Mirror raw value for integration stubs that still read `.value`.
123
+ optionEl.value = String(opt.value);
124
+ optionEl.setAttribute("data-value", String(opt.value));
125
+ optionEl.id = `${listboxId}-opt-${i}`;
126
+ const selected = String(opt.value) === String(value);
127
+ optionEl.setAttribute("aria-selected", selected ? "true" : "false");
128
+ if (selected) optionEl.classList.add("is-selected");
129
+
130
+ const label = document.createElement("span");
131
+ label.className = "custom-select-option-label";
132
+ label.textContent = String(opt.label);
133
+ optionEl.appendChild(label);
134
+
135
+ const check = document.createElement("i");
136
+ check.className = "ri-check-line custom-select-option-check";
137
+ check.setAttribute("aria-hidden", "true");
138
+ optionEl.appendChild(check);
139
+
140
+ optionEl.addEventListener("click", (event) => {
141
+ if (event && typeof event.preventDefault === "function") {
142
+ event.preventDefault();
143
+ }
144
+ if (event && typeof event.stopPropagation === "function") {
145
+ event.stopPropagation();
146
+ }
147
+ commitValue(String(opt.value));
148
+ });
149
+ optionEl.addEventListener("mouseenter", () => {
150
+ setActiveIndex(i, { scroll: false });
151
+ });
152
+
153
+ menu.appendChild(optionEl);
154
+ optionNodes.push(optionEl);
155
+ }
156
+ // Keep a hidden native-like options mirror for limited test stubs that
157
+ // still inspect OPTION children; not used for interaction.
158
+ // (Not a focusable <select>; no native menu.)
159
+ }
160
+
161
+ function syncSelectedClasses() {
162
+ for (const node of optionNodes) {
163
+ const selected = node.dataset.value === String(value);
164
+ node.setAttribute("aria-selected", selected ? "true" : "false");
165
+ if (selected) node.classList.add("is-selected");
166
+ else node.classList.remove("is-selected");
167
+ }
168
+ valueEl.textContent = optionLabel(options, value);
169
+ }
170
+
171
+ function setActiveIndex(index, { scroll = true } = {}) {
172
+ if (optionNodes.length === 0) {
173
+ activeIndex = 0;
174
+ return;
175
+ }
176
+ activeIndex = clamp(index, 0, optionNodes.length - 1);
177
+ for (let i = 0; i < optionNodes.length; i++) {
178
+ const node = optionNodes[i];
179
+ if (i === activeIndex) {
180
+ node.classList.add("is-active");
181
+ if (scroll && typeof node.scrollIntoView === "function") {
182
+ try {
183
+ node.scrollIntoView({ block: "nearest" });
184
+ } catch {
185
+ node.scrollIntoView();
186
+ }
187
+ }
188
+ } else {
189
+ node.classList.remove("is-active");
190
+ }
191
+ }
192
+ const active = optionNodes[activeIndex];
193
+ if (active) {
194
+ menu.setAttribute("aria-activedescendant", active.id);
195
+ }
196
+ }
197
+
198
+ function indexOfValue(v) {
199
+ const idx = options.findIndex((o) => String(o.value) === String(v));
200
+ return idx >= 0 ? idx : 0;
201
+ }
202
+
203
+ function positionMenu() {
204
+ // Prefer absolute positioning within the root so sticky bars keep
205
+ // correct stacking without needing a body portal.
206
+ menu.style.position = "absolute";
207
+ menu.style.left = "0";
208
+ menu.style.right = "auto";
209
+ menu.style.minWidth = "100%";
210
+ menu.style.width = "max-content";
211
+ menu.style.maxWidth = "";
212
+ menu.style.top = "";
213
+ menu.style.bottom = "";
214
+ menu.classList.remove("is-open-up");
215
+ menu.classList.add("is-open-down");
216
+
217
+ const triggerRect =
218
+ typeof trigger.getBoundingClientRect === "function"
219
+ ? trigger.getBoundingClientRect()
220
+ : null;
221
+ const viewportW =
222
+ typeof window !== "undefined" && window.innerWidth
223
+ ? window.innerWidth
224
+ : 1024;
225
+ const viewportH =
226
+ typeof window !== "undefined" && window.innerHeight
227
+ ? window.innerHeight
228
+ : 768;
229
+
230
+ const maxMenuHeight = MAX_VISIBLE_OPTIONS * OPTION_HEIGHT_PX + 8;
231
+ menu.style.maxHeight = `${maxMenuHeight}px`;
232
+
233
+ if (!triggerRect) {
234
+ menu.style.top = `calc(100% + ${MENU_GAP_PX}px)`;
235
+ return;
236
+ }
237
+
238
+ const spaceBelow = viewportH - triggerRect.bottom - VIEWPORT_PAD_PX;
239
+ const spaceAbove = triggerRect.top - VIEWPORT_PAD_PX;
240
+ const openUp =
241
+ spaceBelow < Math.min(maxMenuHeight, OPTION_HEIGHT_PX * 3) &&
242
+ spaceAbove > spaceBelow;
243
+
244
+ if (openUp) {
245
+ menu.classList.remove("is-open-down");
246
+ menu.classList.add("is-open-up");
247
+ menu.style.top = "auto";
248
+ menu.style.bottom = `calc(100% + ${MENU_GAP_PX}px)`;
249
+ menu.style.maxHeight = `${Math.min(maxMenuHeight, Math.max(OPTION_HEIGHT_PX, spaceAbove - MENU_GAP_PX))}px`;
250
+ } else {
251
+ menu.style.top = `calc(100% + ${MENU_GAP_PX}px)`;
252
+ menu.style.bottom = "auto";
253
+ menu.style.maxHeight = `${Math.min(maxMenuHeight, Math.max(OPTION_HEIGHT_PX, spaceBelow - MENU_GAP_PX))}px`;
254
+ }
255
+
256
+ // Clamp width to viewport so narrow screens do not overflow.
257
+ const maxWidth = Math.max(80, viewportW - VIEWPORT_PAD_PX * 2);
258
+ menu.style.maxWidth = `${maxWidth}px`;
259
+ // If the root is near the right edge, pin the menu to the trigger's
260
+ // right side so it grows leftward within the viewport.
261
+ const overflowRight = triggerRect.left + triggerRect.width > viewportW - VIEWPORT_PAD_PX;
262
+ if (overflowRight || triggerRect.left < VIEWPORT_PAD_PX) {
263
+ const left = clamp(
264
+ triggerRect.left,
265
+ VIEWPORT_PAD_PX,
266
+ viewportW - VIEWPORT_PAD_PX - Math.min(triggerRect.width, maxWidth),
267
+ );
268
+ // Switch to fixed coordinates only when we need viewport clamping
269
+ // beyond the local anchor box.
270
+ if (left !== triggerRect.left || triggerRect.width > maxWidth) {
271
+ menu.style.position = "fixed";
272
+ menu.style.left = `${left}px`;
273
+ menu.style.minWidth = `${Math.min(triggerRect.width, maxWidth)}px`;
274
+ menu.style.width = "auto";
275
+ if (openUp) {
276
+ menu.style.top = "auto";
277
+ menu.style.bottom = `${viewportH - triggerRect.top + MENU_GAP_PX}px`;
278
+ } else {
279
+ menu.style.top = `${triggerRect.bottom + MENU_GAP_PX}px`;
280
+ menu.style.bottom = "auto";
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ function clearGlobalListeners() {
287
+ for (const off of detachGlobal) {
288
+ try {
289
+ off();
290
+ } catch {
291
+ // ignore stub cleanup failures
292
+ }
293
+ }
294
+ detachGlobal = [];
295
+ }
296
+
297
+ function closeMenu({ restoreFocus = false } = {}) {
298
+ if (!isOpen) {
299
+ if (restoreFocus && typeof trigger.focus === "function") {
300
+ try {
301
+ trigger.focus({ preventScroll: true });
302
+ } catch {
303
+ trigger.focus();
304
+ }
305
+ }
306
+ return;
307
+ }
308
+ isOpen = false;
309
+ menu.hidden = true;
310
+ trigger.setAttribute("aria-expanded", "false");
311
+ root.classList.remove("is-open");
312
+ menu.classList.remove("is-open-up", "is-open-down");
313
+ menu.removeAttribute("aria-activedescendant");
314
+ clearGlobalListeners();
315
+ if (openInstance && openInstance.root === root) {
316
+ openInstance = null;
317
+ }
318
+ if (restoreFocus && typeof trigger.focus === "function") {
319
+ try {
320
+ trigger.focus({ preventScroll: true });
321
+ } catch {
322
+ trigger.focus();
323
+ }
324
+ }
325
+ }
326
+
327
+ function openMenu() {
328
+ if (trigger.disabled || options.length === 0) return;
329
+ if (openInstance && openInstance.root !== root) {
330
+ openInstance.close({ restoreFocus: false });
331
+ }
332
+ isOpen = true;
333
+ menu.hidden = false;
334
+ trigger.setAttribute("aria-expanded", "true");
335
+ root.classList.add("is-open");
336
+ setActiveIndex(indexOfValue(value), { scroll: true });
337
+ positionMenu();
338
+
339
+ const onPointerDown = (event) => {
340
+ const target = event?.target;
341
+ if (!target) return;
342
+ const inRoot =
343
+ typeof root.contains === "function"
344
+ ? root.contains(target)
345
+ : target === root || target === trigger || target === menu;
346
+ if (inRoot) return;
347
+ // closest walk for limited stubs
348
+ if (typeof target.closest === "function") {
349
+ if (target.closest("[data-custom-select]")) {
350
+ // Another custom-select trigger: let that instance open,
351
+ // which will close us via openInstance single-open.
352
+ const other = target.closest("[data-custom-select]");
353
+ if (other === root) return;
354
+ }
355
+ }
356
+ closeMenu({ restoreFocus: false });
357
+ };
358
+ const onResize = () => {
359
+ if (isOpen) positionMenu();
360
+ };
361
+ const onScroll = (event) => {
362
+ if (!isOpen) return;
363
+ const t = event?.target;
364
+ // Ignore scrolls inside the menu itself.
365
+ if (t && (t === menu || (typeof menu.contains === "function" && menu.contains(t)))) {
366
+ return;
367
+ }
368
+ positionMenu();
369
+ };
370
+ // Sole open-state keyboard owner: document capture. Trigger only opens
371
+ // while closed so capture→target never double-steps or re-opens.
372
+ const onKeyDown = (event) => {
373
+ if (!isOpen) return;
374
+ const key = event?.key;
375
+ if (!key) return;
376
+ const stop = () => {
377
+ if (typeof event.preventDefault === "function") event.preventDefault();
378
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
379
+ };
380
+ if (key === "Escape") {
381
+ stop();
382
+ closeMenu({ restoreFocus: true });
383
+ return;
384
+ }
385
+ if (key === "Tab") {
386
+ // Close without trapping focus; browser moves focus naturally.
387
+ closeMenu({ restoreFocus: false });
388
+ return;
389
+ }
390
+ if (key === "ArrowDown") {
391
+ stop();
392
+ setActiveIndex(activeIndex + 1);
393
+ return;
394
+ }
395
+ if (key === "ArrowUp") {
396
+ stop();
397
+ setActiveIndex(activeIndex - 1);
398
+ return;
399
+ }
400
+ if (key === "Home") {
401
+ stop();
402
+ setActiveIndex(0);
403
+ return;
404
+ }
405
+ if (key === "End") {
406
+ stop();
407
+ setActiveIndex(optionNodes.length - 1);
408
+ return;
409
+ }
410
+ if (key === "Enter" || key === " ") {
411
+ stop();
412
+ const opt = options[activeIndex];
413
+ if (opt) commitValue(String(opt.value));
414
+ }
415
+ };
416
+
417
+ detachGlobal.push(safeAddListener(document, "pointerdown", onPointerDown, true));
418
+ // Fallback for environments without pointer events.
419
+ detachGlobal.push(safeAddListener(document, "mousedown", onPointerDown, true));
420
+ detachGlobal.push(safeAddListener(window, "resize", onResize));
421
+ detachGlobal.push(safeAddListener(document, "scroll", onScroll, true));
422
+ detachGlobal.push(safeAddListener(document, "keydown", onKeyDown, true));
423
+
424
+ openInstance = {
425
+ root,
426
+ close: closeMenu,
427
+ };
428
+
429
+ if (typeof menu.focus === "function") {
430
+ try {
431
+ menu.focus({ preventScroll: true });
432
+ } catch {
433
+ menu.focus();
434
+ }
435
+ }
436
+ }
437
+
438
+ function commitValue(next) {
439
+ const nextValue = String(next);
440
+ const prev = value;
441
+ value = nextValue;
442
+ syncSelectedClasses();
443
+ closeMenu({ restoreFocus: true });
444
+ if (nextValue !== String(prev) && typeof onChange === "function") {
445
+ onChange(nextValue);
446
+ } else if (nextValue === String(prev) && typeof onChange === "function") {
447
+ // Still notify when user explicitly re-selects the same option so
448
+ // callers that rely on change semantics stay consistent; history
449
+ // page clamps hash and no-ops identical query.
450
+ onChange(nextValue);
451
+ }
452
+ }
453
+
454
+ function toggleMenu() {
455
+ if (isOpen) closeMenu({ restoreFocus: true });
456
+ else openMenu();
457
+ }
458
+
459
+ trigger.addEventListener("click", (event) => {
460
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
461
+ if (event && typeof event.stopPropagation === "function") event.stopPropagation();
462
+ if (trigger.disabled) return;
463
+ toggleMenu();
464
+ });
465
+
466
+ trigger.addEventListener("keydown", (event) => {
467
+ if (trigger.disabled) return;
468
+ // Open-state keys are owned exclusively by the document capture listener.
469
+ if (isOpen) return;
470
+ const key = event?.key;
471
+ if (key === "Enter" || key === " ") {
472
+ if (typeof event.preventDefault === "function") event.preventDefault();
473
+ openMenu();
474
+ return;
475
+ }
476
+ if (key === "ArrowDown" || key === "ArrowUp") {
477
+ if (typeof event.preventDefault === "function") event.preventDefault();
478
+ openMenu();
479
+ }
480
+ });
481
+
482
+ // Support limited test stubs that set `.value` then fire a `change` handler
483
+ // (legacy native <select> contract used by inspector unit tests).
484
+ Object.defineProperty(root, "value", {
485
+ configurable: true,
486
+ enumerable: true,
487
+ get() {
488
+ return value;
489
+ },
490
+ set(next) {
491
+ value = next == null ? "" : String(next);
492
+ syncSelectedClasses();
493
+ },
494
+ });
495
+ Object.defineProperty(root, "disabled", {
496
+ configurable: true,
497
+ enumerable: true,
498
+ get() {
499
+ return Boolean(trigger.disabled);
500
+ },
501
+ set(next) {
502
+ trigger.disabled = Boolean(next);
503
+ if (trigger.disabled) {
504
+ root.classList.add("is-disabled");
505
+ closeMenu({ restoreFocus: false });
506
+ } else {
507
+ root.classList.remove("is-disabled");
508
+ }
509
+ // Mirror disabled onto root for query paths that check el.disabled.
510
+ root.setAttribute("aria-disabled", trigger.disabled ? "true" : "false");
511
+ if (trigger.disabled) root.dataset.disabled = "true";
512
+ else delete root.dataset.disabled;
513
+ },
514
+ });
515
+
516
+ // Also mirror disabled/value onto the trigger for attribute-based queries.
517
+ Object.defineProperty(trigger, "value", {
518
+ configurable: true,
519
+ enumerable: true,
520
+ get() {
521
+ return value;
522
+ },
523
+ set(next) {
524
+ root.value = next;
525
+ },
526
+ });
527
+
528
+ root.addEventListener("change", () => {
529
+ // Invoked by stubs via handlers.change() after assigning .value.
530
+ if (typeof onChange === "function") onChange(String(value));
531
+ });
532
+
533
+ // Public control surface for callers that re-render with new props.
534
+ root.setCustomSelectDisabled = (next) => {
535
+ root.disabled = Boolean(next);
536
+ };
537
+ root.setCustomSelectValue = (next) => {
538
+ root.value = next;
539
+ };
540
+ root.getCustomSelectTrigger = () => trigger;
541
+ root.closeCustomSelect = () => closeMenu({ restoreFocus: false });
542
+
543
+ // Forward data-* attributes helpers: allow callers to stamp business hooks
544
+ // on the trigger (e.g. data-dags-page-size) after creation.
545
+ root.getCustomSelectRoot = () => root;
546
+
547
+ if (disabled) root.disabled = true;
548
+ rebuildOptions();
549
+ syncSelectedClasses();
550
+ setActiveIndex(indexOfValue(value), { scroll: false });
551
+
552
+ // Compatibility surface for integration stubs that previously walked native
553
+ // <option> children under a <select>. Not used for real DOM interaction.
554
+ root.__customSelectOptions = () =>
555
+ options.map((opt) => ({
556
+ tagName: "OPTION",
557
+ value: String(opt.value),
558
+ textContent: String(opt.label),
559
+ }));
560
+
561
+ return root;
562
+ }
563
+
564
+ /** Close any open custom select (e.g. before view teardown). */
565
+ export function closeOpenCustomSelect() {
566
+ if (openInstance) openInstance.close({ restoreFocus: false });
567
+ }
@@ -15,7 +15,7 @@
15
15
  <body data-shell="observe-console">
16
16
  <a class="skip-link" href="#main-content">跳到主内容</a>
17
17
  <nav class="embedded-operator-nav" aria-label="Operator 导航">
18
- <a href="/">返回操作台</a>
18
+ <a href="/#/tasks">返回操作台</a>
19
19
  <span aria-current="page">观测</span>
20
20
  </nav>
21
21
  <div id="app">
@@ -185,23 +185,64 @@
185
185
  ></aside>
186
186
  </section>
187
187
 
188
+ <section data-view="dags" id="view-dags" class="view view-dags" hidden>
189
+ <header class="page-header dags-page-header">
190
+ <div class="view-heading page-header-heading">
191
+ <p class="view-kicker">DAG History</p>
192
+ <h2 id="dags-page-title" tabindex="-1">DAG 运行历史</h2>
193
+ <p>全部正式 DAG 执行记录(不含 dry-run / init-only)。</p>
194
+ </div>
195
+ </header>
196
+ <div
197
+ id="dags-degraded"
198
+ class="panel dags-degraded-banner"
199
+ hidden
200
+ ></div>
201
+ <section
202
+ id="dags-status"
203
+ class="panel"
204
+ aria-label="历史列表状态"
205
+ ></section>
206
+ <section
207
+ id="dags-table"
208
+ class="panel panel-table dags-table-panel"
209
+ aria-label="DAG 运行历史表格"
210
+ ></section>
211
+ <section
212
+ id="dags-meta"
213
+ class="panel dags-meta-panel"
214
+ aria-label="分页与刷新"
215
+ ></section>
216
+ <div
217
+ id="dags-copy-announce"
218
+ class="sr-only"
219
+ aria-live="polite"
220
+ aria-atomic="true"
221
+ ></div>
222
+ <div
223
+ id="dags-copy-toast"
224
+ class="dags-copy-toast"
225
+ role="status"
226
+ aria-live="polite"
227
+ hidden
228
+ ></div>
229
+ </section>
230
+
188
231
  <section data-view="night" id="view-night" class="view" hidden>
189
232
  <header class="page-header">
190
233
  <div class="view-heading page-header-heading">
191
234
  <p class="view-kicker">Night Scheduler</p>
192
235
  <h2>夜间任务</h2>
193
236
  </div>
194
- <div class="cta-row">
237
+ <div class="page-header-actions" aria-label="夜间页操作">
195
238
  <button
196
239
  type="button"
197
240
  id="night-refresh-btn"
198
- class="btn btn-secondary"
241
+ class="page-action-btn"
199
242
  >
200
243
  刷新
201
244
  </button>
202
- <a class="btn btn-secondary" href="/?workspace=night"
203
- >打开 Operate 面板</a
204
- >
245
+ <a class="page-action-btn" href="/#/night">打开操作台 · 夜间</a>
205
246
  </div>
206
247
  </header>
207
248
  <section