dsh-agent-team-ui-testing 0.1.1 → 0.1.2

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -35
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3709 -134
  6. package/context-menu.js +134 -0
  7. package/core/clock.js +88 -0
  8. package/core/config-store.js +566 -0
  9. package/core/config.js +892 -0
  10. package/core/contracts.js +240 -0
  11. package/core/errors.js +156 -0
  12. package/core/evidence.js +141 -0
  13. package/core/geometry.js +217 -0
  14. package/core/identity.js +180 -0
  15. package/core/index.js +24 -0
  16. package/core/p0.js +1101 -0
  17. package/core/readiness.js +175 -0
  18. package/core/registry.js +239 -0
  19. package/core/tab-access.js +1350 -0
  20. package/docs/adapters.md +160 -0
  21. package/docs/architecture.md +44 -9
  22. package/docs/capability-tiers.md +20 -4
  23. package/docs/capture-p0.md +39 -2
  24. package/docs/diagnose-reason-codes.md +157 -0
  25. package/docs/element-picker.md +24 -6
  26. package/docs/getting-started.md +47 -1
  27. package/docs/index.md +10 -7
  28. package/docs/multi-tab.md +55 -20
  29. package/docs/network-p0.md +2 -2
  30. package/docs/panel-context-menu.md +41 -0
  31. package/docs/t2-structured-gui.md +2 -2
  32. package/docs/t3-vision.md +6 -1
  33. package/docs/t4-dynamic-behavior.md +1 -0
  34. package/docs/tools.md +65 -7
  35. package/docs/verification.md +26 -2
  36. package/docs/viewport-control.md +21 -8
  37. package/index.js +3263 -251
  38. package/integration/cross-environment.js +1535 -0
  39. package/integration/diagnose.js +945 -0
  40. package/integration/tab-principal.js +493 -0
  41. package/locale/en.json +1 -1
  42. package/locale/zh.json +1 -1
  43. package/network-p0.js +24 -7
  44. package/package.json +4 -3
  45. package/select-control.js +298 -0
  46. package/skills/ui-testing-common/SKILL.md +54 -0
  47. package/skills/ui-testing-fake/SKILL.md +43 -0
  48. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  49. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  50. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  51. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  52. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  53. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  54. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  56. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  57. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  59. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  60. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  62. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  63. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  64. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  65. package/targets/fake/adapter.js +1163 -0
  66. package/targets/fake/fixtures.js +177 -0
  67. package/targets/web/adapter.js +1231 -0
  68. package/targets/web/provider.js +415 -0
  69. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  70. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  71. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  72. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  73. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  74. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
package/client.js CHANGED
@@ -3,21 +3,28 @@ window.__ModuleLoader__.load({
3
3
  factory(require) {
4
4
  const React = require('react');
5
5
  const h = React.createElement;
6
+ // Portal target for the diagnostics overlay: the DSH settings modal is its own layer, so the
7
+ // overlay has to live at <body> level (with a high z-index) to win the stacking order.
8
+ let reactDom = null;
9
+ try { reactDom = require('react-dom'); } catch { reactDom = null; }
6
10
 
7
11
  const ID = 'ui-testing-browser';
12
+ /** 面板构建标识:刷新后用它确认浏览器里跑的是哪一份 client.js(排查「改了没生效」)。 */
13
+ const CLIENT_BUILD = '2026-11-11T16:20+08:00·address-history+go';
8
14
  const KIND = 'debug-view';
9
15
  const TITLE = '调试视图';
10
16
  /** 可选运行环境;本期只有 Web 视图可用,其余置灰。 */
11
17
  const ENVIRONMENTS = [
12
- { id: 'web', label: 'Web 视图', ready: true },
13
- { id: 'windows', label: 'Windows 视图', ready: false },
14
- { id: 'android', label: 'Android 视图', ready: false },
15
- { id: 'linux', label: 'Linux 视图', ready: false },
16
- { id: 'macos', label: 'macOS 视图', ready: false },
17
- { id: 'tui', label: 'TUI 视图', ready: false },
18
- { id: 'harmonyos', label: 'HarmonyOS 视图', ready: false },
19
- { id: 'game', label: '游戏视图', ready: false },
20
- { id: 'three-d', label: '3D 视图', ready: false },
18
+ { id: 'web', label: 'Web 视图', short: 'Web', ready: true },
19
+ { id: 'windows', label: 'Windows 视图', short: 'Windows', ready: false },
20
+ { id: 'android', label: 'Android 视图', short: 'Android', ready: false },
21
+ { id: 'linux', label: 'Linux 视图', short: 'Linux', ready: false },
22
+ { id: 'macos', label: 'macOS 视图', short: 'macOS', ready: false },
23
+ { id: 'tui', label: 'TUI 视图', short: 'TUI', ready: false },
24
+ { id: 'harmonyos', label: 'HarmonyOS 视图', short: 'HarmonyOS', ready: false },
25
+ { id: 'game', label: '游戏视图', short: '游戏', ready: false },
26
+ { id: 'three-d', label: '3D 视图', short: '3D', ready: false },
27
+ { id: 'miniprogram', label: '小程序视图', short: '小程序', ready: false },
21
28
  ];
22
29
  /** 与 Host 半边的运行时通信(同源,走应用自身的鉴权)。 */
23
30
  const HOST_BASE = '/plugins/ui-testing';
@@ -45,6 +52,81 @@ window.__ModuleLoader__.load({
45
52
  });
46
53
  }
47
54
 
55
+ /**
56
+ * Address history: a bounded LIFO stack of the last visited page addresses.
57
+ *
58
+ * The stack is panel-local (localStorage, shared by the panels of this GUI origin) and keeps
59
+ * the newest entry at index 0. Only real HTTP(S) addresses are stored: `about:blank`, empty
60
+ * drafts and app-internal addresses never enter the list. Revisiting a URL moves it back to
61
+ * the front instead of duplicating it. These are pure helpers so the contract test can run the
62
+ * real implementations rather than a copy.
63
+ */
64
+ const ADDRESS_HISTORY_LIMIT = 7;
65
+ const ADDRESS_HISTORY_KEY = 'dsh-agent-team-ui-testing:address-history:v1';
66
+ function addressHistoryNormalize(list, limit = ADDRESS_HISTORY_LIMIT) {
67
+ const out = [];
68
+ for (const item of Array.isArray(list) ? list : []) {
69
+ const value = typeof item === 'string' ? item.trim() : '';
70
+ if (!/^https?:\/\//i.test(value) || out.includes(value)) continue;
71
+ out.push(value);
72
+ if (out.length >= limit) break;
73
+ }
74
+ return out;
75
+ }
76
+ function addressHistoryPush(list, raw, limit = ADDRESS_HISTORY_LIMIT) {
77
+ const value = typeof raw === 'string' ? raw.trim() : '';
78
+ const current = addressHistoryNormalize(list, limit);
79
+ if (!/^https?:\/\//i.test(value)) return current;
80
+ return addressHistoryNormalize([value].concat(current), limit);
81
+ }
82
+ function addressHistoryLabel(raw) {
83
+ const value = String(raw || '');
84
+ try {
85
+ const parsed = new URL(value);
86
+ const path = (parsed.pathname || '/') + (parsed.search || '');
87
+ return String(parsed.host || value) + (path === '/' ? '' : path);
88
+ } catch { return value; }
89
+ }
90
+ /** Local storage accessor: a privacy mode that throws must not break the address bar. */
91
+ function addressHistoryStorage() {
92
+ try { return typeof window !== 'undefined' && window.localStorage ? window.localStorage : null; } catch { return null; }
93
+ }
94
+ function addressHistoryRead(storage, limit = ADDRESS_HISTORY_LIMIT) {
95
+ try {
96
+ if (!storage) return [];
97
+ return addressHistoryNormalize(JSON.parse(storage.getItem(ADDRESS_HISTORY_KEY) || '[]'), limit);
98
+ } catch { return []; }
99
+ }
100
+ function addressHistoryWrite(storage, list) {
101
+ try {
102
+ if (!storage) return false;
103
+ storage.setItem(ADDRESS_HISTORY_KEY, JSON.stringify(addressHistoryNormalize(list)));
104
+ return true;
105
+ } catch { return false; }
106
+ }
107
+
108
+ /**
109
+ * Human-foreground claims (P0-D `browser_annotation`, docs/viewport-control.md).
110
+ *
111
+ * A mount claims "I am displaying my selected tab and I am visible"; the Host turns that into a
112
+ * ~1.5s freshness window, which is what lets the Agent draw an annotation a human can actually
113
+ * see. It is a *display* fact only: no claim means the Agent's `draw` is refused, never that the
114
+ * page is absent. Several panels may legitimately display at once (main column + side seat), so
115
+ * this is a set, not a single flag.
116
+ */
117
+ const guiForegroundClaims = new Set();
118
+ let guiForegroundSerial = 0;
119
+ function newGuiForegroundClaim() {
120
+ guiForegroundSerial += 1;
121
+ return 'fg:' + guiForegroundSerial;
122
+ }
123
+ /** Visible claims *and* a GUI page that is not backgrounded — otherwise nothing is displayed. */
124
+ function guiForegroundActive() {
125
+ if (guiForegroundClaims.size === 0) return false;
126
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return false;
127
+ return true;
128
+ }
129
+
48
130
  function scopeHeaders(scope) {
49
131
  if (!scope) return {};
50
132
  const headers = { 'X-UI-Testing-Scope-Version': SCOPE_VERSION };
@@ -59,6 +141,10 @@ window.__ModuleLoader__.load({
59
141
  } else {
60
142
  headers['X-UI-Testing-Scope'] = 'recent';
61
143
  }
144
+ // "This runtime is my selected tab and I am visible" (docs/viewport-control.md): only sent
145
+ // while this mount really displays it, so the Host's window expires when the user switches
146
+ // tabs, closes the panel or backgrounds the GUI.
147
+ if (guiForegroundActive()) headers['X-UI-Testing-Gui-Foreground'] = '1';
62
148
  return headers;
63
149
  }
64
150
 
@@ -95,6 +181,97 @@ window.__ModuleLoader__.load({
95
181
  return payload;
96
182
  }
97
183
 
184
+ /* ---- Host config transport (M4) ------------------------------------------------------
185
+ * The Host revision is the only truth for the settings page and the panel environment
186
+ * selector. Every helper resolves with the Host payload or throws an Error carrying `.code`
187
+ * (`CONFIG_CONFLICT`, `CONFIG_UNAVAILABLE`, ...); callers render the failure and never fake a
188
+ * saved state. `scope` is `null` on the settings page (legacy/default, no scope headers).
189
+ * ------------------------------------------------------------------------------------- */
190
+ /**
191
+ * Upper bound for one config read, covering **both** the response headers and the JSON body.
192
+ * Without it a connection that never finishes would keep the poll's in-flight marker set
193
+ * forever: no new request, no stale marker, a page that looks frozen (M5).
194
+ */
195
+ const HOST_CONFIG_TIMEOUT_MS = 10000;
196
+
197
+ async function requestHostConfig(method, path, body, options) {
198
+ const scope = options && options.scope;
199
+ const outerSignal = options && options.signal;
200
+ const timeoutMs = options && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
201
+ ? Number(options.timeoutMs)
202
+ : 0;
203
+ const headers = Object.assign({}, scopeHeaders(scope));
204
+ // One controller per request: it carries its own deadline, and the caller's lifecycle signal
205
+ // (unmount) is forwarded into it so a hanging read is aborted on both paths.
206
+ const controller = timeoutMs && typeof AbortController === 'function' ? new AbortController() : null;
207
+ let timedOut = false;
208
+ let timer = null;
209
+ const onOuterAbort = () => { if (controller) controller.abort(); };
210
+ if (controller) {
211
+ if (outerSignal) {
212
+ if (outerSignal.aborted) controller.abort();
213
+ else outerSignal.addEventListener('abort', onOuterAbort, { once: true });
214
+ }
215
+ timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
216
+ }
217
+ const init = {
218
+ method,
219
+ credentials: 'same-origin',
220
+ headers,
221
+ signal: controller ? controller.signal : outerSignal,
222
+ cache: 'no-store',
223
+ };
224
+ if (body !== undefined) {
225
+ headers['content-type'] = 'application/json';
226
+ init.body = JSON.stringify(body);
227
+ }
228
+ try {
229
+ const response = await fetch(HOST_BASE + path, init);
230
+ // `response.json()` is a second, separate wait: keep it inside the same deadline.
231
+ const payload = await response.json().catch(() => ({}));
232
+ if (!response.ok || payload.ok === false) {
233
+ const error = new Error(payload.error || ('配置请求失败:' + response.status));
234
+ error.code = payload.code || (response.status === 404 ? 'CONFIG_UNAVAILABLE' : 'CONFIG_REQUEST_FAILED');
235
+ error.status = response.status;
236
+ error.payload = payload;
237
+ throw error;
238
+ }
239
+ return payload;
240
+ } catch (error) {
241
+ if (timedOut) {
242
+ // 超时是一个可渲染的失败(→ stale),而不是静默的悬挂。
243
+ const timeoutError = new Error('配置请求超时(' + timeoutMs + 'ms)');
244
+ timeoutError.code = 'CONFIG_TIMEOUT';
245
+ timeoutError.timeout = true;
246
+ throw timeoutError;
247
+ }
248
+ throw error;
249
+ } finally {
250
+ if (timer !== null) clearTimeout(timer);
251
+ if (outerSignal && controller) outerSignal.removeEventListener('abort', onOuterAbort);
252
+ }
253
+ }
254
+
255
+ /** GET /config — `view` is `global` (settings page) or `session` (panel). */
256
+ function getHostConfig(scope, view, signal, timeoutMs) {
257
+ return requestHostConfig(
258
+ 'GET',
259
+ '/config?view=' + encodeURIComponent(view || 'global'),
260
+ undefined,
261
+ { scope, signal, timeoutMs: timeoutMs || HOST_CONFIG_TIMEOUT_MS },
262
+ );
263
+ }
264
+
265
+ /** PUT /config — CAS write; resolves with the Host revision, never assumes success. */
266
+ function putHostConfig(patch, expectedRevision, scope) {
267
+ return requestHostConfig('PUT', '/config', { expectedRevision, scope: 'global', patch }, { scope });
268
+ }
269
+
270
+ /** POST /config/select — session environment selection for this panel scope. */
271
+ function selectHostEnvironment(environment, expectedRevision, scope) {
272
+ return requestHostConfig('POST', '/config/select', { scope: 'session', environment, expectedRevision }, { scope });
273
+ }
274
+
98
275
  /** The Host's cold/recent binding (ui.runtimeId) for a scope-bearing response. */
99
276
  function runtimeIdOf(payload) {
100
277
  if (!payload || typeof payload !== 'object') return '';
@@ -188,6 +365,7 @@ window.__ModuleLoader__.load({
188
365
  harmonyos: { preset: 'phone', fit: true },
189
366
  game: { preset: 'wide', fit: true },
190
367
  'three-d': { preset: 'wide', fit: true },
368
+ miniprogram: { preset: 'phone', fit: true },
191
369
  });
192
370
  function envDefaultSize(id) {
193
371
  const def = ENV_DEFAULTS[id] || ENV_DEFAULTS.web;
@@ -325,6 +503,13 @@ window.__ModuleLoader__.load({
325
503
  title: pickCollapseText(page.title) || null,
326
504
  },
327
505
  frameId: source.frameId === undefined || source.frameId === null ? null : String(source.frameId),
506
+ // 嵌套 frame 命中:Host 只在子 frame 上给 frame,selector 属于该 frame 的文档。
507
+ frame: source.frame && source.frame.main === false
508
+ ? {
509
+ id: pickCollapseText(source.frame.id) || null,
510
+ url: pickCollapseText(source.frame.url) || null,
511
+ }
512
+ : null,
328
513
  outerHTML: pickCollapseText(source.outerHTML) || null,
329
514
  outerHTMLTruncated: source.outerHTMLTruncated === true,
330
515
  outerHTMLLength: finite(source.outerHTMLLength),
@@ -372,6 +557,8 @@ window.__ModuleLoader__.load({
372
557
  lines.push('rect(文档坐标): x=' + pickNumberText(rect.x) + ', y=' + pickNumberText(rect.y)
373
558
  + ', ' + pickNumberText(rect.width) + '×' + pickNumberText(rect.height));
374
559
  if (payload.page && payload.page.url) lines.push('page: ' + payload.page.url);
560
+ // 嵌套 frame 的 selector 只在那个 frame 的文档里有效:不写出来,模型会当成主文档选择器。
561
+ if (payload.frame && payload.frame.url) lines.push('frame: ' + payload.frame.url + '(selector 在该 frame 文档内有效)');
375
562
  if (payload.outerHTMLTruncated) {
376
563
  const shown = Math.max(0, Array.from(payload.outerHTML || '').length - 1);
377
564
  const total = payload.outerHTMLLength === null ? '?' : String(payload.outerHTMLLength);
@@ -752,7 +939,7 @@ window.__ModuleLoader__.load({
752
939
  const message = String((error && error.message) || '');
753
940
  if (code === 'UNSUPPORTED' || code === 'NOT_FOUND' || code === 'TOOL_NOT_FOUND'
754
941
  || /未知指令|unknown action|unsupported|not\s*found/i.test(message)) {
755
- return '当前 Host 不支持选元素,请重启 DSH 后刷新面板';
942
+ return '当前宿主不支持选元素,请重启 DSH 后刷新面板';
756
943
  }
757
944
  return '获取元素失败,请稍后重试';
758
945
  }
@@ -1097,6 +1284,82 @@ window.__ModuleLoader__.load({
1097
1284
  * layout), natural* are the decoded frame pixels. The host window DPR never enters this
1098
1285
  * formula: event.clientX/Y, getBoundingClientRect and the target viewport are all CSS px.
1099
1286
  */
1287
+ /** 单选 <select> 面板浮层的几何参数(纯数据;契约测试按源码抽取校验)。 */
1288
+ const SELECT_POP_GEOMETRY = {
1289
+ itemHeight: 26, minWidth: 160, maxWidth: 280, edge: 8, gap: 4, pad: 4, maxVisibleItems: 12,
1290
+ };
1291
+ /** 点击与按下点之间的最大偏移:超过就算拖拽,不弹浮层。 */
1292
+ const SELECT_CLICK_SLOP_PX = 4;
1293
+
1294
+ /**
1295
+ * 原生 <select> 浮层的定位(纯函数,无 DOM)。
1296
+ *
1297
+ * 输入:点击点在画面框内的 CSS px、要显示的行数、画面框的可用宽高;输出浮层的
1298
+ * left/top/width/maxHeight 以及是否上翻/左移。规则:
1299
+ * ① 优先在点击点下方展开;② 下方放不下、上方放得下 → 上翻;
1300
+ * ③ 两侧都放不下 → 取空间较大的一侧,并把高度截断(列表内部滚动);
1301
+ * ④ 任何情况下都夹在框内(edge 内边距),水平越界则左移。
1302
+ * 行数再乘 itemHeight 得到自然高度;maxVisibleItems 是"不滚动时最多显示几行"的上限。
1303
+ */
1304
+ function selectPopLayout(point, itemCount, bounds) {
1305
+ const g = SELECT_POP_GEOMETRY;
1306
+ const boxW = Math.max(0, Number(bounds && bounds.width) || 0);
1307
+ const boxH = Math.max(0, Number(bounds && bounds.height) || 0);
1308
+ const rawX = Number(point && point.x);
1309
+ const rawY = Number(point && point.y);
1310
+ const x = Number.isFinite(rawX) ? rawX : 0;
1311
+ const y = Number.isFinite(rawY) ? rawY : 0;
1312
+ const count = Math.max(0, Math.floor(Number(itemCount) || 0));
1313
+ // 高度按"最多 maxVisibleItems 行"算,但**所有条目都要渲染**(容器自己滚动):
1314
+ // 截断只发生在高度上,绝不能把第 13 项之后的选项从列表里丢掉。
1315
+ const rowsForHeight = Math.max(1, Math.min(count, g.maxVisibleItems));
1316
+ const natural = rowsForHeight * g.itemHeight + g.pad * 2;
1317
+ const minHeight = g.itemHeight + g.pad * 2;
1318
+ const roomBelow = Math.max(0, boxH - y - g.gap - g.edge);
1319
+ const roomAbove = Math.max(0, y - g.gap - g.edge);
1320
+ let side = 'below';
1321
+ let top;
1322
+ let maxHeight;
1323
+ if (natural <= roomBelow) {
1324
+ top = y + g.gap;
1325
+ maxHeight = natural;
1326
+ } else if (natural <= roomAbove) {
1327
+ side = 'above';
1328
+ top = y - g.gap - natural;
1329
+ maxHeight = natural;
1330
+ } else if (roomBelow >= roomAbove) {
1331
+ top = y + g.gap;
1332
+ maxHeight = Math.max(minHeight, Math.min(natural, roomBelow));
1333
+ } else {
1334
+ side = 'above';
1335
+ maxHeight = Math.max(minHeight, Math.min(natural, roomAbove));
1336
+ top = y - g.gap - maxHeight;
1337
+ }
1338
+ // ④ 硬夹进画面框:极端窄/矮面板也不许画到框外。
1339
+ if (top + maxHeight > boxH - g.edge) top = boxH - g.edge - maxHeight;
1340
+ if (top < g.edge) top = g.edge;
1341
+ const room = Math.max(1, boxW - g.edge * 2);
1342
+ const preferred = Math.max(g.minWidth, Math.min(g.maxWidth, room));
1343
+ const width = Math.max(1, Math.min(preferred, room));
1344
+ const wantedLeft = x - g.pad;
1345
+ const lastLeft = Math.max(g.edge, boxW - g.edge - width);
1346
+ const left = Math.min(Math.max(g.edge, wantedLeft), lastLeft);
1347
+ const visibleItems = Math.max(1, Math.floor((maxHeight - g.pad * 2) / g.itemHeight));
1348
+ return {
1349
+ left: Math.round(left * 1000) / 1000,
1350
+ top: Math.round(top * 1000) / 1000,
1351
+ width: Math.round(width * 1000) / 1000,
1352
+ maxHeight: Math.round(maxHeight * 1000) / 1000,
1353
+ side,
1354
+ flippedUp: side === 'above',
1355
+ shiftedLeft: left < wantedLeft - 0.5,
1356
+ itemHeight: g.itemHeight,
1357
+ pad: g.pad,
1358
+ visibleItems,
1359
+ scrolls: count > visibleItems,
1360
+ };
1361
+ }
1362
+
1100
1363
  function paintedRect(rect, naturalW, naturalH) {
1101
1364
  const nw = Number(naturalW) || 0;
1102
1365
  const nh = Number(naturalH) || 0;
@@ -1139,6 +1402,7 @@ window.__ModuleLoader__.load({
1139
1402
  .utb-select:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
1140
1403
  .utb-select option:disabled { color: var(--dsw-alias-label-secondary); }
1141
1404
  .utb-env-caret { position: absolute; right: 7px; display: grid; place-items: center; pointer-events: none; color: var(--dsw-alias-label-secondary); }
1405
+ .utb-env-error { max-width: 220px; font-size: 11px; line-height: 14px; color: #f59e0b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1142
1406
  .utb-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1143
1407
  .utb-bar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; flex: 0 0 auto; background: var(--dsw-alias-bg-layer-1); border-bottom: 1px solid var(--dsw-alias-border-l1); }
1144
1408
  .utb-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; flex: 0 0 auto; color: var(--dsw-alias-label-secondary); background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; }
@@ -1148,12 +1412,31 @@ window.__ModuleLoader__.load({
1148
1412
  .utb-addr { flex: 1 1 auto; min-width: 0; height: 28px; padding: 0 10px; font: inherit; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; }
1149
1413
  .utb-addr::placeholder { color: var(--dsw-alias-label-secondary); }
1150
1414
  .utb-addr[data-invalid="true"] { border-color: var(--dsw-alias-state-error-primary); outline-color: var(--dsw-alias-state-error-primary); }
1151
- .utb-addr:focus-visible, .utb-btn:focus-visible, .utb-menu-item:focus-visible, .utb-num:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
1415
+ .utb-addr:focus-visible, .utb-btn:focus-visible, .utb-menu-item:focus-visible, .utb-num:focus-visible, .utb-addr-hist:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
1416
+ .utb-addr-wrap { position: relative; display: flex; align-items: center; flex: 1 1 auto; min-width: 0; }
1417
+ .utb-addr-wrap .utb-addr { flex: 1 1 auto; padding-right: 30px; }
1418
+ .utb-addr-hist { position: absolute; top: 2px; right: 2px; bottom: 2px; display: inline-flex; align-items: center; justify-content: center; width: 24px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
1419
+ .utb-addr-hist:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
1420
+ .utb-addr-hist:disabled { opacity: .45; cursor: not-allowed; }
1421
+ .utb-addr-hist[data-utb-history-toggle="open"] { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
1422
+ .utb-addr-pop { right: 0; left: auto; min-width: 100%; max-width: 560px; max-height: 264px; overflow: auto; }
1423
+ .utb-addr-pop-head { padding: 4px 8px 6px; font-size: 12px; line-height: 16px; color: var(--dsw-alias-label-secondary); }
1424
+ .utb-addr-pop-empty { padding: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
1425
+ .utb-addr-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1152
1426
  .utb-more { position: relative; flex: 0 0 auto; }
1153
1427
  .utb-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 208px; padding: 4px; background: var(--dsw-alias-bg-overlay); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.28); }
1154
1428
  .utb-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; font: inherit; text-align: left; color: var(--dsw-alias-label-primary); background: transparent; border: 0; border-radius: 6px; cursor: pointer; }
1155
1429
  .utb-menu-item:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
1156
1430
  .utb-menu-item:disabled { color: var(--dsw-alias-label-secondary); opacity: .6; cursor: not-allowed; }
1431
+ /* 原生 <select> 的面板侧浮层(浏览器弹层在 UI 图层,帧里看不到)。
1432
+ 观感复用 .utb-pop 那一套 token;定位由 selectPopLayout 纯函数给出。 */
1433
+ .utb-select-pop { overflow-y: auto; overflow-x: hidden; padding: 4px; background: var(--dsw-alias-bg-overlay); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.28); outline: none; }
1434
+ .utb-select-pop:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
1435
+ .utb-select-pop-item { display: block; width: 100%; padding: 4px 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary); border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
1436
+ .utb-context-menu button { background: transparent; font-family: inherit; }
1437
+ .utb-select-pop-item:hover { background: var(--dsw-alias-bg-layer-2); }
1438
+ .utb-select-pop-item[data-utb-select-item="active"] { background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2)); color: var(--dsw-alias-brand-primary); }
1439
+ .utb-select-pop-item[data-utb-select-item="disabled"] { color: var(--dsw-alias-label-secondary); opacity: .55; cursor: not-allowed; }
1157
1440
  /* 调试工具弹窗被拦截时的本地提示 + 可点击降级链接(不写 Runtime lastError)。 */
1158
1441
  .utb-devtools-note { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; row-gap: 4px; padding: 6px 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border-bottom: 1px solid var(--dsw-alias-border-l1); }
1159
1442
  .utb-devtools-note a { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
@@ -1184,6 +1467,7 @@ window.__ModuleLoader__.load({
1184
1467
  .utb-annot-color:disabled { opacity: .55; cursor: not-allowed; }
1185
1468
  .utb-annot-width { width: 54px; }
1186
1469
  /* v2:横栏右侧的可见文案按钮「清除画布」(§11.4)与状态栏同步提示(§11.3)。 */
1470
+ .utb-annot-shot { width: auto; min-width: 0; height: 28px; padding: 0 10px; font-size: 12px; line-height: 1; white-space: nowrap; border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); }
1187
1471
  .utb-annot-clear { width: auto; min-width: 0; height: 28px; padding: 0 10px; margin-left: auto; font-size: 12px; line-height: 1; white-space: nowrap; border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); }
1188
1472
  .utb-annot-sync { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-state-warn-primary); }
1189
1473
  .utb-annot-layer { display: block; overflow: hidden; }
@@ -1248,6 +1532,13 @@ window.__ModuleLoader__.load({
1248
1532
  .utb-menu-row.utb-menu-row > svg { flex: 0 0 auto; }
1249
1533
  .utb-menu-row.utb-menu-row:hover { background: rgba(164, 183, 229, 0.14); background: color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent); }
1250
1534
  .utb-menu-row.utb-menu-row:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
1535
+ /* 视图层没有该环境时的占位:问号图标 + 说明,不留空白面板 */
1536
+ .utb-noview { flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 24px; text-align: center; }
1537
+ .utb-noview-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; color: var(--dsw-alias-label-tertiary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent); }
1538
+ .utb-noview-text { display: flex; flex-direction: column; gap: 4px; font-size: 12px; line-height: 18px; }
1539
+ .utb-noview-text strong { font-size: 13px; color: var(--dsw-alias-label-primary); }
1540
+ .utb-noview-text p { margin: 0; color: var(--dsw-alias-label-tertiary); }
1541
+
1251
1542
  `;
1252
1543
 
1253
1544
  function IconBrowser({ size = 16 }) {
@@ -1308,6 +1599,16 @@ window.__ModuleLoader__.load({
1308
1599
  h('path', { d: 'M3 9.5h18' }),
1309
1600
  h('path', { d: 'M6.3 7.3h.01M8.8 7.3h.01' }));
1310
1601
  }
1602
+ /** 地址栏历史:时钟箭头(点击展开最近访问)。 */
1603
+ function IconHistory() {
1604
+ return h('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
1605
+ h('path', { d: 'M3.5 12a8.5 8.5 0 1 0 2.6-6.1' }), h('path', { d: 'M3.2 4.4v4.2h4.2' }), h('path', { d: 'M12 8.2V12l2.9 1.8' }));
1606
+ }
1607
+ /** 转到:右箭头 + 落点竖线(等同回车跳转地址)。 */
1608
+ function IconGo() {
1609
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
1610
+ h('path', { d: 'M4.5 12h12' }), h('path', { d: 'm12.2 7.4 4.6 4.6-4.6 4.6' }), h('path', { d: 'M19.6 6.4v11.2' }));
1611
+ }
1311
1612
  function IconMore() {
1312
1613
  return h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
1313
1614
  h('circle', { cx: 5.5, cy: 12, r: 1.5 }), h('circle', { cx: 12, cy: 12, r: 1.5 }), h('circle', { cx: 18.5, cy: 12, r: 1.5 }));
@@ -2025,13 +2326,36 @@ window.__ModuleLoader__.load({
2025
2326
  const [syncMismatch, setSyncMismatch] = React.useState(false);
2026
2327
  const [seedStatus, setSeedStatus] = React.useState(null);
2027
2328
  const [blankHint, setBlankHint] = React.useState('');
2329
+ /**
2330
+ * 原生 <select> 的面板侧浮层。浏览器把弹层画在 UI 图层,帧里根本看不到,所以键盘可用、
2331
+ * 鼠标不可用;这里用点击点向 Host 问命中,命中 select 就在点击点画自己的列表。
2332
+ * 形状:{anchor:{x,y}, bounds:{width,height}, select:<projection>, reason:<不支持原因|null>, activeIndex}
2333
+ */
2334
+ const [selectPop, setSelectPop] = React.useState(null);
2335
+ const selectPopRef = React.useRef(null);
2336
+ selectPopRef.current = selectPop;
2337
+ const selectPopSeqRef = React.useRef(0);
2338
+ const selectPopElementRef = React.useRef(null);
2339
+ const [pageContext, setPageContext] = React.useState(null);
2340
+ const pageContextRef = React.useRef(null);
2341
+ pageContextRef.current = pageContext;
2342
+ const pageContextSeqRef = React.useRef(0);
2343
+ const pageContextElementRef = React.useRef(null);
2344
+ const inputQueueRef = React.useRef(Promise.resolve());
2028
2345
  const [inputNotice, setInputNotice] = React.useState('');
2029
2346
  const [tabNotice, setTabNotice] = React.useState('');
2030
2347
  const [tabOpening, setTabOpening] = React.useState(false);
2348
+ // The tab group list is cached in a ref (it must survive a transient remote=null): a local
2349
+ // prune after a Host-confirmed close needs its own render bump.
2350
+ const [, setTabsTick] = React.useState(0);
2031
2351
  const [pending, setPending] = React.useState(false);
2032
2352
  const [navBusy, setNavBusy] = React.useState(false);
2033
2353
  const [mode, setMode] = React.useState('idle');
2034
2354
  const [env, setEnv] = React.useState('web');
2355
+ // Host-backed environment selection: busy while the POST /config/select is in flight, and a
2356
+ // local failure notice that keeps the previous value (never a silent switch).
2357
+ const [envBusy, setEnvBusy] = React.useState(false);
2358
+ const [envNotice, setEnvNotice] = React.useState('');
2035
2359
  const [displaySwitching, setDisplaySwitching] = React.useState(false);
2036
2360
  const [shot, setShot] = React.useState('');
2037
2361
  const [remote, setRemote] = React.useState({ mode: 'idle', activity: '', url: '', running: false });
@@ -2112,6 +2436,9 @@ window.__ModuleLoader__.load({
2112
2436
  const frameGeomRef = React.useRef(null);
2113
2437
  const pendingGeomRef = React.useRef(null);
2114
2438
  const dragRef = React.useRef({ active: false, pointerId: -1, x: 0, y: 0 });
2439
+ /** 最近一次 pointerdown 的面板坐标:用来区分"点击"与"拖拽"(只有点击才弹 select 浮层)。 */
2440
+ const selectClickRef = React.useRef({ x: 0, y: 0, pointerId: -1, valid: false });
2441
+ // Host dispatches a complete right-click in one queued operation, including release.
2115
2442
  // P0-C「拖拽」模式:手势状态与共享 canvasX/canvasY 的节流写入(同一时刻最多一个 set 在途)。
2116
2443
  const panDragRef = React.useRef({
2117
2444
  active: false, pointerId: -1, startClientX: 0, startClientY: 0,
@@ -2195,6 +2522,12 @@ window.__ModuleLoader__.load({
2195
2522
  const [annotStaleState, setAnnotStaleState] = React.useState({ stale: false, staleReason: null });
2196
2523
  // A live gesture/erase exists (drives the 清除画布 disabled state without per-move renders).
2197
2524
  const [annotBusy, setAnnotBusy] = React.useState(false);
2525
+ // 标注栏「截图」的状态:按钮进行态 + 就地提示(成功/失败原因都给具体文案)。
2526
+ // 光标形状传递:预览层跟随页面命中元素的 computed cursor(手型/文本/抓取…),
2527
+ // 而不是永远显示箭头。只接受 CSS 关键字白名单,`url(...)` 一律不落到样式上。
2528
+ const [frameCursor, setFrameCursor] = React.useState('auto');
2529
+ const [annotShotBusy, setAnnotShotBusy] = React.useState(false);
2530
+ const [annotShotNotice, setAnnotShotNotice] = React.useState('');
2198
2531
  const annotSyncRef = React.useRef({ state: 'idle', timer: 0 });
2199
2532
  // Identity gate: no old shape is rendered or published before the new scope's first trusted
2200
2533
  // state. `epoch` is the scope generation the gate belongs to; `key` is the D key.
@@ -2240,6 +2573,16 @@ window.__ModuleLoader__.load({
2240
2573
  // runtime id returned by the first /state (or /ensure); `binding` is empty until then.
2241
2574
  const seatSessionId = props && typeof props.seatSessionId === 'string' ? props.seatSessionId : '';
2242
2575
  const visible = !props || props.visible !== false;
2576
+ // Human-foreground claim (P0-D `browser_annotation`): recycled by this mount lifetime, added
2577
+ // while the seat is visible and withdrawn on unmount, so the Host's window can expire.
2578
+ const foregroundClaimRef = React.useRef('');
2579
+ if (!foregroundClaimRef.current) foregroundClaimRef.current = newGuiForegroundClaim();
2580
+ React.useEffect(() => {
2581
+ const claim = foregroundClaimRef.current;
2582
+ if (visible) guiForegroundClaims.add(claim);
2583
+ else guiForegroundClaims.delete(claim);
2584
+ return () => guiForegroundClaims.delete(claim);
2585
+ }, [visible]);
2243
2586
  const capture = props && props.capture ? props.capture : { sessionId: '', resolution: 'none' };
2244
2587
  // Internal browser tabs (one Runtime per tab): the Host group list is the only source.
2245
2588
  const panelViews = remote && remote.ui && remote.ui.version === 1 && Array.isArray(remote.ui.views)
@@ -2293,6 +2636,51 @@ window.__ModuleLoader__.load({
2293
2636
  const scopeEpoch = scopeEpochRef.current;
2294
2637
  const scopeRef = React.useRef(scope);
2295
2638
  scopeRef.current = scope;
2639
+ // Host config for this panel scope: the selector's allowed environments and current
2640
+ // resolution come from the Host, not from the static ENVIRONMENTS list.
2641
+ const hostConfig = useHostConfig(scope, 'session');
2642
+ // 设置页改完开关/选择后立刻同步过来:重读一次配置,组合框与视图跟着变,不用等刷新。
2643
+ useHostConfigChange(() => {
2644
+ if (hostConfig && typeof hostConfig.reload === 'function') hostConfig.reload();
2645
+ });
2646
+ const hostResolved = hostConfig.state.resolved || null;
2647
+ const resolvedEnvironment = hostResolved && hostResolved.environment ? String(hostResolved.environment) : '';
2648
+ const allowedEnvironments = hostResolved && Array.isArray(hostResolved.enabledEnvironments)
2649
+ ? hostResolved.enabledEnvironments.map(String) : null;
2650
+ /**
2651
+ * 组合框**只列开关已打开的目标环境**(`environments.<id>.enabled`):
2652
+ * 开了 web + android,就只出现这两个选项。打开的但视图层没实现 → 选中后渲染空态
2653
+ * (执行环境不变)。宿主配置还没读到(`config` 为 null)时按可执行集合 + web 兜底,
2654
+ * 免得一上来列表是空的。
2655
+ */
2656
+ const envEnabledList = React.useMemo(() => ENVIRONMENTS
2657
+ .map((option) => {
2658
+ const fromConfig = enabledFromConfig(hostConfig.state.config, option.id);
2659
+ const enabled = fromConfig === null
2660
+ ? (option.id === 'web' || (allowedEnvironments ? allowedEnvironments.indexOf(option.id) !== -1 : false))
2661
+ : fromConfig;
2662
+ return Object.assign({}, option, { enabled });
2663
+ })
2664
+ .filter((option) => option.enabled), [hostConfig.state.config, allowedEnvironments]);
2665
+ const envOptions = React.useMemo(() => (envEnabledList.length
2666
+ ? envEnabledList
2667
+ : ENVIRONMENTS.filter((option) => option.id === 'web').map((option) => Object.assign({}, option, { enabled: true }))),
2668
+ [envEnabledList]);
2669
+
2670
+ // 当前选中的环境被关掉时(用户在设置页关开关),退回宿主当前执行环境,避免组合框空值。
2671
+ React.useEffect(() => {
2672
+ if (envOptions.some((option) => option.id === env)) return;
2673
+ const fallback = envOptions.some((option) => option.id === resolvedEnvironment)
2674
+ ? resolvedEnvironment
2675
+ : ((envOptions[0] && envOptions[0].id) || 'web');
2676
+ setEnv(fallback);
2677
+ }, [envOptions, env, resolvedEnvironment]);
2678
+ React.useEffect(() => {
2679
+ if (!resolvedEnvironment) return;
2680
+ const option = ENVIRONMENTS.find((item) => item.id === resolvedEnvironment);
2681
+ if (!option || !option.ready) return; // an unimplemented environment never becomes current
2682
+ setEnv((current) => (current === resolvedEnvironment ? current : resolvedEnvironment));
2683
+ }, [resolvedEnvironment]);
2296
2684
  const visibleRef = React.useRef(true);
2297
2685
  visibleRef.current = visible;
2298
2686
  const readyRef = React.useRef(false);
@@ -2414,6 +2802,16 @@ window.__ModuleLoader__.load({
2414
2802
  tabNoticeTimerRef.current = window.setTimeout(() => setTabNotice(''), 4000);
2415
2803
  }
2416
2804
 
2805
+ /** Drop one view from the cached group list the moment the Host confirms its close. */
2806
+ function dropClosedView(view) {
2807
+ const current = stableViewsRef.current;
2808
+ if (!current || !view) return;
2809
+ const views = current.views.filter((item) => item && item.id !== view.id);
2810
+ if (views.length === current.views.length) return;
2811
+ stableViewsRef.current = { ...current, views, viewsRevision: (current.viewsRevision || 0) + 1 };
2812
+ setTabsTick((value) => value + 1);
2813
+ }
2814
+
2417
2815
  function closeView(view) {
2418
2816
  if (!view) return;
2419
2817
  if (view.kind === 'primary') {
@@ -2438,6 +2836,9 @@ window.__ModuleLoader__.load({
2438
2836
  // Close with the child's own binding. The Host answer is the only success signal; on
2439
2837
  // success an active child falls back locally (its own /state may already be gone).
2440
2838
  callHost('close', {}, { scope: scopeSnapshot('recent', '', view.runtimeId, false) }).then(() => {
2839
+ // The Host answer is the success signal: retire the view locally so the strip, the
2840
+ // active tab and the empty state do not wait one more /state poll to agree.
2841
+ dropClosedView(view);
2441
2842
  if (activeViewId === view.id) {
2442
2843
  viewSwitchRef.current = true;
2443
2844
  retiredRuntimeRef.current = '';
@@ -2445,8 +2846,10 @@ window.__ModuleLoader__.load({
2445
2846
  }
2446
2847
  }).catch((error) => {
2447
2848
  const code = String((error && error.code) || '');
2849
+ // A refused close is never silent: the tab stays in the strip, so the user has to learn
2850
+ // why (a stale/unknown binding now answers SCOPE_NOT_FOUND instead of a fake ok).
2448
2851
  if (code === 'BUSY') showTabNotice('标签正被 Agent 操作;稍后再关闭');
2449
- /* else: keep the current selection; the poll owns the real state */
2852
+ else showTabNotice('关闭标签失败,请稍后重试');
2450
2853
  });
2451
2854
  }
2452
2855
 
@@ -2679,6 +3082,39 @@ window.__ModuleLoader__.load({
2679
3082
  setError('');
2680
3083
  }
2681
3084
 
3085
+ /* ----------------------------------------------------------------------------------------
3086
+ * Address history (panel-local, docs/getting-started.md): the newest 7 visited HTTP(S)
3087
+ * addresses, newest first. Recorded from the *accepted* address, so a redirect or an Agent
3088
+ * navigation is stored as the page that actually loaded, and a failed navigation is not.
3089
+ * -------------------------------------------------------------------------------------- */
3090
+ const [addressHistory, setAddressHistory] = React.useState(() => addressHistoryRead(addressHistoryStorage()));
3091
+ const [historyOpen, setHistoryOpen] = React.useState(false);
3092
+ const historyAddress = acceptedAddress();
3093
+ React.useEffect(() => {
3094
+ if (!/^https?:\/\//i.test(historyAddress)) return;
3095
+ const storage = addressHistoryStorage();
3096
+ const current = addressHistoryRead(storage);
3097
+ const next = addressHistoryPush(current, historyAddress);
3098
+ if (next.length === current.length && next[0] === current[0]) return;
3099
+ addressHistoryWrite(storage, next);
3100
+ setAddressHistory(next);
3101
+ }, [historyAddress]);
3102
+ // The dropdown is a local popover: it never survives a mode switch or a scope change.
3103
+ React.useEffect(() => {
3104
+ if (!historyOpen) return undefined;
3105
+ const close = (event) => { if (!event.target.closest || !event.target.closest('.utb-addr-wrap')) setHistoryOpen(false); };
3106
+ const escape = (event) => { if (event.key === 'Escape') setHistoryOpen(false); };
3107
+ document.addEventListener('mousedown', close);
3108
+ document.addEventListener('keydown', escape);
3109
+ return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', escape); };
3110
+ }, [historyOpen]);
3111
+ React.useEffect(() => { setHistoryOpen(false); }, [scopeEpoch, annotOn]);
3112
+ function chooseHistory(url) {
3113
+ setHistoryOpen(false);
3114
+ if (annotRef.current.on) return; // local navigation is disabled in annotation mode
3115
+ submit(undefined, url);
3116
+ }
3117
+
2682
3118
  /* ----------------------------------------------------------------------------------------
2683
3119
  * Shared viewport state (Host `ui.view`).
2684
3120
  *
@@ -4180,6 +4616,94 @@ window.__ModuleLoader__.load({
4180
4616
  }
4181
4617
 
4182
4618
  /** 清除画布:清已完成 + 草稿/编辑,立即发布空文档,保留模式/工具/样式(§11.4)。 */
4619
+ /**
4620
+ * 标注栏「截图」:把**当前页面 + 已接受的标注**合成一张 PNG,作为附件放进聊天框。
4621
+ *
4622
+ * 语义边界(docs/viewport-control.md):走 Host 的 `POST /screenshot {includeAnnotations:true}`,
4623
+ * 也就是 Agent 的 `browser_screenshot{includeAnnotations:true}` 同一条离屏合成路径,因此像素与
4624
+ * 证据完全一致,不是面板自己另画一遍 SVG。它沿用面板的 scope 身份;附件通过 Conversation 服务
4625
+ * 注册成 draft attachment(`createDrafts` + `addAttachments`),照片字节随消息发送,不落盘。
4626
+ */
4627
+ function annotShotTarget() {
4628
+ const sid = baseIdentity && baseIdentity.kind === 'session' ? String(baseIdentity.sessionId || '') : '';
4629
+ if (!sid) return { ok: false, reason: '当前面板没有绑定会话,无法加入聊天框附件' };
4630
+ const ctx = pluginContext;
4631
+ if (!ctx || typeof ctx.get !== 'function') return { ok: false, reason: '聊天框服务不可用' };
4632
+ let sessions = null;
4633
+ let conversation = null;
4634
+ try {
4635
+ sessions = ctx.get('sessions');
4636
+ conversation = ctx.get('conversation');
4637
+ } catch {
4638
+ return { ok: false, reason: '聊天框服务不可用' };
4639
+ }
4640
+ if (!conversation || typeof conversation.createDrafts !== 'function'
4641
+ || !conversation.input || typeof conversation.input.for !== 'function'
4642
+ || !sessions || typeof sessions.binding !== 'function') {
4643
+ return { ok: false, reason: '聊天框服务不可用' };
4644
+ }
4645
+ let binding = null;
4646
+ try { binding = sessions.binding(sid); } catch { binding = null; }
4647
+ if (!binding || !binding.ctx) return { ok: false, reason: '会话尚未就绪(未 retain),稍后再试' };
4648
+ let face = null;
4649
+ try { face = conversation.input.for(binding.ctx); } catch { face = null; }
4650
+ if (!face || typeof face.addAttachments !== 'function') return { ok: false, reason: '聊天框服务不可用' };
4651
+ return { ok: true, sid, conversation, face };
4652
+ }
4653
+
4654
+ async function captureAnnotationShot() {
4655
+ if (annotShotBusy) return;
4656
+ setAnnotShotNotice('');
4657
+ const target = annotShotTarget();
4658
+ if (!target.ok) {
4659
+ setAnnotShotNotice('截图失败:' + target.reason);
4660
+ return;
4661
+ }
4662
+ setAnnotShotBusy(true);
4663
+ try {
4664
+ // 先让「已接受」的文档追上最新笔画:截图取的是 Host 侧文档,pending 时不等待就会少一笔。
4665
+ if (annotDirtyRef.current || annotSyncRef.current.state === 'pending') {
4666
+ try { annotFlushPending(); } catch { /* best effort */ }
4667
+ await new Promise((resolve) => { window.setTimeout(resolve, 260); });
4668
+ }
4669
+ const scopeNow = scopeRef.current;
4670
+ const response = await fetch(HOST_BASE + '/screenshot', {
4671
+ method: 'POST',
4672
+ headers: Object.assign({ 'content-type': 'application/json' }, scopeHeaders(scopeNow)),
4673
+ credentials: 'same-origin',
4674
+ body: JSON.stringify({ includeAnnotations: true }),
4675
+ });
4676
+ const payload = await response.json().catch(() => null);
4677
+ if (!response.ok || !payload || payload.ok !== true || !payload.image) {
4678
+ throw new Error((payload && (payload.error || payload.reason)) || ('HTTP ' + response.status));
4679
+ }
4680
+ const binary = atob(payload.image);
4681
+ const bytes = new Uint8Array(binary.length);
4682
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
4683
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
4684
+ const file = new File([bytes], 'annotation-' + stamp + '.png', { type: 'image/png' });
4685
+ const drafts = target.conversation.createDrafts(target.sid, [file], false);
4686
+ const list = Array.isArray(drafts) ? drafts : [];
4687
+ const ids = list.map((draft) => draft && draft.id).filter(Boolean);
4688
+ if (!ids.length) throw new Error('附件没有被聊天框接受');
4689
+ const added = target.face.addAttachments(ids);
4690
+ if (!added) {
4691
+ // 回车发送中/命令执行中的聊天框会拒绝新附件:立刻释放,避免留下看不见的孤儿附件。
4692
+ for (const draft of list) {
4693
+ try { target.conversation.releaseDraftAttachment(draft.id); } catch { /* best effort */ }
4694
+ }
4695
+ throw new Error('聊天框正忙(发送中),稍后再试');
4696
+ }
4697
+ // 成功不弹任何提示:附件本身就出现在聊天框里,那一张缩略图就是结果;
4698
+ // 失败才在按钮旁给具体原因(静默失败才是真问题)。
4699
+ try { if (typeof target.face.focus === 'function') target.face.focus(); } catch { /* best effort */ }
4700
+ } catch (error) {
4701
+ setAnnotShotNotice('截图失败:' + String((error && error.message) || error));
4702
+ } finally {
4703
+ setAnnotShotBusy(false);
4704
+ }
4705
+ }
4706
+
4183
4707
  function clearAnnotCanvas() {
4184
4708
  if (!annotRef.current.on) return;
4185
4709
  // The pointerdown of the button already cancelled a live edit; this covers keyboard activation.
@@ -5416,6 +5940,16 @@ window.__ModuleLoader__.load({
5416
5940
  pickCommitAt(event);
5417
5941
  return;
5418
5942
  }
5943
+ if (event.button === 2) {
5944
+ if (!frameInputReady()) return;
5945
+ const rightGeom = frameGeometry();
5946
+ const rightPoint = rightGeom ? mapEventPoint(event, rightGeom) : null;
5947
+ if (!rightPoint || !rightPoint.inside || rightPoint.blank) return;
5948
+ event.preventDefault();
5949
+ event.currentTarget.focus();
5950
+ openPageContext(event, rightPoint);
5951
+ return;
5952
+ }
5419
5953
  if (event.button !== 0) return;
5420
5954
  if (panMode) {
5421
5955
  // Pan is the recovery path: it must start even when page input is fail-closed (stale
@@ -5453,10 +5987,96 @@ window.__ModuleLoader__.load({
5453
5987
  event.preventDefault();
5454
5988
  event.currentTarget.focus();
5455
5989
  dragRef.current = { active: true, pointerId: event.pointerId, x: point.x, y: point.y };
5990
+ selectClickRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, valid: true };
5456
5991
  try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* ignore */ }
5457
5992
  sendEvents([{ kind: 'mouse', type: 'mousePressed', x: point.x, y: point.y, button: 'left', buttons: 1, clickCount: 1 }]);
5458
5993
  }
5459
5994
 
5995
+ /** CSS cursor 关键字白名单:页面只能在这几个值里改变预览光标。 */
5996
+ const PAGE_CURSOR_KEYWORDS = new Set([
5997
+ 'auto', 'default', 'pointer', 'text', 'move', 'grab', 'grabbing', 'crosshair', 'wait', 'help',
5998
+ 'progress', 'cell', 'copy', 'alias', 'context-menu', 'not-allowed', 'no-drop', 'zoom-in',
5999
+ 'zoom-out', 'all-scroll', 'vertical-text', 'col-resize', 'row-resize', 'n-resize', 's-resize',
6000
+ 'e-resize', 'w-resize', 'ne-resize', 'nw-resize', 'se-resize', 'sw-resize', 'ew-resize',
6001
+ 'ns-resize', 'nesw-resize', 'nwse-resize', 'none',
6002
+ ]);
6003
+ function pageCursorKeyword(raw) {
6004
+ const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
6005
+ return PAGE_CURSOR_KEYWORDS.has(value) ? value : 'auto';
6006
+ }
6007
+ /**
6008
+ * 光标形状传递(`POST /element {cursorOnly:true}`)。
6009
+ *
6010
+ * 页面里 `cursor:pointer` 的元素在真实浏览器里是手型,但面板把画面画成 <img>,指针形状来自
6011
+ * 面板自己的 CSS —— 于是"里面是手型、外面是箭头"。这里按节流询问页面命中的 computed cursor
6012
+ * 并写回预览层;迟到的响应按序号丢弃,避免快速移动时旧位置的光标覆盖新位置。
6013
+ */
6014
+ // 面板侧"以为还按着"的键(code → 键信息),用于焦点离开时补 keyUp。
6015
+ const panelHeldKeysRef = React.useRef(new Map());
6016
+ const cursorProbeAtRef = React.useRef(0);
6017
+ const cursorProbeSeqRef = React.useRef(0);
6018
+ const cursorProbeTimerRef = React.useRef(0);
6019
+ const cursorProbePointRef = React.useRef(null);
6020
+ function resetFrameCursor() {
6021
+ cursorProbeSeqRef.current += 1;
6022
+ if (cursorProbeTimerRef.current) window.clearTimeout(cursorProbeTimerRef.current);
6023
+ cursorProbeTimerRef.current = 0;
6024
+ cursorProbePointRef.current = null;
6025
+ cursorProbeAtRef.current = 0;
6026
+ setFrameCursor('auto');
6027
+ }
6028
+ function frameCursorAllowed() {
6029
+ return visibleRef.current && readyRef.current && !writesPausedRef.current
6030
+ && !annotRef.current.on && !pickRef.current.on && !panModeRef.current;
6031
+ }
6032
+ function probeFrameCursor() {
6033
+ cursorProbeTimerRef.current = 0;
6034
+ const client = cursorProbePointRef.current;
6035
+ if (!client || !frameCursorAllowed()) { resetFrameCursor(); return; }
6036
+ const geom = frameGeometry();
6037
+ const canvas = canvasPosition();
6038
+ if (!geom || !canvas || !inputGeomReady() || !canvasBarrier().ready) {
6039
+ // A replacement image may have naturalWidth=0 until decoded. Keep the final hover
6040
+ // through that brief gap as well as input-barrier gaps, with a bounded retry.
6041
+ if (Date.now() < client.expiresAt) cursorProbeTimerRef.current = window.setTimeout(probeFrameCursor, 120);
6042
+ else resetFrameCursor();
6043
+ return;
6044
+ }
6045
+ // Re-map the latest pointer at dispatch time; a trailing probe must not reuse old geometry.
6046
+ const point = mapEventPoint(client, geom);
6047
+ if (!point.inside || point.blank) { resetFrameCursor(); return; }
6048
+ const seq = cursorProbeSeqRef.current;
6049
+ const epoch = scopeEpochRef.current;
6050
+ const documentKey = selectPopDocument();
6051
+ const canvasKey = selectPopCanvas();
6052
+ const current = () => seq === cursorProbeSeqRef.current && epoch === scopeEpochRef.current
6053
+ && documentKey === selectPopDocument() && canvasKey === selectPopCanvas() && frameCursorAllowed();
6054
+ cursorProbeAtRef.current = Date.now();
6055
+ callHost('element', { x: point.q.x + canvas.x, y: point.q.y + canvas.y, cursorOnly: true }, {
6056
+ scope: scopeRef.current,
6057
+ signal: abortRef.current ? abortRef.current.signal : undefined,
6058
+ }).then((reply) => {
6059
+ if (!current()) return;
6060
+ const element = reply && reply.element ? reply.element : null;
6061
+ setFrameCursor(pageCursorKeyword(element && element.cursor));
6062
+ }).catch(() => { if (current()) setFrameCursor('auto'); });
6063
+ }
6064
+ function scheduleFrameCursor(event) {
6065
+ // Invalidate the previous position even when this move falls inside the throttle window.
6066
+ cursorProbeSeqRef.current += 1;
6067
+ cursorProbePointRef.current = { clientX: event.clientX, clientY: event.clientY, expiresAt: Date.now() + 1000 };
6068
+ if (cursorProbeTimerRef.current) return;
6069
+ const wait = 120 - (Date.now() - cursorProbeAtRef.current);
6070
+ if (wait <= 0) probeFrameCursor();
6071
+ else cursorProbeTimerRef.current = window.setTimeout(probeFrameCursor, wait);
6072
+ }
6073
+ const cursorDocumentKey = selectPopDocument();
6074
+ const cursorCanvasKey = selectPopCanvas();
6075
+ React.useEffect(() => {
6076
+ resetFrameCursor();
6077
+ return resetFrameCursor;
6078
+ }, [scopeEpoch, cursorDocumentKey, cursorCanvasKey, visible, ready, identity.paused, panMode, annotOn, pickOn]);
6079
+
5460
6080
  function onFramePointerMove(event) {
5461
6081
  if (annotRef.current.on) return; // annotation mode: zero page input
5462
6082
  // 选元素模式:悬浮只更新节流命中请求,零页面 hover 派发。
@@ -5473,6 +6093,8 @@ window.__ModuleLoader__.load({
5473
6093
  return;
5474
6094
  }
5475
6095
  if (panMode) return; // 拖拽模式:不向页面派发 hover/拖拽移动
6096
+ // Retain the last hover even if frame sync briefly blocks page input.
6097
+ scheduleFrameCursor(event);
5476
6098
  // Fail closed for page input while O≠0 cannot be proven: no stale-coordinate move/release.
5477
6099
  if (!inputGeomReady() || !canvasBarrier().ready) return;
5478
6100
  const drag = dragRef.current;
@@ -5481,7 +6103,7 @@ window.__ModuleLoader__.load({
5481
6103
  if (!geom) return;
5482
6104
  const point = mapEventPoint(event, geom);
5483
6105
  if (!point.inside) return;
5484
- if (point.blank) return; // blank never dispatches (a drag into blank keeps its button state)
6106
+ if (point.blank) { resetFrameCursor(); return; } // blank never dispatches (a drag into blank keeps its button state)
5485
6107
  const now = Date.now();
5486
6108
  if (now - hoverAtRef.current < 60) return;
5487
6109
  hoverAtRef.current = now;
@@ -5489,8 +6111,294 @@ window.__ModuleLoader__.load({
5489
6111
  sendEvents([{ kind: 'mouse', type: 'mouseMoved', x: point.x, y: point.y, buttons: dragging ? 1 : 0 }]);
5490
6112
  }
5491
6113
 
6114
+ /** 不支持浮层的形态 → 原因字符串(null = 支持)。与 Host 侧 selectShapeSupport 同义。 */
6115
+ function selectPopReason(projection) {
6116
+ if (!projection) return 'detached';
6117
+ if (projection.multiple === true) return 'multiple';
6118
+ if (Number(projection.size) > 1) return 'size';
6119
+ if (!projection.options || projection.options.length === 0) return 'empty';
6120
+ return null;
6121
+ }
6122
+
6123
+ function selectPopClose(restoreFocus = false) {
6124
+ selectPopSeqRef.current += 1;
6125
+ selectPopRef.current = null;
6126
+ setSelectPop(null);
6127
+ if (restoreFocus && frameImgRef.current) frameImgRef.current.focus({ preventScroll: true });
6128
+ }
6129
+
6130
+ function selectPopDocument() {
6131
+ const state = remoteRef.current;
6132
+ const ui = state && state.ui;
6133
+ return annotDKey(annotDOf(ui)) + '|' + String(ui && ui.address || state && state.url || '');
6134
+ }
6135
+
6136
+ function selectPopCanvas() {
6137
+ const canvas = canvasPosition();
6138
+ const view = viewRef.current;
6139
+ return canvas ? JSON.stringify([canvas.x, canvas.y, view && view.scale]) : '';
6140
+ }
6141
+
6142
+ function selectPopIsCurrent(pop) {
6143
+ // A visible hint survives newer probes; only their responses/option popovers depend on seq.
6144
+ return pop && pop.epoch === scopeEpochRef.current && (pop.reason || pop.seq === selectPopSeqRef.current)
6145
+ && pop.document === selectPopDocument() && pop.canvas === selectPopCanvas()
6146
+ && readyRef.current && !writesPausedRef.current && !annotRef.current.on && !pickRef.current.on;
6147
+ }
6148
+
6149
+ /**
6150
+ * 一次点击之后问 Host 命中了什么:命中 <select> 就在点击点画浮层。
6151
+ * 全部复用 picker 的坐标换算(q + 画布偏移 = 页面文档坐标),并沿用"画布原点移动就丢弃"
6152
+ * 的规则,避免浮层画在错位的位置。
6153
+ */
6154
+ function selectPopProbe(event, point) {
6155
+ if (!readyRef.current || writesPausedRef.current) return;
6156
+ const canvas = canvasPosition();
6157
+ const box = frameBoxRef.current;
6158
+ if (!canvas || !box) return;
6159
+ const rect = box.getBoundingClientRect();
6160
+ const epoch = scopeEpochRef.current;
6161
+ const seq = selectPopSeqRef.current + 1;
6162
+ selectPopSeqRef.current = seq;
6163
+ const signal = abortRef.current ? abortRef.current.signal : undefined;
6164
+ const context = { epoch, seq, scope: scopeRef.current, document: selectPopDocument(), canvas: selectPopCanvas() };
6165
+ callHost('element', { x: point.q.x + canvas.x, y: point.q.y + canvas.y, detail: false }, {
6166
+ scope: context.scope,
6167
+ signal,
6168
+ }).then((payload) => {
6169
+ if (!selectPopIsCurrent(context)) return;
6170
+ const element = payload && payload.element;
6171
+ const projection = element && element.select ? element.select : null;
6172
+ if (!projection || !projection.target) { selectPopClose(); return; }
6173
+ const reason = selectPopReason(projection);
6174
+ const current = selectPopRef.current;
6175
+ // Keep the existing DOM/live region until dismissal instead of re-triggering the hint.
6176
+ if (reason && current?.reason && selectPopIsCurrent(current)) return;
6177
+ const options = projection.options || [];
6178
+ const selectedIndex = options.findIndex((option) => option.selected);
6179
+ setSelectPop({
6180
+ ...context,
6181
+ anchor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
6182
+ bounds: { width: rect.width, height: rect.height },
6183
+ select: projection,
6184
+ reason,
6185
+ activeIndex: selectedIndex >= 0 ? selectedIndex : 0,
6186
+ });
6187
+ }).catch(() => {
6188
+ if (selectPopIsCurrent(context)) selectPopClose();
6189
+ });
6190
+ }
6191
+
6192
+ /** 选中一项:写回 → 补一次 Escape 关掉可能已打开的原生弹层 → 收起浮层。 */
6193
+ function selectPopChoose(option) {
6194
+ const pop = selectPopRef.current;
6195
+ if (!selectPopIsCurrent(pop) || pop.reason || !option || option.disabled) return;
6196
+ const signal = abortRef.current ? abortRef.current.signal : undefined;
6197
+ const optionIndex = pop.select.options.indexOf(option);
6198
+ selectPopClose(true);
6199
+ const settleEscape = () => {
6200
+ if (pop.epoch !== scopeEpochRef.current || pop.document !== selectPopDocument()) return;
6201
+ sendEvents([
6202
+ { kind: 'key', type: 'keyDown', key: 'Escape', code: 'Escape', virtualKeyCode: 27, modifiers: 0 },
6203
+ { kind: 'key', type: 'keyUp', key: 'Escape', code: 'Escape', virtualKeyCode: 27, modifiers: 0 },
6204
+ ]);
6205
+ };
6206
+ callHost('select', { value: option.value, optionIndex, target: pop.select.target }, { scope: pop.scope, signal })
6207
+ .then((payload) => {
6208
+ if (!payload || payload.ok !== true) {
6209
+ noteInputError(new Error('下拉写入失败'));
6210
+ return;
6211
+ }
6212
+ // 顺序很重要:先写回再 Escape。反过来的话,Escape 会先把用户刚选的值撤销掉。
6213
+ settleEscape();
6214
+ })
6215
+ .catch((error) => { noteInputError(error); });
6216
+ }
6217
+
6218
+ /** 浮层内键盘:↑↓ 移动(跳过 disabled)、Enter 选中、Esc 关闭;不支持形态只有 Esc。 */
6219
+ function onSelectPopKeyDown(event) {
6220
+ const pop = selectPopRef.current;
6221
+ if (!pop) return;
6222
+ if (event.key === 'Escape' || event.key === 'Esc') {
6223
+ event.preventDefault();
6224
+ event.stopPropagation();
6225
+ selectPopClose(true);
6226
+ sendEvents(panelKeyEvents(event));
6227
+ return;
6228
+ }
6229
+ if (pop.reason) {
6230
+ const events = panelKeyEvents(event);
6231
+ if (events.length) { event.preventDefault(); sendEvents(events); }
6232
+ return;
6233
+ }
6234
+ const options = pop.select.options || [];
6235
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
6236
+ event.preventDefault();
6237
+ event.stopPropagation();
6238
+ if (!options.length) return;
6239
+ const step = event.key === 'ArrowDown' ? 1 : -1;
6240
+ let index = Number.isInteger(pop.activeIndex) ? pop.activeIndex : 0;
6241
+ for (let hop = 0; hop < options.length; hop += 1) {
6242
+ index = (index + step + options.length) % options.length;
6243
+ if (!options[index].disabled) break;
6244
+ }
6245
+ setSelectPop(Object.assign({}, pop, { activeIndex: index }));
6246
+ return;
6247
+ }
6248
+ if (event.key === 'Enter') {
6249
+ event.preventDefault();
6250
+ event.stopPropagation();
6251
+ const option = options[pop.activeIndex];
6252
+ if (option && !option.disabled) selectPopChoose(option);
6253
+ }
6254
+ }
6255
+
6256
+ /** 点击浮层外部关闭(面板任意位置,含画面区的下一次按下)。 */
6257
+ React.useEffect(() => {
6258
+ const onDocumentPointerDown = (event) => {
6259
+ const node = selectPopElementRef.current;
6260
+ if (node && event.target && typeof node.contains === 'function' && node.contains(event.target)) return;
6261
+ // Re-clicking page controls must not collapse/reinsert the outside-preview hint row.
6262
+ if (selectPopRef.current?.reason && frameImgRef.current?.contains(event.target)) return;
6263
+ selectPopClose();
6264
+ };
6265
+ document.addEventListener('pointerdown', onDocumentPointerDown, true);
6266
+ return () => document.removeEventListener('pointerdown', onDocumentPointerDown, true);
6267
+ }, []);
6268
+
6269
+ React.useEffect(() => {
6270
+ if (selectPopRef.current && !selectPopIsCurrent(selectPopRef.current)) selectPopClose();
6271
+ });
6272
+
6273
+ function closePageContext(restoreFocus = false) {
6274
+ pageContextSeqRef.current += 1;
6275
+ pageContextRef.current = null;
6276
+ setPageContext(null);
6277
+ if (restoreFocus && frameImgRef.current) frameImgRef.current.focus({ preventScroll: true });
6278
+ }
6279
+
6280
+ function pageContextCurrent(context) {
6281
+ return context && context.seq === pageContextSeqRef.current && context.epoch === scopeEpochRef.current
6282
+ && context.document === selectPopDocument() && context.canvas === selectPopCanvas()
6283
+ && readyRef.current && !writesPausedRef.current && !annotRef.current.on && !pickRef.current.on;
6284
+ }
6285
+
6286
+ function openPageContext(event, point) {
6287
+ closePageContext();
6288
+ selectPopClose();
6289
+ const box = frameBoxRef.current;
6290
+ if (!box) return;
6291
+ const rect = box.getBoundingClientRect();
6292
+ const context = { seq: pageContextSeqRef.current, epoch: scopeEpochRef.current, scope: scopeRef.current,
6293
+ document: selectPopDocument(), canvas: selectPopCanvas(),
6294
+ anchor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
6295
+ bounds: { width: rect.width, height: rect.height } };
6296
+ const signal = abortRef.current?.signal;
6297
+ const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
6298
+ const pending = inputQueueRef.current.then(async () => {
6299
+ if (!pageContextCurrent(context) || !frameInputReady()) return;
6300
+ const payload = await callHost('context-menu', { x: point.x, y: point.y, modifiers }, { scope: context.scope, signal });
6301
+ if (context.seq !== pageContextSeqRef.current || context.epoch !== scopeEpochRef.current || signal?.aborted) return;
6302
+ // A fresh frame may arrive before /state after reload. Bind to the document observed by
6303
+ // THIS transaction, while acceptRemote's revision gate rejects a late/retired snapshot.
6304
+ const state = payload.state;
6305
+ const responseDocument = state && (annotDKey(annotDOf(state.ui)) + '|' + String(state.ui?.address || state.url || ''));
6306
+ if (!responseDocument || (context.document !== selectPopDocument() && responseDocument !== selectPopDocument())) return;
6307
+ acceptRemote(state);
6308
+ const observed = { ...context, document: responseDocument };
6309
+ if (!pageContextCurrent(observed)) return;
6310
+ setInputNotice('');
6311
+ if (payload.status === 'default' && payload.menu) {
6312
+ setPageContext({ ...observed, ...payload.menu, activeIndex: payload.menu.items.findIndex(item => !item.disabled) });
6313
+ }
6314
+ }).catch(error => { if (pageContextCurrent(context)) noteInputError(error); });
6315
+ inputQueueRef.current = pending;
6316
+ }
6317
+
6318
+ function choosePageContext(item) {
6319
+ const context = pageContextRef.current;
6320
+ if (!pageContextCurrent(context) || !item || item.disabled) return;
6321
+ // Start clipboard access in the real user gesture, before any asynchronous Host request.
6322
+ const copied = item.text ? clipboardWriteText(item.text) : null;
6323
+ closePageContext(true);
6324
+ if (copied) {
6325
+ copied.then(ok => {
6326
+ if (ok || context.epoch !== scopeEpochRef.current || context.document !== selectPopDocument()) return;
6327
+ setInputNotice('无法访问剪贴板,请检查浏览器权限');
6328
+ window.clearTimeout(inputNoticeTimerRef.current);
6329
+ inputNoticeTimerRef.current = window.setTimeout(() => setInputNotice(''), 3200);
6330
+ });
6331
+ return;
6332
+ }
6333
+ const signal = abortRef.current?.signal;
6334
+ const pending = inputQueueRef.current.then(async () => {
6335
+ if (context.epoch !== scopeEpochRef.current || context.document !== selectPopDocument()) return;
6336
+ const payload = await callHost('context-action', { token: context.token, action: item.id }, { scope: context.scope, signal });
6337
+ if (context.epoch === scopeEpochRef.current && !signal?.aborted && payload.state) acceptRemote(payload.state);
6338
+ }).catch(error => { if (context.epoch === scopeEpochRef.current) noteInputError(error); });
6339
+ inputQueueRef.current = pending;
6340
+ }
6341
+
6342
+ function onPageContextKeyDown(event) {
6343
+ const context = pageContextRef.current;
6344
+ if (!context) return;
6345
+ event.preventDefault();
6346
+ event.stopPropagation();
6347
+ if (event.key === 'Escape' || event.key === 'Tab') { closePageContext(true); return; }
6348
+ if (event.key === 'Enter' || event.key === ' ') { choosePageContext(context.items[context.activeIndex]); return; }
6349
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
6350
+ const enabled = context.items.map((item, index) => item.disabled ? -1 : index).filter(index => index >= 0);
6351
+ let index = enabled.indexOf(context.activeIndex);
6352
+ index = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
6353
+ : (index + (event.key === 'ArrowUp' ? -1 : 1) + enabled.length) % enabled.length;
6354
+ setPageContext({ ...context, activeIndex: enabled[index] });
6355
+ }
6356
+
6357
+ React.useEffect(() => {
6358
+ const outside = event => {
6359
+ if (pageContextElementRef.current?.contains(event.target)) return;
6360
+ closePageContext();
6361
+ };
6362
+ const scroll = event => {
6363
+ const target = event?.target;
6364
+ const preview = stage.current;
6365
+ // Sibling panes (especially the streaming chat) do not move this menu's anchor.
6366
+ if (event?.type !== 'scroll' || target === document || target === window
6367
+ || (target?.contains && preview && target.contains(preview))
6368
+ || (preview?.contains(target) && !pageContextElementRef.current?.contains(target))) closePageContext();
6369
+ };
6370
+ document.addEventListener('pointerdown', outside, true);
6371
+ window.addEventListener('blur', scroll);
6372
+ window.addEventListener('resize', scroll);
6373
+ // Scroll can move the anchor without a React render (including stage/ancestor scrolling).
6374
+ document.addEventListener('scroll', scroll, true);
6375
+ return () => {
6376
+ document.removeEventListener('pointerdown', outside, true);
6377
+ window.removeEventListener('blur', scroll);
6378
+ window.removeEventListener('resize', scroll);
6379
+ document.removeEventListener('scroll', scroll, true);
6380
+ };
6381
+ }, []);
6382
+ React.useEffect(() => {
6383
+ if (pageContextRef.current && !pageContextCurrent(pageContextRef.current)) closePageContext();
6384
+ });
6385
+
6386
+ /**
6387
+ * 预览区内部的右键菜单属于页面,不属于宿主。
6388
+ *
6389
+ * 宿主(DSH Web)把右键菜单挂在容器上(`useContextMenu` 的 trigger props → React
6390
+ * `onContextMenu`),预览是它的子树,所以右键会弹宿主的菜单。这里在预览区域内
6391
+ * `preventDefault + stopPropagation`:既不弹宿主菜单,也不让事件继续冒泡到宿主容器。
6392
+ * 页面右键由 Host 完整事务派发;未被页面取消时显示面板菜单。
6393
+ */
6394
+ function onFrameContextMenu(event) {
6395
+ event.preventDefault();
6396
+ event.stopPropagation();
6397
+ }
6398
+
5492
6399
  /** The release is dispatched even when the pointer left the image (no stuck button). */
5493
6400
  function onFramePointerUp(event) {
6401
+ if (event.button === 2) return; // Host already paired press/release, even outside the image.
5494
6402
  if (annotRef.current.on) return; // annotation mode: zero page input
5495
6403
  if (pickRef.current.on) return; // pick mode: action already committed on pointerdown
5496
6404
  const pan = panDragRef.current;
@@ -5510,9 +6418,11 @@ window.__ModuleLoader__.load({
5510
6418
  dragRef.current = { active: false, pointerId: -1, x: drag.x, y: drag.y };
5511
6419
  const geom = frameGeometry();
5512
6420
  const point = geom ? mapEventPoint(event, geom) : null;
6421
+ let released = Promise.resolve(false);
6422
+ const probeContext = { epoch: scopeEpochRef.current, seq: selectPopSeqRef.current, document: selectPopDocument(), canvas: selectPopCanvas() };
5513
6423
  if (point) {
5514
6424
  const size = acceptedViewSize();
5515
- sendEvents([{
6425
+ released = sendEvents([{
5516
6426
  kind: 'mouse',
5517
6427
  type: 'mouseReleased',
5518
6428
  x: clampNumber(point.x, 0, Math.max(0, size.w - POINTER_EPSILON)),
@@ -5523,6 +6433,22 @@ window.__ModuleLoader__.load({
5523
6433
  }]);
5524
6434
  }
5525
6435
  try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { /* ignore */ }
6436
+ // 原生 <select>:点击(非拖拽、非空白)之后问一次命中。浮层由浏览器 UI 图层绘制,
6437
+ // 帧里看不到,所以这里用同一坐标向 Host 取选项,在点击点画面板自己的列表。
6438
+ if (point) {
6439
+ const pressed = selectClickRef.current;
6440
+ const moved = !pressed.valid
6441
+ || pressed.pointerId !== event.pointerId
6442
+ || Math.abs(event.clientX - pressed.x) > SELECT_CLICK_SLOP_PX
6443
+ || Math.abs(event.clientY - pressed.y) > SELECT_CLICK_SLOP_PX;
6444
+ selectClickRef.current = { x: 0, y: 0, pointerId: -1, valid: false };
6445
+ if (!moved && !point.blank) {
6446
+ const click = { clientX: event.clientX, clientY: event.clientY };
6447
+ released.then((applied) => {
6448
+ if (applied && selectPopIsCurrent(probeContext)) selectPopProbe(click, point);
6449
+ });
6450
+ }
6451
+ }
5526
6452
  }
5527
6453
 
5528
6454
  /** Best-effort release for pointercancel / lost capture / blur while dragging. */
@@ -5877,6 +6803,9 @@ window.__ModuleLoader__.load({
5877
6803
  // unconfirmed content to the module queue under the old frozen identity *before* the old
5878
6804
  // remote/view refs are dropped, then retract this panel's local view only.
5879
6805
  annotRetainDirty(true);
6806
+ selectPopClose();
6807
+ inputQueueRef.current = Promise.resolve();
6808
+ selectClickRef.current.valid = false;
5880
6809
  requestGenerationRef.current += 1;
5881
6810
  if (abortRef.current) abortRef.current.abort();
5882
6811
  abortRef.current = new AbortController();
@@ -6276,20 +7205,160 @@ window.__ModuleLoader__.load({
6276
7205
  inputNoticeTimerRef.current = window.setTimeout(() => setInputNotice(''), 3200);
6277
7206
  }
6278
7207
 
7208
+ /** 面板按键的默认 virtualKeyCode(浏览器给不出 keyCode 时用)。 */
7209
+ const PANEL_KEY_CODES = {
7210
+ Backspace: 8, Tab: 9, Enter: 13, Shift: 16, Control: 17, Alt: 18, Pause: 19, CapsLock: 20,
7211
+ Escape: 27, ' ': 32, PageUp: 33, PageDown: 34, End: 35, Home: 36,
7212
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, Insert: 45, Delete: 46,
7213
+ Meta: 91, ContextMenu: 93,
7214
+ F1: 112, F2: 113, F3: 114, F4: 115, F5: 116, F6: 117, F7: 118, F8: 119,
7215
+ F9: 120, F10: 121, F11: 122, F12: 123,
7216
+ };
7217
+
7218
+ /**
7219
+ * 真人按键 → Host `/input` 事件。旧实现有三处漏:`Ctrl/Cmd/Alt` 组合直接 return
7220
+ * (快捷键全丢)、只认 7 个键(Home/End/PgUp/PgDn/F1-F12 全丢)、可打印字符走
7221
+ * `kind:'text'`(页面收不到 keydown,绑在 keydown 上的快捷键与输入框按键逻辑都看不到)。
7222
+ * 现在统一发 keyDown + keyUp:带 modifiers 位掩码、virtualKeyCode、物理 code、autoRepeat,
7223
+ * 可打印字符同时带 text/unmodifiedText。
7224
+ */
7225
+ function panelKeyEvents(event) {
7226
+ const key = String((event && event.key) || '');
7227
+ if (!key || key === 'Dead' || key === 'Unidentified') return [];
7228
+ const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0)
7229
+ | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
7230
+ const virtualKeyCode = Number.isFinite(event.keyCode) && event.keyCode > 0
7231
+ ? event.keyCode
7232
+ : PANEL_KEY_CODES[key];
7233
+ const printable = key.length === 1;
7234
+ // 命令修饰键(Ctrl/Cmd/Alt)按下时这是**快捷键**而不是输入:必须发 rawKeyDown 且不带
7235
+ // text——带 text 时 Chrome 按字符插入处理,页面只看到「打了个字母」,全选/复制/粘贴/剪切
7236
+ // 这些编辑命令不会执行(用户实测:组合键是分开的、不能真正全选)。
7237
+ const altGraph = typeof event.getModifierState === 'function' && event.getModifierState('AltGraph');
7238
+ const command = !altGraph && (event.ctrlKey === true || event.metaKey === true || event.altKey === true);
7239
+ const text = command ? '' : (printable ? key : (key === 'Enter' ? '\r' : ''));
7240
+ const base = {
7241
+ kind: 'key',
7242
+ key,
7243
+ code: event.code || undefined,
7244
+ virtualKeyCode,
7245
+ modifiers,
7246
+ location: Number.isFinite(event.location) ? event.location : undefined,
7247
+ autoRepeat: event.repeat === true ? true : undefined,
7248
+ };
7249
+ const down = Object.assign({}, base, { type: command ? 'rawKeyDown' : 'keyDown' });
7250
+ if (text) {
7251
+ down.text = text;
7252
+ down.unmodifiedText = text;
7253
+ }
7254
+ // 只发按下。旧实现这里顺手补一个 keyUp("按下即松开"),于是页面永远看不到"按住":
7255
+ // 游戏里按住 W+A 斜向移动、按住方向键连续走动全部失效(用户实测:WASD 斜向移动没用)。
7256
+ // 真实的 keyup 由 onKeyUp 转发,焦点离开预览时由 releasePanelKeys 兜底松开。
7257
+ return [down];
7258
+ }
7259
+
7260
+ /** 松开:只发 keyUp(与上面的 keyDown 成对,不再凭空补一个)。 */
7261
+ function panelKeyReleaseEvents(event) {
7262
+ const key = String((event && event.key) || '');
7263
+ if (!key || key === 'Dead' || key === 'Unidentified') return [];
7264
+ const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0)
7265
+ | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
7266
+ const virtualKeyCode = Number.isFinite(event.keyCode) && event.keyCode > 0
7267
+ ? event.keyCode
7268
+ : PANEL_KEY_CODES[key];
7269
+ return [{
7270
+ kind: 'key',
7271
+ type: 'keyUp',
7272
+ key,
7273
+ code: event.code || undefined,
7274
+ virtualKeyCode,
7275
+ modifiers,
7276
+ location: Number.isFinite(event.location) ? event.location : undefined,
7277
+ }];
7278
+ }
7279
+
7280
+ /**
7281
+ * 焦点离开预览时把"面板以为还按着"的键松开。
7282
+ *
7283
+ * 少了这一步,用户在预览里按住 W、鼠标点聊天框之后,页面会一直认为 W 还按着(Host 侧现在
7284
+ * 也有按键状态表,会把后续事件继续标成 shift/… 位)。只发 keyUp,不产生任何其它副作用。
7285
+ */
7286
+ function releasePanelKeys() {
7287
+ const held = panelHeldKeysRef.current;
7288
+ if (!held || !held.size) return;
7289
+ const events = [];
7290
+ for (const entry of held) {
7291
+ events.push({ kind: 'key', type: 'keyUp', key: entry.key, code: entry.code, virtualKeyCode: entry.virtualKeyCode });
7292
+ }
7293
+ held.clear();
7294
+ if (events.length) sendEvents(events);
7295
+ }
7296
+
7297
+ /* ---- 系统剪贴板桥:面板里的 Ctrl+C/X/V 与宿主系统共用剪贴板 -------------------------- */
7298
+ // 页面跑在宿主的 Chromium 里,它的剪贴板是浏览器内部的;面板(GUI 页面)才有真正的系统
7299
+ // 剪贴板 API。所以复制/剪切时把选区文本写进系统剪贴板,粘贴时把系统剪贴板的内容插进页面。
7300
+ async function clipboardWriteText(text) {
7301
+ const value = String(text || '');
7302
+ if (!value) return false;
7303
+ try {
7304
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
7305
+ await navigator.clipboard.writeText(value);
7306
+ return true;
7307
+ }
7308
+ } catch { /* 权限被拒:保持浏览器内部剪贴板行为 */ }
7309
+ return false;
7310
+ }
7311
+
7312
+ /** 返回 `null` 表示"读不到"(权限/不支持),空串表示"确实是空的"。 */
7313
+ async function clipboardReadText() {
7314
+ try {
7315
+ if (navigator.clipboard && typeof navigator.clipboard.readText === 'function') {
7316
+ const text = await navigator.clipboard.readText();
7317
+ return typeof text === 'string' ? text : '';
7318
+ }
7319
+ } catch { /* 权限被拒 */ }
7320
+ return null;
7321
+ }
7322
+
7323
+ /** 页面当前选区文本(只读评估;输入框/文本域按 selectionStart..End 取,否则取 document 选区)。 */
7324
+ function pageSelectionText(signal, scope = scopeRef.current) {
7325
+ const expression = "(() => { const el = document.activeElement; "
7326
+ + "if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { const s = el.selectionStart, e = el.selectionEnd; "
7327
+ + "return (typeof s === 'number' && typeof e === 'number' && e > s) ? String(el.value).slice(s, e) : ''; } "
7328
+ + "return window.getSelection ? String(window.getSelection()) : ''; })()";
7329
+ return callHost('eval', { expression }, { scope, signal })
7330
+ .then((payload) => {
7331
+ if (!payload) return '';
7332
+ if (typeof payload.value === 'string') return payload.value;
7333
+ if (payload.result && typeof payload.result.value === 'string') return payload.result.value;
7334
+ return '';
7335
+ })
7336
+ .catch(() => '');
7337
+ }
7338
+
6279
7339
  function sendEvents(events, options) {
6280
- if (annotRef.current.on) return; // single gate: annotation mode never dispatches page input
6281
- // 选元素模式零页面交互:唯一例外是滚轮(契约要求继续滚动目标页)。
6282
- if (pickRef.current.on && !(options && options.wheel === true)) return;
6283
- if (!readyRef.current || writesPausedRef.current) return;
7340
+ const scope = scopeRef.current;
7341
+ const epoch = scopeEpochRef.current;
7342
+ const documentKey = selectPopDocument();
6284
7343
  const signal = abortRef.current ? abortRef.current.signal : undefined;
6285
- const clearsNotice = (events || []).some((entry) => entry
6286
- && (entry.kind !== 'mouse' || ['mousePressed', 'mouseReleased', 'mouseClicked'].includes(String(entry.type))));
6287
- callHost('input', { events }, { scope: scopeRef.current, signal }).then(() => {
6288
- // Only a real content action clears the notice; hover moves must not erase a BUSY hint.
6289
- if (clearsNotice) setInputNotice('');
7344
+ const current = () => epoch === scopeEpochRef.current && documentKey === selectPopDocument()
7345
+ && !signal?.aborted && readyRef.current && !writesPausedRef.current
7346
+ && !annotRef.current.on && (!pickRef.current.on || options?.wheel === true);
7347
+ const pending = inputQueueRef.current.then(async () => {
7348
+ if (!current()) return false;
7349
+ const prepared = typeof events === 'function' ? await events({ scope, signal }) : events;
7350
+ if (!current() || !prepared?.length) return false;
7351
+ await callHost('input', { events: prepared }, { scope, signal });
7352
+ const clearsNotice = prepared.some((entry) => entry
7353
+ && (entry.kind !== 'mouse' || ['mousePressed', 'mouseReleased', 'mouseClicked'].includes(String(entry.type))));
7354
+ if (current() && clearsNotice) setInputNotice('');
7355
+ return true;
6290
7356
  }).catch((error) => {
6291
- noteInputError(error);
7357
+ if (current()) noteInputError(error);
7358
+ return false;
6292
7359
  });
7360
+ inputQueueRef.current = pending;
7361
+ return pending;
6293
7362
  }
6294
7363
 
6295
7364
  // 人类接管:真实输入后 4 秒内标记,其余时间回落到空闲
@@ -6373,7 +7442,7 @@ window.__ModuleLoader__.load({
6373
7442
  syncDraft();
6374
7443
  }
6375
7444
 
6376
- function submit(event) {
7445
+ function submit(event, overrideAddress) {
6377
7446
  if (event) event.preventDefault();
6378
7447
  if (annotRef.current.on) return; // annotation mode: local navigation is disabled
6379
7448
  if (pendingRef.current.active) return;
@@ -6384,9 +7453,12 @@ window.__ModuleLoader__.load({
6384
7453
  // A local navigation initiated outside the mode invalidates the drawing immediately and
6385
7454
  // submits a normal empty document under the old identity (§12.1).
6386
7455
  annotNavigationClear();
7456
+ // `overrideAddress` lets the history dropdown navigate a stored URL without waiting for a
7457
+ // React state round-trip through the draft (the go button and Enter pass the draft).
7458
+ const source = typeof overrideAddress === 'string' ? overrideAddress : draft;
6387
7459
  let parsed;
6388
7460
  try {
6389
- const raw = draft.trim();
7461
+ const raw = source.trim();
6390
7462
  if (!raw) throw new Error('请输入网址。');
6391
7463
  parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : 'https://' + raw);
6392
7464
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('仅支持 HTTP 和 HTTPS 网址。');
@@ -6629,7 +7701,7 @@ window.__ModuleLoader__.load({
6629
7701
  : !pickRunning ? '需先启动内嵌浏览器'
6630
7702
  : !ready ? '运行环境未就绪'
6631
7703
  : !viewSync ? '等待视口同步'
6632
- : !canvasOn ? '当前 Host 不支持画布框选'
7704
+ : !canvasOn ? '当前宿主不支持画布框选'
6633
7705
  : '选择元素发送到聊天框(点击画面中的元素,加入聊天框)';
6634
7706
  const pickEntryDisabled = !pickOn && !pickReadyNow;
6635
7707
  const pickBoxRect = pickOn && frameBoxRef.current ? frameBoxRef.current.getBoundingClientRect() : null;
@@ -6670,18 +7742,18 @@ window.__ModuleLoader__.load({
6670
7742
  const annotStale = annotStaleState;
6671
7743
  const annotNavHint = '标注按工作区保留,导航/刷新会清除当前页面标注';
6672
7744
  const annotSyncText = annotSyncState === 'unsupported'
6673
- ? '标注未同步(Host 无标注通道)'
7745
+ ? '标注未同步(宿主无标注通道)'
6674
7746
  : annotSyncState === 'error'
6675
7747
  ? '标注未同步'
6676
7748
  : annotSyncState === 'pending'
6677
7749
  ? '标注同步中…'
6678
7750
  : '';
6679
7751
  const annotSyncTitle = (annotSyncState === 'unsupported'
6680
- ? '标注未同步:当前 Host 没有标注上报通道,标注只在本面板可见,Agent 看不到'
7752
+ ? '标注未同步:当前宿主没有标注上报通道,标注只在本面板可见,Agent 看不到'
6681
7753
  : annotSyncState === 'error'
6682
7754
  ? '标注未同步:最近一次上报失败,Agent 看到的内容可能已过期'
6683
7755
  : annotSyncState === 'pending'
6684
- ? '标注同步中:正在把本次修改上报给 Host'
7756
+ ? '标注同步中:正在把本次修改上报给宿主'
6685
7757
  : annotStale.stale && annotStale.staleReason === 'panel-unseen'
6686
7758
  ? '标注已保存到工作区,来源面板心跳已过期'
6687
7759
  : '标注已同步给 Agent(只读线索,不构成页面事实)') + ';' + annotNavHint;
@@ -6894,17 +7966,9 @@ window.__ModuleLoader__.load({
6894
7966
  });
6895
7967
  }
6896
7968
 
6897
- /**
6898
- * 用户 2026-11-03 第 3 条:用户主动切换环境时套该环境默认,只发布 width/height(不发布
6899
- * scale),fit=true、pan=false;annotOn 或非 ready 不生效。
6900
- */
6901
- function changeEnv(next) {
6902
- if (annotRef.current.on) return;
6903
- const option = ENVIRONMENTS.find((item) => item.id === next);
6904
- if (!option || !option.ready) return;
6905
- if (next === env) return;
7969
+ /** 套用某环境的默认尺寸(只发布 width/height;scale 回落到共享比例)。 */
7970
+ function applyEnvDefaultSize(next) {
6906
7971
  const target = envDefaultSize(next);
6907
- setEnv(next);
6908
7972
  const dirty = viewDirtyRef.current;
6909
7973
  dirty.width = target.width;
6910
7974
  dirty.height = target.height;
@@ -6916,11 +7980,60 @@ window.__ModuleLoader__.load({
6916
7980
  changeFit(target.fit);
6917
7981
  }
6918
7982
  /**
6919
- * 用户 2026-11-03 第 5/7 条:左侧四个分辨率预设不再只是宽高——点任一预设同时把 X/Y 归零,
6920
- * 并按当前模式处理右侧 `拖拽|适应窗口|默认` 分页选择器(上下文相关,互斥联动):
6921
- * - 拖拽中 → 只退出拖拽(fit 本就是 false),右侧落到「默认」;
6922
- * - 适应窗口中 → **保持**「适应窗口」(fit 是本地显示偏好,不发布比例;新尺寸自动适应);
6923
- * - 已是「默认」→ 仍是「默认」。
7983
+ * 用户 2026-11-03 第 3 条 + M4:切换运行环境先提交 Host 会话选择(POST /config/select,
7984
+ * 带本面板 scope),成功后才改本地状态;失败保留旧值并显示错误,不静默切换。
7985
+ * 未实现环境(!ready)仍不可选,也不会因为选择而启动任何探测。
7986
+ */
7987
+ /** 环境视图注册表(提前声明:changeEnv 要按它判断"这个环境有没有视图")。 */
7988
+ const ENV_VIEWS = { web: webViewBody };
7989
+
7990
+ function changeEnv(next) {
7991
+ if (annotRef.current.on || envBusy) return;
7992
+ const option = ENVIRONMENTS.find((item) => item.id === next);
7993
+ if (!option) return;
7994
+ if (next === env) return;
7995
+ if (enabledFromConfig(hostConfig.state.config, next) === false) {
7996
+ setEnvNotice('该目标环境还没有启用:请先在设置页打开它的开关');
7997
+ return;
7998
+ }
7999
+ // 视图层没有这个环境:只切换“看哪个视图”(渲染空态),**不写宿主配置**——
8000
+ // v1 宿主不接受 planned 环境,提交只会被拒并回滚,没有任何好处。
8001
+ if (typeof ENV_VIEWS[next] !== 'function') {
8002
+ setEnvNotice('');
8003
+ setEnv(next);
8004
+ return;
8005
+ }
8006
+ if (!option.ready) return;
8007
+ const config = hostConfig.state;
8008
+ if (config.revision === null) {
8009
+ setEnvNotice(config.loading
8010
+ ? '正在读取宿主配置,请稍后重试'
8011
+ : (config.error || '宿主未提供配置接口,无法切换运行环境'));
8012
+ return;
8013
+ }
8014
+ setEnvBusy(true);
8015
+ setEnvNotice('');
8016
+ selectHostEnvironment(next, config.revision, scopeRef.current).then((payload) => {
8017
+ hostConfig.adopt(payload);
8018
+ setEnvBusy(false);
8019
+ setEnv(next);
8020
+ applyEnvDefaultSize(next);
8021
+ }).catch((error) => {
8022
+ setEnvBusy(false);
8023
+ if (error && error.code === 'CONFIG_CONFLICT') {
8024
+ adoptHostConflict(hostConfig, error);
8025
+ setEnvNotice('配置已被其他窗口修改(CONFIG_CONFLICT),已刷新;本次切换未生效');
8026
+ return;
8027
+ }
8028
+ setEnvNotice('切换运行环境失败:' + (configErrorText(error && error.message) || '未知错误'));
8029
+ });
8030
+ }
8031
+ /**
8032
+ * 用户 2026-11-03 第 5/7 条:左侧四个分辨率预设不再只是宽高——点任一预设同时把 X/Y 归零,
8033
+ * 并按当前模式处理右侧 `拖拽|适应窗口|默认` 分页选择器(上下文相关,互斥联动):
8034
+ * - 拖拽中 → 只退出拖拽(fit 本就是 false),右侧落到「默认」;
8035
+ * - 适应窗口中 → **保持**「适应窗口」(fit 是本地显示偏好,不发布比例;新尺寸自动适应);
8036
+ * - 已是「默认」→ 仍是「默认」。
6924
8037
  * 不改 `scale`/`%`(「默认」语义 = `%` 回落到共享 view.scale)。
6925
8038
  *
6926
8039
  * 宽高与坐标合并进同一个 120ms 的 v1 patch(一次 /viewport,带 expectedRevision):画布
@@ -7010,73 +8123,62 @@ window.__ModuleLoader__.load({
7010
8123
  try { transport.retrySeedRefresh(seedContext()); } catch { /* transport must not break UI */ }
7011
8124
  }
7012
8125
 
7013
- return h('div', {
7014
- className: 'utb',
7015
- 'data-utb-tabs-supported': tabsSupported ? 'true' : 'false',
7016
- 'data-utb-tabs-visible': showTabs ? 'true' : 'false',
7017
- 'data-utb-active-view': activeView ? String(activeView.id) : '',
7018
- 'data-utb-runtime-id': ui ? String(ui.runtimeId || '') : '',
7019
- 'data-utb-scope-binding': String(scope.binding || ''),
7020
- 'data-utb-views-count': stableViews ? String(stableViews.views.length) : '0',
7021
- 'data-utb-ready-views': String(readyViewList.length),
7022
- 'data-utb-tabs-pref': tabsVisible ? 'true' : 'false',
7023
- 'data-utb-active-busy': activeBusy ? 'true' : 'false',
7024
- 'data-utb-display-mode': displayMode,
7025
- onPointerDown: markHuman,
7026
- onKeyDown: markHuman,
7027
- onWheel: markHuman,
7028
- },
7029
- h('style', null, CSS),
7030
- h('div', { className: 'utb-top' },
7031
- h('span', { className: 'utb-env-icon', 'aria-hidden': true }, h(IconWindow, null)),
7032
- h('div', { className: 'utb-env-text' },
7033
- h('span', { className: 'utb-env-title' }, 'Web · 调试视图')),
7034
- h('span', { className: 'utb-gap' }),
7035
- // Web-view-only run-mode switch: headless (default) vs headed (visible window + profile).
7036
- env === 'web' && h('div', {
7037
- className: 'utb-seg utb-display-seg',
7038
- role: 'group',
7039
- 'aria-label': '运行模式',
7040
- 'data-utb-display-mode': displayMode,
7041
- },
7042
- [['headless', '无头'], ['headed', '有头']].map(([id, label]) => {
7043
- const description = id === 'headed'
7044
- ? '有头模式(离屏隐藏窗口 + 持久 profile)\n'
7045
- + '· 真实有头 Chromium,较不易被反爬识别;需要登录/过风控的站点用它\n'
7046
- + '· 登录态 / Cookie 跨重启保留\n'
7047
- + '· 启动较慢;切换会关闭所有页面,需重新打开'
7048
- : '无头模式(默认)\n'
7049
- + '· 不显示浏览器窗口,启动快、资源占用低\n'
7050
- + '· 容易被反爬 / 风控识别(Cloudflare、Turnstile 等会持续挑战或 403)\n'
7051
- + '· 每次启动使用临时 profile,不保留登录态';
7052
- return h('button', {
7053
- key: id,
7054
- type: 'button',
7055
- className: 'utb-seg-btn',
7056
- 'aria-pressed': displayMode === id,
7057
- 'aria-description': description,
7058
- 'data-utb-display': id,
7059
- title: description,
7060
- disabled: displaySwitching,
7061
- onClick: () => changeDisplayMode(id),
7062
- }, segDot(displayMode === id, 'display'), label);
7063
- })),
7064
- h('label', { className: 'utb-env-pick' },
7065
- h('span', { className: 'utb-sr' }, '选择运行环境'),
7066
- h('select', {
7067
- className: 'utb-select',
7068
- value: env,
7069
- 'aria-label': '选择运行环境',
7070
- disabled: annotOn,
7071
- onChange: (e) => changeEnv(e.target.value),
7072
- }, ENVIRONMENTS.map((option) => h('option', { key: option.id, value: option.id, disabled: !option.ready }, option.label))),
7073
- h('span', { className: 'utb-env-caret', 'aria-hidden': true }, h(IconChevron, null)))),
7074
- h('div', { className: 'utb-bar' },
8126
+ /**
8127
+ * 环境视图注册表:右上角组合框选哪个目标环境,下面就渲染哪个视图。
8128
+ * 目前只有 web 实现了视图;其余目标环境在表里缺席 → 走「视图层暂不支持」占位。
8129
+ * 接新环境时只加一项(或改成按宿主适配器声明注册),外壳不用动。
8130
+ */
8131
+ function webViewBody() {
8132
+ return [
8133
+ h('div', { className: 'utb-bar' },
7075
8134
  button('后退', h(IconArrow, { dir: 'back' }), () => move('back'), backDisabled),
7076
8135
  button('前进', h(IconArrow, { dir: 'forward' }), () => move('forward'), forwardDisabled),
7077
8136
  button('刷新', h(IconReload, null), reload, reloadDisabled),
7078
- h('form', { onSubmit: submit, style: { flex: '1 1 auto', minWidth: 0, display: 'flex' } },
7079
- h('input', { className: 'utb-addr', type: 'text', value: draft, spellCheck: false, 'aria-label': '网址', placeholder: '输入网址,例如 https://example.com', 'aria-invalid': error ? 'true' : undefined, 'data-invalid': error ? 'true' : 'false', title: error || undefined, disabled: annotOn, onChange: changeAddress, onFocus: () => { focusedRef.current = true; dirtyRef.current = false; }, onBlur: blurAddress, onKeyDown: keyAddress })),
8137
+ h('form', { onSubmit: submit, style: { flex: '1 1 auto', minWidth: 0, display: 'flex', position: 'relative' } },
8138
+ h('div', { className: 'utb-addr-wrap' },
8139
+ h('input', { className: 'utb-addr', type: 'text', value: draft, spellCheck: false, 'aria-label': '网址', placeholder: '输入网址,例如 https://example.com', 'aria-invalid': error ? 'true' : undefined, 'data-invalid': error ? 'true' : 'false', title: error || undefined, disabled: annotOn, onChange: changeAddress, onFocus: () => { focusedRef.current = true; dirtyRef.current = false; }, onBlur: blurAddress, onKeyDown: keyAddress }),
8140
+ h('button', {
8141
+ type: 'button',
8142
+ className: 'utb-addr-hist',
8143
+ 'aria-label': '最近访问的网址',
8144
+ 'aria-haspopup': 'menu',
8145
+ 'aria-expanded': historyOpen,
8146
+ title: addressHistory.length
8147
+ ? '最近访问(最多 7 条,最近的在上面)'
8148
+ : '还没有访问记录:先在地址栏打开一个网址',
8149
+ 'data-utb-history-toggle': historyOpen ? 'open' : 'closed',
8150
+ 'data-utb-history-count': String(addressHistory.length),
8151
+ disabled: annotOn,
8152
+ onMouseDown: (event) => event.preventDefault(), // keep the address caret/draft untouched
8153
+ onClick: () => setHistoryOpen((value) => !value),
8154
+ }, h(IconHistory, null)),
8155
+ historyOpen && h('div', {
8156
+ className: 'utb-pop utb-addr-pop',
8157
+ role: 'menu',
8158
+ 'aria-label': '最近访问的网址',
8159
+ 'data-utb-history-list': 'true',
8160
+ 'data-utb-history-size': String(addressHistory.length),
8161
+ },
8162
+ h('div', { className: 'utb-addr-pop-head' }, '最近访问(' + addressHistory.length + '/' + ADDRESS_HISTORY_LIMIT + ')'),
8163
+ addressHistory.length
8164
+ ? addressHistory.map((url, index) => h('button', {
8165
+ key: url,
8166
+ type: 'button',
8167
+ className: 'utb-menu-item utb-addr-item',
8168
+ role: 'menuitem',
8169
+ title: url,
8170
+ 'data-utb-history-item': String(index),
8171
+ 'data-utb-history-url': url,
8172
+ onClick: () => chooseHistory(url),
8173
+ }, h('span', { className: 'utb-addr-label' }, addressHistoryLabel(url))))
8174
+ : h('div', { className: 'utb-addr-pop-empty' }, '还没有访问记录')))),
8175
+ button('转到', h(IconGo, null), () => submit(undefined), annotOn || busy, {
8176
+ 'data-utb-go': annotOn || busy ? 'disabled' : 'ready',
8177
+ title: '转到地址栏里的网址(等同回车)',
8178
+ // Keep the address field focused: a blur would cancel the draft (the input would snap
8179
+ // back to the accepted address) before this click could submit what the user typed.
8180
+ onMouseDown: (event) => event.preventDefault(),
8181
+ }),
7080
8182
  tabsSupported && button('标签页', h('span', { className: 'utb-tabs-glyph', 'aria-hidden': true }, '▤'), () => setTabsVisible((value) => !value), false, {
7081
8183
  'aria-pressed': tabsVisible,
7082
8184
  'data-utb-tabs-toggle': tabsVisible ? 'on' : 'off',
@@ -7165,6 +8267,12 @@ window.__ModuleLoader__.load({
7165
8267
  onClick: newTab,
7166
8268
  }, '+')),
7167
8269
  tabNotice && h('div', { className: 'utb-sync-note utb-tab-note', role: 'status', 'aria-live': 'polite', 'data-utb-tab-notice': 'true' }, tabNotice),
8270
+ selectPop && selectPop.reason && h('div', {
8271
+ className: 'utb-sync-note utb-select-note',
8272
+ role: 'status',
8273
+ 'aria-live': 'polite',
8274
+ 'data-utb-select-unsupported': selectPop.reason,
8275
+ }, '该下拉不支持浮层,请用键盘 ↑↓ + Enter'),
7168
8276
  activeBusy && h('div', { className: 'utb-sync-note utb-agent-note', role: 'status', 'aria-live': 'polite', 'data-utb-agent-busy': 'true' }, 'Agent 正在操作此标签;预览继续,输入暂不可用'),
7169
8277
  latestPopupNotice && h('div', {
7170
8278
  className: 'utb-sync-note utb-popup-note',
@@ -7255,6 +8363,24 @@ window.__ModuleLoader__.load({
7255
8363
  },
7256
8364
  })),
7257
8365
  // v2 §11.4:横栏右侧(粗细之后)的可见文案按钮;仅模式内可见、无二次确认、空态禁用。
8366
+ // 「粗细」之后:把当前页面连同标注截成一张图,加入聊天框附件。
8367
+ // 禁用条件与「清除画布」同源(没有可视画面 / 正在截图都不派发)。
8368
+ h('button', {
8369
+ type: 'button',
8370
+ className: 'utb-btn utb-annot-shot',
8371
+ 'aria-label': '截图(含标注)',
8372
+ title: '把当前页面连同标注一起截图,加入聊天框附件',
8373
+ 'data-utb-annot-action': 'capture',
8374
+ 'data-utb-annot-shot': annotShotBusy ? 'busy' : (annotReady ? 'ready' : 'unsupported'),
8375
+ disabled: !annotReady || annotShotBusy,
8376
+ onClick: captureAnnotationShot,
8377
+ }, annotShotBusy ? '截图中…' : '截图'),
8378
+ annotShotNotice && h('span', {
8379
+ className: 'utb-annot-sync',
8380
+ role: 'status',
8381
+ 'aria-live': 'polite',
8382
+ 'data-utb-annot-shot-notice': 'true',
8383
+ }, annotShotNotice),
7258
8384
  h('button', {
7259
8385
  type: 'button',
7260
8386
  className: 'utb-btn utb-annot-clear',
@@ -7306,7 +8432,7 @@ window.__ModuleLoader__.load({
7306
8432
  ? '画布坐标 X(画布 CSS px,可为负);滚轮调整(Shift ×10,Alt ×1),Enter 提交,Esc 恢复'
7307
8433
  : viewSync
7308
8434
  ? '页面顶层滚动位置 X(文档 CSS px);滚轮调整(Shift ×10,Alt ×1),Enter 提交,Esc 恢复'
7309
- : '旧 Host 不支持视口坐标同步',
8435
+ : '旧宿主不支持视口坐标同步',
7310
8436
  disabled: !viewSync || annotOn,
7311
8437
  onChange: (e) => changePositionField('x', e.target.value),
7312
8438
  onFocus: () => { viewFocusRef.current = 'x'; },
@@ -7327,7 +8453,7 @@ window.__ModuleLoader__.load({
7327
8453
  ? '画布坐标 Y(画布 CSS px,可为负);滚轮调整(Shift ×10,Alt ×1),Enter 提交,Esc 恢复'
7328
8454
  : viewSync
7329
8455
  ? '页面顶层滚动位置 Y(文档 CSS px);滚轮调整(Shift ×10,Alt ×1),Enter 提交,Esc 恢复'
7330
- : '旧 Host 不支持视口坐标同步',
8456
+ : '旧宿主不支持视口坐标同步',
7331
8457
  disabled: !viewSync || annotOn,
7332
8458
  onChange: (e) => changePositionField('y', e.target.value),
7333
8459
  onFocus: () => { viewFocusRef.current = 'y'; },
@@ -7430,7 +8556,9 @@ window.__ModuleLoader__.load({
7430
8556
  'aria-live': 'polite',
7431
8557
  'data-utb-pick-status': pickStatus ? pickStatus.kind : 'mode',
7432
8558
  }, pickStatus ? pickStatus.text : '选元素模式:点击画面中的元素加入聊天框 · 滚轮可滚动页面 · Esc 退出'),
7433
- h('div', { className: 'utb-stage', ref: stage },
8559
+ // The whole preview area swallows the host context menu (page clicks only); see
8560
+ // onFrameContextMenu. Outside this element the host menu keeps its normal behaviour.
8561
+ h('div', { className: 'utb-stage', ref: stage, onContextMenu: onFrameContextMenu },
7434
8562
  activeUrl
7435
8563
  ? (shot
7436
8564
  ? h('div', {
@@ -7515,7 +8643,7 @@ window.__ModuleLoader__.load({
7515
8643
  display: 'block', width: '100%', height: '100%', objectFit: 'contain', objectPosition: '50% 50%',
7516
8644
  userSelect: 'none', touchAction: 'none',
7517
8645
  position: 'relative', zIndex: 1,
7518
- cursor: pickOn ? 'crosshair' : panMode ? (panDragging ? 'grabbing' : 'grab') : 'auto',
8646
+ cursor: pickOn ? 'crosshair' : panMode ? (panDragging ? 'grabbing' : 'grab') : frameCursor,
7519
8647
  transform: frameShift ? `translate(${frameShiftX}px, ${frameShiftY}px)` : 'none',
7520
8648
  clipPath: seedClip || undefined,
7521
8649
  },
@@ -7526,24 +8654,150 @@ window.__ModuleLoader__.load({
7526
8654
  onLostPointerCapture: releaseFrameDrag,
7527
8655
  onPointerLeave: () => {
7528
8656
  if (pickRef.current.on) pickClearOverlay();
8657
+ resetFrameCursor();
7529
8658
  },
7530
8659
  onKeyDown: (event) => {
7531
8660
  if (annotRef.current.on) return; // annotation mode: zero page input
7532
8661
  if (pickRef.current.on) return; // pick mode: zero page keyboard input
7533
- if (event.metaKey || event.ctrlKey || event.altKey) return;
7534
- event.preventDefault();
7535
- if (event.key.length === 1) {
7536
- sendEvents([{ kind: 'text', text: event.key }]);
8662
+ if (event.isComposing) return; // IME 组合中:别抢字符
8663
+ if (event.key === 'Escape' && selectPopRef.current?.reason) selectPopClose();
8664
+ const commandKey = event.ctrlKey === true || event.metaKey === true;
8665
+ const keyName = String(event.key || '').toLowerCase();
8666
+ if (commandKey && event.altKey !== true && (keyName === 'c' || keyName === 'x')) {
8667
+ // 复制/剪切:页面照常处理,同时把选区文本写进系统剪贴板(读要在转发前,剪切会删掉选区)。
8668
+ event.preventDefault();
8669
+ const events = panelKeyEvents(event);
8670
+ sendEvents(async ({ scope, signal }) => {
8671
+ const text = await pageSelectionText(signal, scope);
8672
+ if (text) await clipboardWriteText(text);
8673
+ return events;
8674
+ });
8675
+ return;
8676
+ }
8677
+ if (commandKey && event.altKey !== true && keyName === 'v') {
8678
+ // 粘贴:系统剪贴板可读就由我们插入文本,避免浏览器内部剪贴板插入旧内容。
8679
+ event.preventDefault();
8680
+ const events = panelKeyEvents(event);
8681
+ const textRead = clipboardReadText();
8682
+ sendEvents(async () => {
8683
+ const text = await textRead;
8684
+ if (text === null) return events;
8685
+ return text === '' ? [] : [{ kind: 'text', text }];
8686
+ });
7537
8687
  return;
7538
8688
  }
7539
- const virtual = { Backspace: 8, Tab: 9, Enter: 13, Escape: 27, ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, Delete: 46 }[event.key];
7540
- if (!virtual) return;
7541
- sendEvents([
7542
- { kind: 'key', type: 'keyDown', key: event.key, code: event.code, virtualKeyCode: virtual },
7543
- { kind: 'key', type: 'keyUp', key: event.key, code: event.code, virtualKeyCode: virtual },
7544
- ]);
8689
+ const events = panelKeyEvents(event);
8690
+ if (!events.length) return;
8691
+ event.preventDefault();
8692
+ // 记下"还按着"的键:真实 keyup 由 onKeyUp 转发,焦点离开时由 onBlur 兜底松开。
8693
+ const identity = event.code || event.key;
8694
+ if (identity && !event.repeat) {
8695
+ panelHeldKeysRef.current.set(identity, {
8696
+ key: String(event.key || ''),
8697
+ code: event.code || undefined,
8698
+ virtualKeyCode: Number.isFinite(event.keyCode) && event.keyCode > 0 ? event.keyCode : undefined,
8699
+ });
8700
+ }
8701
+ sendEvents(events);
8702
+ },
8703
+ // 松开:只转发 keyUp。少这一条时页面永远看不到"按住",按住 W+A 斜向移动这类
8704
+ // 连续输入全部失效(用户实测)。
8705
+ onKeyUp: (event) => {
8706
+ if (annotRef.current.on) return;
8707
+ if (pickRef.current.on) return;
8708
+ if (event.isComposing) return;
8709
+ const identity = event.code || event.key;
8710
+ if (identity) panelHeldKeysRef.current.delete(identity);
8711
+ const events = panelKeyReleaseEvents(event);
8712
+ if (!events.length) return;
8713
+ event.preventDefault();
8714
+ sendEvents(events);
7545
8715
  },
8716
+ onBlur: () => { releasePanelKeys(); },
7546
8717
  }),
8718
+ // 原生 <select> 的面板侧浮层:绝对定位在画面框内、点击点附近。
8719
+ // 浏览器自己的弹层在 UI 图层(帧里看不到),所以选项列表由面板绘制;
8720
+ // 写回走 POST /select(与 /input 同一套门禁)。
8721
+ selectPop && !selectPop.reason ? (() => {
8722
+ const rowCount = (selectPop.select.options || []).length;
8723
+ const layout = selectPopLayout(selectPop.anchor, rowCount, selectPop.bounds);
8724
+ return h('div', {
8725
+ className: 'utb-select-pop',
8726
+ ref: (node) => {
8727
+ selectPopElementRef.current = node;
8728
+ if (node && node.dataset.utbSelectFocused !== '1') {
8729
+ node.dataset.utbSelectFocused = '1';
8730
+ try { node.focus({ preventScroll: true }); } catch { /* focus 是 best-effort */ }
8731
+ }
8732
+ },
8733
+ role: 'listbox',
8734
+ tabIndex: -1,
8735
+ 'aria-label': '下拉选项',
8736
+ 'data-utb-select-pop': selectPop.reason || 'ready',
8737
+ 'data-utb-select-reason': selectPop.reason || '',
8738
+ 'data-utb-select-side': layout.side,
8739
+ 'data-utb-select-layout': JSON.stringify({
8740
+ left: layout.left, top: layout.top, width: layout.width,
8741
+ maxHeight: layout.maxHeight, flippedUp: layout.flippedUp, shiftedLeft: layout.shiftedLeft,
8742
+ }),
8743
+ style: {
8744
+ position: 'absolute',
8745
+ left: layout.left,
8746
+ top: layout.top,
8747
+ width: layout.width,
8748
+ maxHeight: layout.maxHeight,
8749
+ zIndex: 6,
8750
+ },
8751
+ onKeyDown: onSelectPopKeyDown,
8752
+ }, (selectPop.select.options || []).map((option, index) => h('div', {
8753
+ key: `${index}:${option.value}`,
8754
+ className: 'utb-select-pop-item',
8755
+ role: 'option',
8756
+ 'aria-selected': index === selectPop.activeIndex ? 'true' : 'false',
8757
+ 'aria-disabled': option.disabled ? 'true' : 'false',
8758
+ 'data-utb-select-item': option.disabled ? 'disabled'
8759
+ : (index === selectPop.activeIndex ? 'active' : 'ready'),
8760
+ 'data-utb-select-value': option.value,
8761
+ title: option.label || option.value,
8762
+ // 浮层内的按下不许穿透成页面鼠标事件(否则又是一次 select 点击)。
8763
+ onPointerDown: (event) => { event.preventDefault(); event.stopPropagation(); },
8764
+ onPointerEnter: () => {
8765
+ if (option.disabled) return;
8766
+ setSelectPop((current) => (current && current.seq === selectPop.seq
8767
+ ? Object.assign({}, current, { activeIndex: index })
8768
+ : current));
8769
+ },
8770
+ onClick: () => selectPopChoose(option),
8771
+ }, option.label || option.value || '(空值)')));
8772
+ })() : null,
8773
+ pageContext ? (() => {
8774
+ const layout = selectPopLayout(pageContext.anchor, pageContext.items.length, pageContext.bounds);
8775
+ return h('div', {
8776
+ className: 'utb-select-pop utb-context-menu',
8777
+ 'data-utb-context-menu': 'default',
8778
+ role: 'menu', tabIndex: -1, 'aria-label': '页面右键菜单',
8779
+ 'aria-activedescendant': `utb-context-item-${pageContext.seq}-${pageContext.activeIndex}`,
8780
+ ref: node => {
8781
+ pageContextElementRef.current = node;
8782
+ if (node && node.dataset.utbFocused !== '1') {
8783
+ node.dataset.utbFocused = '1';
8784
+ node.focus({ preventScroll: true });
8785
+ }
8786
+ },
8787
+ style: { position: 'absolute', left: layout.left, top: layout.top, width: layout.width, maxHeight: layout.maxHeight, zIndex: 7 },
8788
+ onPointerDown: event => { event.preventDefault(); event.stopPropagation(); },
8789
+ onKeyDown: onPageContextKeyDown,
8790
+ }, pageContext.items.map((item, index) => h('button', {
8791
+ key: item.id, id: `utb-context-item-${pageContext.seq}-${index}`,
8792
+ className: 'utb-select-pop-item', type: 'button', role: 'menuitem', tabIndex: -1,
8793
+ disabled: item.disabled === true, 'aria-disabled': item.disabled ? 'true' : 'false',
8794
+ 'data-utb-context-action': item.id,
8795
+ 'data-utb-select-item': item.disabled ? 'disabled' : index === pageContext.activeIndex ? 'active' : 'ready',
8796
+ style: { border: 0, textAlign: 'left' },
8797
+ onPointerEnter: () => { if (!item.disabled) setPageContext(current => current ? { ...current, activeIndex: index } : current); },
8798
+ onClick: () => choosePageContext(item),
8799
+ }, item.label)));
8800
+ })() : null,
7547
8801
  // P0-D annotation layer: covers the untranslated painted rect, viewBox = accepted
7548
8802
  // canvas window. Committed shapes are React nodes; the live draft is one imperative
7549
8803
  // <g> updated by rAF (never re-rendered per pointer move).
@@ -7657,20 +8911,2341 @@ window.__ModuleLoader__.load({
7657
8911
  }, annotSyncText),
7658
8912
  h('span', { className: 'utb-gap' }),
7659
8913
  h('span', { className: 'utb-activity', title: remote.activity ? `运行时:${remote.activity}` : '视图状态' },
7660
- remote.activity || activity)));
8914
+ remote.activity || activity))
8915
+ ];
8916
+ }
8917
+ const envOption = ENVIRONMENTS.find((item) => item.id === env) || { id: env, label: env, short: env };
8918
+ const envViewTitle = (envOption.short || envOption.label || env) + ' · 调试视图';
8919
+ const envViewSupported = typeof ENV_VIEWS[env] === 'function';
8920
+
8921
+ /** 视图层没有该环境时占位:问号图标 + 一句人话,不留空白面板。 */
8922
+ function noEnvView() {
8923
+ const hostEnvOption = ENVIRONMENTS.find((item) => item.id === (resolvedEnvironment || 'web'));
8924
+ const hostEnvLabel = (hostEnvOption && (hostEnvOption.label || hostEnvOption.id)) || (resolvedEnvironment || 'Web 视图');
8925
+ return h('div', { className: 'utb-noview', 'data-utb-noview': env },
8926
+ h('span', { className: 'utb-noview-icon', 'aria-hidden': true },
8927
+ h('svg', { viewBox: '0 0 24 24', width: 28, height: 28, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' },
8928
+ h('circle', { cx: 12, cy: 12, r: 9 }),
8929
+ h('path', { d: 'M9.6 9.4a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .8-1 1.5v.4' }),
8930
+ h('circle', { cx: 12, cy: 17, r: .95, fill: 'currentColor', stroke: 'none' }))),
8931
+ h('div', { className: 'utb-noview-text' },
8932
+ h('strong', null, '视图层暂不支持'),
8933
+ h('p', null, (envOption.label || env) + ' 的调试视图还没有实现(适配器未接入)。'),
8934
+ h('p', { className: 'utb-noview-hint' }, '执行环境仍然是 ' + hostEnvLabel + ',这里只切换视图。'),
8935
+ h('p', { className: 'utb-noview-hint' }, '可以在设置页查看该环境的适配器与依赖状态。')));
8936
+ }
8937
+
8938
+ return h('div', {
8939
+ className: 'utb',
8940
+ 'data-utb-tabs-supported': tabsSupported ? 'true' : 'false',
8941
+ 'data-utb-tabs-visible': showTabs ? 'true' : 'false',
8942
+ 'data-utb-active-view': activeView ? String(activeView.id) : '',
8943
+ 'data-utb-runtime-id': ui ? String(ui.runtimeId || '') : '',
8944
+ 'data-utb-scope-binding': String(scope.binding || ''),
8945
+ 'data-utb-views-count': stableViews ? String(stableViews.views.length) : '0',
8946
+ 'data-utb-ready-views': String(readyViewList.length),
8947
+ 'data-utb-tabs-pref': tabsVisible ? 'true' : 'false',
8948
+ 'data-utb-active-busy': activeBusy ? 'true' : 'false',
8949
+ 'data-utb-display-mode': displayMode,
8950
+ // 组合框当前选中的目标环境,以及视图层是否已实现该环境
8951
+ 'data-utb-build': CLIENT_BUILD,
8952
+ 'data-utb-view': env,
8953
+ 'data-utb-view-supported': envViewSupported ? 'true' : 'false',
8954
+ onPointerDown: markHuman,
8955
+ onKeyDown: markHuman,
8956
+ onWheel: markHuman,
8957
+ },
8958
+ h('style', null, CSS),
8959
+ h('div', { className: 'utb-top' },
8960
+ h('span', { className: 'utb-env-icon', 'aria-hidden': true }, h(IconWindow, null)),
8961
+ h('div', { className: 'utb-env-text' },
8962
+ h('span', { className: 'utb-env-title', 'data-utb-env-title': env }, envViewTitle)),
8963
+ h('span', { className: 'utb-gap' }),
8964
+ // Web-view-only run-mode switch: headless (default) vs headed (visible window + profile).
8965
+ env === 'web' && envViewSupported && h('div', {
8966
+ className: 'utb-seg utb-display-seg',
8967
+ role: 'group',
8968
+ 'aria-label': '运行模式',
8969
+ 'data-utb-display-mode': displayMode,
8970
+ },
8971
+ [['headless', '无头'], ['headed', '有头']].map(([id, label]) => {
8972
+ const description = id === 'headed'
8973
+ ? '有头模式(离屏隐藏窗口 + 持久 profile)\n'
8974
+ + '· 真实有头 Chromium,较不易被反爬识别;需要登录/过风控的站点用它\n'
8975
+ + '· 登录态 / Cookie 跨重启保留\n'
8976
+ + '· 启动较慢;切换会关闭所有页面,需重新打开'
8977
+ : '无头模式(默认)\n'
8978
+ + '· 不显示浏览器窗口,启动快、资源占用低\n'
8979
+ + '· 容易被反爬 / 风控识别(Cloudflare、Turnstile 等会持续挑战或 403)\n'
8980
+ + '· 每次启动使用临时 profile,不保留登录态';
8981
+ return h('button', {
8982
+ key: id,
8983
+ type: 'button',
8984
+ className: 'utb-seg-btn',
8985
+ 'aria-pressed': displayMode === id,
8986
+ 'aria-description': description,
8987
+ 'data-utb-display': id,
8988
+ title: description,
8989
+ disabled: displaySwitching,
8990
+ onClick: () => changeDisplayMode(id),
8991
+ }, segDot(displayMode === id, 'display'), label);
8992
+ })),
8993
+ h('label', { className: 'utb-env-pick' },
8994
+ h('span', { className: 'utb-sr' }, '选择运行环境'),
8995
+ h('select', {
8996
+ className: 'utb-select',
8997
+ value: env,
8998
+ 'aria-label': '选择运行环境',
8999
+ disabled: annotOn || envBusy,
9000
+ title: envBusy ? '正在提交到宿主…' : (hostConfig.state.error || undefined),
9001
+ 'data-utb-env-busy': envBusy ? 'true' : 'false',
9002
+ onChange: (e) => changeEnv(e.target.value),
9003
+ // 打开下拉时重读一次:另一个窗口改了开关,这里也能立刻看到最新的可选项
9004
+ onFocus: () => { if (hostConfig && typeof hostConfig.reload === 'function') hostConfig.reload(); },
9005
+ // 没有视图的环境也允许选:选中后渲染空态(只切视图,不写宿主配置)。
9006
+ // 只有“有视图但宿主不允许执行”的环境才禁用。
9007
+ }, envOptions.map((option) => h('option', {
9008
+ key: option.id,
9009
+ value: option.id,
9010
+ 'data-utb-env-enabled': 'true',
9011
+ }, option.label))),
9012
+ h('span', { className: 'utb-env-caret', 'aria-hidden': true }, h(IconChevron, null))),
9013
+ (hostConfig.state.error || envNotice) ? h('span', {
9014
+ className: 'utb-env-error',
9015
+ role: 'alert',
9016
+ 'data-uts-config-error': hostConfig.state.error ? 'read' : 'select',
9017
+ title: hostConfig.state.error || envNotice,
9018
+ }, hostConfig.state.error || envNotice) : null),
9019
+ ...(envViewSupported ? ENV_VIEWS[env]() : [noEnvView()]));
7661
9020
  }
7662
9021
 
7663
- return {
7664
- inject: ['slots'],
7665
- apply(ctx) {
7666
- // 面板组件在模块作用域定义,这里把根 ctx 交给它按需读取 DSH 服务(聊天框追加)。
7667
- pluginContext = ctx;
7668
- // 选元素引用的 `@` 来源:候选=最近选取的元素;chip 显示紧凑标签,模型文本走 codec.serialize。
7669
- registerElementReferenceSource(ctx);
7670
- // 发送后的用户消息里,同样的元素块折叠成紧凑 chip(模型文本不变)。
7671
- registerUserTextElementReference(ctx);
9022
+ /* =====================================================================================
9023
+ * DSH 设置页:第五节点为卡片目标;第三节点接口与第四节点执行端以同级标签展示。
9024
+ * 配置与就绪状态来自 Host(GET/PUT /config):页面只提交意图,Host revision 是唯一真值。
9025
+ * Host 未提供接口时页面保持可用,但明确显示读取失败,绝不把任何目标标成已就绪或显示版本。
9026
+ * ===================================================================================== */
9027
+ const TARGET_CHAINS = {
9028
+ chromium: { communication: 'CDP', backend: 'Chromium DevTools' },
9029
+ electron: { communication: 'CDP / RPC', backend: 'Electron DevTools / 桌面自动化服务' },
9030
+ linux: { communication: 'D-Bus / X11 / Wayland', backend: 'AT-SPI / 桌面输入服务' },
9031
+ webkitgtk: { communication: 'WebKit Inspector / D-Bus', backend: 'WebKitGTK 调试后端 / AT-SPI' },
9032
+ windows: { communication: 'RPC / 系统 API', backend: 'UI Automation / 桌面输入服务' },
9033
+ webview2: { communication: 'CDP / RPC', backend: 'WebView2 DevTools / UI Automation' },
9034
+ macos: { communication: 'RPC / 系统 API', backend: 'Accessibility / 桌面输入服务' },
9035
+ wkwebview: { communication: 'WebKit Inspector / RPC', backend: 'WebKit 调试后端 / Accessibility' },
9036
+ android: { communication: 'ADB / RPC', backend: 'UI Automator / 系统输入服务' },
9037
+ androidWeb: { communication: 'ADB / CDP', backend: 'UI Automator / WebView DevTools' },
9038
+ terminal: { communication: 'PTY / IPC', backend: '终端会话服务' },
9039
+ harmonyos: { communication: 'HDC / RPC', backend: 'UiTest / 系统输入服务' },
9040
+ native3d: { communication: 'RPC / IPC', backend: '桌面自动化服务 / 应用辅助程序' },
9041
+ };
7672
9042
 
7673
- ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: ID }, DebugView));
9043
+ /** 每张卡片表示运行中的应用类型;框架只作为技术栈说明。状态来自 Host 就绪结果。 */
9044
+ function plannedTarget(id, name, note, chain) {
9045
+ return { id, name, note, chain, state: 'planned' };
9046
+ }
9047
+
9048
+ function miniProgramTarget(id, platform) {
9049
+ return plannedTarget(id, platform + '小程序', '运行于' + platform + '小程序容器的应用', {
9050
+ communication: '平台自动化接口(待选)', backend: platform + '开发者工具 / 辅助程序(待选)',
9051
+ });
9052
+ }
9053
+
9054
+ const TARGET_ENV_GROUPS = [
9055
+ {
9056
+ id: 'web', title: 'Web 视图',
9057
+ targets: [
9058
+ { id: 'chrome', name: 'Chrome', note: 'Chrome / Chromium 中运行的网页', chain: TARGET_CHAINS.chromium,
9059
+ state: 'supported' },
9060
+ // Edge 与 Chrome 共用 CDP 执行端,已是「已实现」;是否真的可跑仍由 Host 就绪的依赖轴
9061
+ // (providers.edge.axes.dependency)回答,卡片不得据此显示成「可用」。
9062
+ Object.assign(plannedTarget('edge', 'Edge', 'Edge 中运行的网页', {
9063
+ communication: 'CDP', backend: 'Edge DevTools',
9064
+ }), { state: 'supported' }),
9065
+ Object.assign(plannedTarget('firefox', 'Firefox', 'Firefox 中运行的网页', {
9066
+ communication: 'WebDriver BiDi', backend: 'Firefox Remote Agent',
9067
+ }), { state: 'unsupported' }),
9068
+ ],
9069
+ },
9070
+ {
9071
+ id: 'linux', title: 'Linux 视图',
9072
+ targets: [
9073
+ plannedTarget('linux-native', 'Linux 原生桌面应用', 'GTK / Qt / Flutter 等原生窗口', TARGET_CHAINS.linux),
9074
+ plannedTarget('electron', 'Electron 应用', 'Chromium 页面与原生应用窗口', TARGET_CHAINS.electron),
9075
+ plannedTarget('tauri', 'Tauri 应用', 'WebKitGTK 页面与原生应用窗口', TARGET_CHAINS.webkitgtk),
9076
+ ],
9077
+ },
9078
+ {
9079
+ id: 'windows', title: 'Windows 视图',
9080
+ targets: [
9081
+ plannedTarget('windows-native', 'Windows 原生桌面应用', 'Win32 / Qt / Flutter 等原生窗口', TARGET_CHAINS.windows),
9082
+ plannedTarget('dotnet', '.NET 桌面应用', 'WPF / Windows Forms', TARGET_CHAINS.windows),
9083
+ plannedTarget('winui3', 'WinUI 3 应用', 'Windows App SDK 应用', TARGET_CHAINS.windows),
9084
+ plannedTarget('win-electron', 'Electron 应用', 'Chromium 页面与原生应用窗口', TARGET_CHAINS.electron),
9085
+ plannedTarget('win-tauri', 'Tauri 应用', 'WebView2 页面与原生应用窗口', TARGET_CHAINS.webview2),
9086
+ ],
9087
+ },
9088
+ {
9089
+ id: 'macos', title: 'macOS 视图',
9090
+ targets: [
9091
+ plannedTarget('macos-native', 'macOS 原生桌面应用', 'AppKit / SwiftUI / Qt / Flutter', TARGET_CHAINS.macos),
9092
+ plannedTarget('mac-electron', 'Electron 应用', 'Chromium 页面与原生应用窗口', TARGET_CHAINS.electron),
9093
+ plannedTarget('mac-tauri', 'Tauri 应用', 'WKWebView 页面与原生应用窗口', TARGET_CHAINS.wkwebview),
9094
+ ],
9095
+ },
9096
+ {
9097
+ id: 'android', title: 'Android 视图',
9098
+ targets: [
9099
+ plannedTarget('android-native', 'Android 原生应用', 'Android Views / Jetpack Compose', TARGET_CHAINS.android),
9100
+ plannedTarget('android-webview', 'Android WebView 应用', '内嵌网页;Capacitor / Cordova', TARGET_CHAINS.androidWeb),
9101
+ plannedTarget('android-flutter', 'Flutter Android 应用', 'Flutter 引擎渲染的应用', TARGET_CHAINS.android),
9102
+ plannedTarget('react-native', 'React Native 应用', 'Android 原生视图与 JS 运行时', TARGET_CHAINS.android),
9103
+ ],
9104
+ },
9105
+ {
9106
+ id: 'tui', title: 'TUI 视图',
9107
+ targets: [
9108
+ plannedTarget('python-tui', 'Python 终端应用', 'Textual 等终端界面', TARGET_CHAINS.terminal),
9109
+ plannedTarget('node-tui', 'Node.js 终端应用', 'Ink / blessed 等终端界面', TARGET_CHAINS.terminal),
9110
+ plannedTarget('go-tui', 'Go 终端应用', 'Bubble Tea 等终端界面', TARGET_CHAINS.terminal),
9111
+ plannedTarget('rust-tui', 'Rust 终端应用', 'Ratatui 等终端界面', TARGET_CHAINS.terminal),
9112
+ ],
9113
+ },
9114
+ {
9115
+ id: 'harmonyos', title: 'HarmonyOS 视图',
9116
+ targets: [
9117
+ plannedTarget('harmony-native', '鸿蒙原生应用', 'ArkUI / ArkTS', TARGET_CHAINS.harmonyos),
9118
+ plannedTarget('arkui-x', 'ArkUI-X 应用', 'ArkUI 跨平台应用', TARGET_CHAINS.harmonyos),
9119
+ plannedTarget('harmony-flutter', 'Flutter 鸿蒙应用', 'Flutter 鸿蒙适配应用', TARGET_CHAINS.harmonyos),
9120
+ plannedTarget('harmony-rn', 'React Native 鸿蒙应用', 'React Native 鸿蒙适配应用', TARGET_CHAINS.harmonyos),
9121
+ ],
9122
+ },
9123
+ {
9124
+ id: 'game', title: '游戏视图',
9125
+ targets: [
9126
+ plannedTarget('unity', 'Unity 游戏', 'Unity Player 中运行的游戏', {
9127
+ communication: 'RPC / IPC', backend: 'Unity 调试辅助程序(待选)',
9128
+ }),
9129
+ plannedTarget('unreal', 'Unreal 游戏', 'Unreal Engine 游戏实例', {
9130
+ communication: 'RPC / IPC', backend: 'Unreal 调试辅助程序(待选)',
9131
+ }),
9132
+ plannedTarget('godot', 'Godot 游戏', 'Godot 游戏实例', {
9133
+ communication: '引擎调试协议 / RPC', backend: 'Godot 调试后端 / 辅助程序(待选)',
9134
+ }),
9135
+ plannedTarget('cocos', 'Cocos 游戏', 'Cocos 原生或 Web 游戏实例', {
9136
+ communication: 'CDP / RPC', backend: 'DevTools / 引擎辅助程序(待选)',
9137
+ }),
9138
+ ],
9139
+ },
9140
+ {
9141
+ id: 'three-d', title: '3D 视图',
9142
+ targets: [
9143
+ plannedTarget('web-3d', 'Web 三维应用', 'Three.js / Babylon.js / PlayCanvas;WebGL / WebGPU', TARGET_CHAINS.chromium),
9144
+ plannedTarget('native-3d', '原生三维应用', '原生窗口中的三维场景', TARGET_CHAINS.native3d),
9145
+ plannedTarget('three-unity', 'Unity 三维应用', 'Unity Player 中的交互式三维场景', {
9146
+ communication: 'RPC / IPC', backend: 'Unity 调试辅助程序(待选)',
9147
+ }),
9148
+ // 用户 2026-11-10 要求:3D 场景补上建模/动画工具。它们都是原生桌面程序,
9149
+ // 没有现成调试协议,先按 native3d 链路登记为 planned,避免"3D 只有引擎"的误解。
9150
+ plannedTarget('blender', 'Blender', 'Blender 场景、模型与渲染设置界面', {
9151
+ communication: 'Python / RPC(bpy 待评估)', backend: 'Blender 内置 Python 控制台(待实现)',
9152
+ }),
9153
+ plannedTarget('3dsmax', '3ds Max', '3ds Max 场景、模型与材质编辑器', {
9154
+ communication: 'RPC / IPC(MAXScript 待评估)', backend: '3ds Max 脚本与辅助程序(待实现)',
9155
+ }),
9156
+ plannedTarget('mmd', 'MikuMikuDance(MMD)', 'MMD 动画、模型预览与动作编辑(PMD/PMX/VMD)', {
9157
+ communication: 'RPC / IPC(待评估)', backend: 'MMD 辅助程序(待实现)',
9158
+ }),
9159
+ plannedTarget('pmx-editor', 'PMX Editor', 'PMX 模型结构、材质与变形编辑', {
9160
+ communication: 'RPC / IPC(待评估)', backend: 'PMX Editor 辅助程序(待实现)',
9161
+ }),
9162
+ ],
9163
+ },
9164
+ {
9165
+ id: 'miniprogram', title: '小程序视图',
9166
+ targets: [
9167
+ miniProgramTarget('wechat', '微信'),
9168
+ miniProgramTarget('alipay', '支付宝'),
9169
+ miniProgramTarget('douyin', '抖音'),
9170
+ miniProgramTarget('qq', 'QQ'),
9171
+ miniProgramTarget('baidu', '百度智能'),
9172
+ miniProgramTarget('kuaishou', '快手'),
9173
+ miniProgramTarget('jd', '京东'),
9174
+ miniProgramTarget('meituan', '美团'),
9175
+ miniProgramTarget('xiaohongshu', '小红书'),
9176
+ miniProgramTarget('taobao', '淘宝'),
9177
+ miniProgramTarget('dingtalk', '钉钉'),
9178
+ ],
9179
+ },
9180
+ ];
9181
+
9182
+ /** 设置页样式:只作用于 `.uts-*`,不覆盖宿主设置页的其它样式。 */
9183
+ const TARGET_ENV_CSS = `
9184
+ .uts-settings { display: flex; flex-direction: column; gap: 20px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary); }
9185
+ .uts-head { display: flex; flex-direction: column; gap: 4px; }
9186
+ .uts-head-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; }
9187
+ .uts-filter { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; color: var(--dsw-alias-label-secondary); font-size: 12px; }
9188
+ .uts-page-title { margin: 0; font-size: 16px; font-weight: 500; line-height: 24px; }
9189
+ .uts-page-desc { margin: 0; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
9190
+ .uts-body { display: flex; flex-direction: column; gap: 12px; }
9191
+ .uts-title { margin: 0; font-size: 14px; font-weight: 600; line-height: 20px; }
9192
+ .uts-chain-guide { margin: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 20px; overflow-wrap: anywhere; }
9193
+ .uts-groups { display: flex; flex-direction: column; gap: 18px; }
9194
+ .uts-group { display: flex; flex-direction: column; gap: 8px; }
9195
+ .uts-group-heading { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; font-weight: 600; line-height: 20px; }
9196
+ .uts-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
9197
+ .uts-card-title { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
9198
+ .uts-card-switch { flex: none; display: inline-flex; align-items: center; }
9199
+ .uts-card-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; }
9200
+ .uts-card-actions { display: flex; gap: 6px; }
9201
+ .uts-card-foot .uts-card-actions { margin: 0 0 0 auto; }
9202
+ .uts-diagnose-btn { box-sizing: border-box; padding: 2px 8px; font: inherit; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-secondary); background: transparent; border: .5px solid var(--dsw-alias-border-l2); border-radius: 6px; cursor: pointer; }
9203
+ .uts-diagnose-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l1); }
9204
+ .uts-diagnostics .uts-axes { margin-top: 8px; }
9205
+ .uts-diagnostics .uts-head-row { gap: 8px; }
9206
+ .uts-group-env { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; padding: 2px 8px; font-size: 12px; font-weight: 400; color: var(--dsw-alias-label-secondary); }
9207
+ .uts-group-toggle { display: flex; align-items: center; gap: 8px; width: 100%; margin: 0; padding: 4px 8px; font: inherit; font-weight: 600; line-height: 20px; color: var(--dsw-alias-label-primary); text-align: left; background: transparent; border: 0; border-radius: 8px; cursor: pointer; }
9208
+ .uts-group-toggle:hover { background: var(--dsw-alias-bg-layer-2); }
9209
+ .uts-group-toggle:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
9210
+ .uts-group-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9211
+ .uts-group-count { flex: 0 0 auto; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; }
9212
+ .uts-group-chevron { flex: 0 0 auto; margin-left: auto; display: grid; place-items: center; color: var(--dsw-alias-label-secondary); transform: rotate(-90deg); transition: transform .15s ease; }
9213
+ .uts-group[data-uts-expanded="true"] .uts-group-chevron { transform: rotate(0deg); }
9214
+ .uts-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px 20px; }
9215
+ .uts-item { display: flex; flex-direction: column; min-width: 0; }
9216
+ .uts-card { box-sizing: border-box; width: 100%; flex: 1 1 auto; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border: .5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); }
9217
+ .uts-card-name { font-size: 13px; font-weight: 600; line-height: 20px; overflow-wrap: anywhere; }
9218
+ .uts-card-note { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
9219
+ font-size: 12px; line-height: 18px; max-height: 36px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
9220
+ .uts-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
9221
+ .uts-field-inline { flex-direction: row; align-items: center; gap: 8px; }
9222
+ .uts-field-label { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
9223
+ .uts-field-value { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
9224
+ .uts-field-value[data-uts-valid="false"], .uts-field-value[data-uts-valid="unknown"] { color: var(--dsw-alias-label-tertiary); }
9225
+ .uts-empty { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
9226
+ .uts-tags { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 6px; margin-top: auto; min-width: 0; }
9227
+ .uts-card-state { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
9228
+ .uts-tag { box-sizing: border-box; display: inline-flex; align-items: center; min-height: 18px; max-width: 100%; padding: 2px 7px; border-radius: 6px; font-size: 11px; line-height: 16px; white-space: normal; overflow-wrap: anywhere; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-bg-layer-2); border: .5px solid var(--dsw-alias-border-l2); }
9229
+ .uts-tag[data-uts-tag="supported"] { color: #16a34a; background: color-mix(in srgb, #16a34a 12%, transparent); border-color: color-mix(in srgb, #16a34a 35%, transparent); }
9230
+ .uts-chain { margin-top: 0; margin-bottom: 10px; }
9231
+ .uts-axes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px 14px; padding: 8px 10px; border: .5px dashed var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); }
9232
+ .uts-axis { display: flex; flex-direction: column; min-width: 0; }
9233
+ .uts-axis-head { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
9234
+ .uts-axis-label { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-secondary); }
9235
+ .uts-axis-status { font-size: 11px; line-height: 16px; font-weight: 600; color: var(--dsw-alias-label-tertiary); }
9236
+ .uts-axis-reason { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
9237
+ .uts-axis[data-uts-status="supported"] .uts-axis-status,
9238
+ .uts-axis[data-uts-status="ok"] .uts-axis-status,
9239
+ .uts-axis[data-uts-status="bound"] .uts-axis-status,
9240
+ .uts-axis[data-uts-status="available"] .uts-axis-status,
9241
+ .uts-axis[data-uts-status="true"] .uts-axis-status { color: #16a34a; }
9242
+ .uts-axis[data-uts-status="failed"] .uts-axis-status,
9243
+ .uts-axis[data-uts-status="degraded"] .uts-axis-status,
9244
+ .uts-axis[data-uts-status="stale"] .uts-axis-status,
9245
+ .uts-axis[data-uts-status="partial"] .uts-axis-status,
9246
+ .uts-axis[data-uts-status="unavailable"] .uts-axis-status { color: #d97706; }
9247
+ .uts-config-error { margin: 0; padding: 8px 10px; border-radius: 8px; font-size: 12px; line-height: 18px; color: #b91c1c; background: color-mix(in srgb, #b91c1c 10%, transparent); border: .5px solid color-mix(in srgb, #b91c1c 30%, transparent); overflow-wrap: anywhere; }
9248
+ .uts-config-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
9249
+ .uts-config-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-left: auto; }
9250
+ .uts-link-btn { padding: 2px 8px; font: inherit; font-size: 12px; line-height: 18px; color: var(--dsw-alias-brand-primary); background: transparent; border: .5px solid var(--dsw-alias-border-l2); border-radius: 6px; cursor: pointer; }
9251
+ .uts-link-btn:disabled { color: var(--dsw-alias-label-tertiary); cursor: not-allowed; }
9252
+ .uts-switch { position: relative; display: inline-flex; align-items: center; width: 32px; height: 18px; flex: 0 0 auto; cursor: pointer; }
9253
+ .uts-switch-input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
9254
+ .uts-switch-input:disabled { cursor: not-allowed; }
9255
+ .uts-switch-track { position: relative; display: block; width: 32px; height: 18px; border-radius: 999px; background: var(--dsw-alias-bg-layer-3, color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent)); transition: background-color .15s ease; }
9256
+ .uts-switch-thumb { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .28); transition: transform .15s ease; }
9257
+ .uts-switch-input:checked + .uts-switch-track { background: var(--dsw-alias-brand-primary); }
9258
+ .uts-switch-input:checked + .uts-switch-track .uts-switch-thumb { transform: translateX(14px); }
9259
+ .uts-switch-input:disabled + .uts-switch-track { opacity: .45; }
9260
+ .uts-switch-input:focus-visible + .uts-switch-track { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
9261
+ @media (prefers-reduced-motion: reduce) { .uts-switch-track, .uts-switch-thumb, .uts-group-chevron { transition: none; } }
9262
+ /* --- 诊断页四个板块:实时调试拓扑 / 各模块诊断状态(六轴卡片)/ 诊断日志 / 调试节点职责 --- */
9263
+ .uts-diag-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; }
9264
+ .uts-diag-overall { box-sizing: border-box; display: inline-flex; align-items: center; gap: 7px; padding: 3px 11px; border-radius: 999px; font-size: 12px; line-height: 18px; color: #d97706; background: color-mix(in srgb, #d97706 12%, transparent); border: .5px solid color-mix(in srgb, #d97706 38%, transparent); }
9265
+ /* 胶囊跟随五态(旧的三态 ready/pending/broken 已全部退役,写成 ready 会永远不匹配、只剩默认橙色)。 */
9266
+ .uts-diag-overall[data-uts-overall="PASS"] { color: #16a34a; background: color-mix(in srgb, #16a34a 12%, transparent); border-color: color-mix(in srgb, #16a34a 38%, transparent); }
9267
+ .uts-diag-overall[data-uts-overall="WARN"] { color: #d97706; background: color-mix(in srgb, #d97706 12%, transparent); border-color: color-mix(in srgb, #d97706 38%, transparent); }
9268
+ .uts-diag-overall[data-uts-overall="FAIL"] { color: #b91c1c; background: color-mix(in srgb, #b91c1c 12%, transparent); border-color: color-mix(in srgb, #b91c1c 38%, transparent); }
9269
+ .uts-diag-overall[data-uts-overall="SKIPPED"] { color: var(--dsw-alias-label-tertiary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent); border-style: dashed; border-color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 55%, transparent); }
9270
+ .uts-diag-overall[data-uts-overall="UNKNOWN"] { color: var(--dsw-alias-label-tertiary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent); border-style: dotted; border-color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent); }
9271
+ .uts-diag-overall-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
9272
+ .uts-diag-stale { flex: 1 1 100%; margin: 0; font-size: 11px; line-height: 16px; color: #d97706; overflow-wrap: anywhere; }
9273
+ .uts-diag-panel { display: flex; flex-direction: column; gap: 10px; padding: 13px 15px; border: .5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); }
9274
+ .uts-diag-panel-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; }
9275
+ .uts-diag-panel-title { margin: 0; font-size: 13px; font-weight: 600; line-height: 20px; }
9276
+ .uts-diag-flow { margin: 0; font-size: 10px; line-height: 15px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
9277
+ .uts-diag-chain-scroll { overflow-x: auto; overflow-y: hidden; padding-bottom: 2px; }
9278
+ .uts-diag-chain { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 56px minmax(0, 1fr) 56px minmax(0, 1fr) 56px minmax(0, 1fr) 56px minmax(0, 1fr); align-items: center; min-width: 760px; padding-top: 16px; }
9279
+ /* 纯装饰导轨(不承载状态):虚线 + 右端静态小点,颜色只取中性值。 */
9280
+ .uts-diag-rail { position: absolute; left: 7.5%; right: 7.5%; top: 4px; height: 0; border-top: 1px dashed color-mix(in srgb, var(--dsw-alias-label-tertiary) 55%, transparent); pointer-events: none; }
9281
+ .uts-diag-rail-dot { position: absolute; right: -2px; top: -3px; width: 5px; height: 5px; border-radius: 50%; background: color-mix(in srgb, var(--dsw-alias-label-tertiary) 70%, transparent); opacity: .65; }
9282
+ .uts-diag-node { box-sizing: border-box; display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 10px 11px; border: .5px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--dsw-alias-bg-layer-2); }
9283
+ .uts-diag-node[data-uts-node-state="PASS"] { border-color: color-mix(in srgb, #16a34a 42%, transparent); }
9284
+ .uts-diag-node[data-uts-node-state="WARN"] { border-color: color-mix(in srgb, #d97706 42%, transparent); }
9285
+ .uts-diag-node[data-uts-node-state="FAIL"] { border-color: color-mix(in srgb, #b91c1c 45%, transparent); }
9286
+ .uts-diag-node[data-uts-node-state="SKIPPED"] { border-style: dashed; border-color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 55%, transparent); }
9287
+ .uts-diag-node[data-uts-node-state="UNKNOWN"] { border-style: dashed; border-color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 40%, transparent); }
9288
+ .uts-diag-node[data-uts-stage-phase="running"] { border-color: var(--dsw-alias-brand-primary); animation: uts-diag-pulse 1s ease-in-out infinite alternate; }
9289
+ @keyframes uts-diag-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-brand-primary) 0%, transparent); } to { box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 22%, transparent); } }
9290
+ .uts-diag-node-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
9291
+ .uts-diag-node-index { font-size: 9px; line-height: 14px; letter-spacing: .1em; color: var(--dsw-alias-label-tertiary); }
9292
+ .uts-diag-node-glyph { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 7px; color: var(--dsw-alias-label-tertiary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent); }
9293
+ .uts-diag-node[data-uts-node-state="PASS"] .uts-diag-node-glyph { color: #16a34a; background: color-mix(in srgb, #16a34a 12%, transparent); }
9294
+ .uts-diag-node[data-uts-node-state="WARN"] .uts-diag-node-glyph { color: #d97706; background: color-mix(in srgb, #d97706 12%, transparent); }
9295
+ .uts-diag-node[data-uts-node-state="FAIL"] .uts-diag-node-glyph { color: #b91c1c; background: color-mix(in srgb, #b91c1c 12%, transparent); }
9296
+ .uts-diag-node-role { font-size: 10px; line-height: 15px; color: var(--dsw-alias-label-tertiary); }
9297
+ .uts-diag-node-name { font-size: 12px; font-weight: 600; line-height: 18px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9298
+ /* 职责描述固定占两行(15px × 2 = 30px):卡片高度不随文案长短跳动 */
9299
+ .uts-diag-node-sub { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; min-height: 30px; font-size: 10px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
9300
+ .uts-diag-node-status { display: flex; align-items: flex-start; gap: 5px; min-width: 0; font-size: 10px; line-height: 15px; color: var(--dsw-alias-label-tertiary); }
9301
+ /* 状态信息预留两行(30px = 2 × 15px),超出两行才截断;完整内容在节点悬停提示里。 */
9302
+ .uts-diag-node-status > span { min-width: 0; min-height: 30px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
9303
+ .uts-diag-node[data-uts-node-state="WARN"] .uts-diag-node-status { color: #d97706; }
9304
+ .uts-diag-node[data-uts-node-state="FAIL"] .uts-diag-node-status { color: #b91c1c; }
9305
+ .uts-diag-dot { flex: none; width: 5px; height: 5px; margin-top: 5px; border-radius: 50%; background: var(--dsw-alias-label-tertiary); }
9306
+ .uts-diag-node[data-uts-node-state="PASS"] .uts-diag-dot { background: #16a34a; }
9307
+ .uts-diag-node[data-uts-node-state="WARN"] .uts-diag-dot { background: #d97706; }
9308
+ .uts-diag-node[data-uts-node-state="FAIL"] .uts-diag-dot { background: #b91c1c; }
9309
+ .uts-diag-edge { position: relative; display: flex; align-items: center; justify-content: center; align-self: center; width: 100%; }
9310
+ .uts-diag-edge-line { position: relative; display: block; width: 100%; height: 1.5px; background: #16a34a; }
9311
+ .uts-diag-edge[data-uts-edge-state="PASS"] .uts-diag-edge-line { background: #16a34a; }
9312
+ .uts-diag-edge[data-uts-edge-state="PASS"] .uts-diag-edge-line::before { content: ""; position: absolute; top: 50%; right: 0; transform: translateY(-50%); border-left: 4px solid #16a34a; border-top: 3px solid transparent; border-bottom: 3px solid transparent; }
9313
+ .uts-diag-edge[data-uts-edge-state="WARN"] .uts-diag-edge-line { background: #d97706; }
9314
+ .uts-diag-edge[data-uts-edge-state="WARN"] .uts-diag-edge-label { color: #d97706; }
9315
+ .uts-diag-edge[data-uts-edge-state="SKIPPED"] .uts-diag-edge-line { background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--dsw-alias-label-tertiary) 60%, transparent) 0 4px, transparent 4px 8px); }
9316
+ .uts-diag-edge[data-uts-edge-state="UNKNOWN"] .uts-diag-edge-line { background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent) 0 2px, transparent 2px 6px); }
9317
+ .uts-diag-edge[data-uts-edge-state="FAIL"] .uts-diag-edge-line { background: repeating-linear-gradient(90deg, #b91c1c 0 4px, transparent 4px 8px); }
9318
+ .uts-diag-edge[data-uts-edge-state="FAIL"] .uts-diag-edge-label { color: #b91c1c; }
9319
+ /* 沿通路移动的小球:绝对定位,不参与布局;只有本 run 里 PASS 且下游已 stage-end 的段才跑。 */
9320
+ .uts-diag-edge-dot { position: absolute; top: 50%; left: 0; width: 6px; height: 6px; margin-top: -3px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 6px color-mix(in srgb, #16a34a 55%, transparent); opacity: .9; display: none; pointer-events: none; }
9321
+ .uts-diag-edge[data-uts-edge-live="true"] .uts-diag-edge-dot { display: block; animation: uts-diag-flow 2.1s linear infinite; }
9322
+ @keyframes uts-diag-flow { 0% { left: 0; opacity: 0; } 12% { opacity: .9; } 88% { opacity: .9; } 100% { left: calc(100% - 6px); opacity: 0; } }
9323
+ .uts-diag-edge-break { position: absolute; display: grid; place-items: center; width: 15px; height: 15px; border-radius: 50%; font-size: 10px; line-height: 1; color: #b91c1c; background: var(--dsw-alias-bg-layer-1); border: .5px solid color-mix(in srgb, #b91c1c 50%, transparent); }
9324
+ .uts-diag-edge-label { position: absolute; top: calc(50% + 7px); left: 50%; transform: translateX(-50%); font-size: 9px; line-height: 13px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
9325
+ .uts-diag-panel-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; }
9326
+ .uts-diag-panel-title { margin: 0; font-size: 13px; font-weight: 600; line-height: 20px; }
9327
+ .uts-diag-flow { margin: 0; font-size: 10px; line-height: 15px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
9328
+ .uts-diag-flow { margin: 0; font-size: 10px; line-height: 15px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
9329
+ .uts-diag-axes { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
9330
+ .uts-diag-axis { box-sizing: border-box; display: flex; align-items: flex-start; gap: 10px; min-width: 0; padding: 11px 12px; border: .5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-2); }
9331
+ .uts-diag-axis-icon { flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; color: var(--dsw-alias-label-secondary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent); }
9332
+ .uts-diag-axis[data-uts-status="supported"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="ok"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="bound"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="available"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="true"] .uts-diag-axis-icon { color: #16a34a; background: color-mix(in srgb, #16a34a 12%, transparent); }
9333
+ .uts-diag-axis[data-uts-status="failed"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="degraded"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="stale"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="partial"] .uts-diag-axis-icon, .uts-diag-axis[data-uts-status="unavailable"] .uts-diag-axis-icon { color: #d97706; background: color-mix(in srgb, #d97706 12%, transparent); }
9334
+ .uts-diag-axis-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; }
9335
+ .uts-diag-axis-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
9336
+ .uts-diag-axis-name { font-size: 11px; line-height: 16px; font-weight: 600; color: var(--dsw-alias-label-secondary); }
9337
+ .uts-diag-axis-value { flex: none; font-size: 11px; line-height: 16px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-label-tertiary); }
9338
+ .uts-diag-axis[data-uts-status="supported"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="ok"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="bound"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="available"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="true"] .uts-diag-axis-value { color: #16a34a; }
9339
+ .uts-diag-axis[data-uts-status="failed"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="degraded"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="stale"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="partial"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="unavailable"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="unsupported"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="unbound"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="not-probed"] .uts-diag-axis-value, .uts-diag-axis[data-uts-status="false"] .uts-diag-axis-value { color: #d97706; }
9340
+ .uts-diag-axis-reason { font-family: inherit; font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
9341
+ /* 占位固定三行(16px × 3):六张卡片高度一致,且 run 结果变化时卡片不会跳动 */
9342
+ .uts-diag-axis-checks { display: block; min-height: 48px; font-family: inherit; font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
9343
+ /* 一行一项:不用分隔符串成一句,逐行列出更易读;单项过长用省略号,完整列表看悬停提示 */
9344
+ .uts-diag-axis-checks-item { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
9345
+ /* 「+n」是计数标签:字号更小、颜色更淡,与描述文字明显区分 */
9346
+ .uts-diag-axis-checks-more { margin-left: 5px; font-size: 9px; line-height: 16px; letter-spacing: .02em; color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 55%, transparent); }
9347
+ .uts-diag-bottom { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: 12px; }
9348
+ .uts-diag-events { display: flex; flex-direction: column; gap: 7px; }
9349
+ .uts-diag-event { display: grid; grid-template-columns: 62px 8px minmax(0, 1fr); align-items: start; gap: 8px; min-width: 0; font-size: 11px; line-height: 16px; }
9350
+ .uts-diag-event-time { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
9351
+ /* 圆点颜色只跟随事件状态(旧写法按 kind="change" 上橙色,把所有 stage 行都染成告警色)。 */
9352
+ .uts-diag-event-pin { width: 5px; height: 5px; margin-top: 6px; border-radius: 50%; background: var(--dsw-alias-label-tertiary); }
9353
+ .uts-diag-event-pin[data-uts-event-status="PASS"] { background: #16a34a; }
9354
+ .uts-diag-event-pin[data-uts-event-status="WARN"] { background: #d97706; }
9355
+ .uts-diag-event-pin[data-uts-event-status="FAIL"] { background: #b91c1c; }
9356
+ .uts-diag-event-pin[data-uts-event-status="SKIPPED"] { background: color-mix(in srgb, var(--dsw-alias-label-tertiary) 85%, transparent); }
9357
+ .uts-diag-event-pin[data-uts-event-status="UNKNOWN"] { background: color-mix(in srgb, var(--dsw-alias-label-tertiary) 60%, transparent); }
9358
+ .uts-diag-event-pin[data-uts-event-status="INFO"] { background: var(--dsw-alias-label-tertiary); }
9359
+ .uts-diag-event[data-uts-event-time-source="observed"] .uts-diag-event-time { font-style: italic; opacity: .75; }
9360
+ .uts-diag-event-text { min-width: 0; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
9361
+ .uts-diag-duties { display: flex; flex-direction: column; gap: 8px; }
9362
+ .uts-diag-duty { display: flex; align-items: flex-start; gap: 8px; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
9363
+ .uts-diag-duty-arrow { flex: none; color: #16a34a; }
9364
+ .uts-diag-duty-text strong { font-weight: 600; color: var(--dsw-alias-label-primary); }
9365
+ .uts-diag-empty { margin: 0; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
9366
+ @media (max-width: 900px) { .uts-diag-bottom { grid-template-columns: minmax(0, 1fr); } }
9367
+ @media (prefers-reduced-motion: reduce) {
9368
+ .uts-diag-edge[data-uts-edge-live="true"] .uts-diag-edge-dot { animation: none; }
9369
+ .uts-diag-node[data-uts-stage-phase="running"] { animation: none; }
9370
+ }
9371
+ .uts-diag-modal-overlay { position: fixed; inset: 0; z-index: 3000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(0, 0, 0, .45); pointer-events: auto; }
9372
+ .uts-diag-modal-panel { box-sizing: border-box; position: relative; display: flex; flex-direction: column; width: min(1180px, 94vw); max-height: 86vh; overflow: auto; border-radius: 14px; border: .5px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); box-shadow: 0 24px 64px rgba(0, 0, 0, .32); }
9373
+ .uts-diag-modal-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 16px; border-bottom: .5px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); }
9374
+ .uts-diag-modal-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
9375
+ .uts-diag-modal-close { flex: none; }
9376
+ .uts-diag-modal-body { padding: 14px 16px 18px; }
9377
+ .uts-diagnostics-body { display: flex; flex-direction: column; gap: 12px; }
9378
+ .uts-diag-action { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; }
9379
+
9380
+ `;
9381
+
9382
+ /* ---- Host config (M4): one authoritative revision for the settings page and the panel ---- */
9383
+ const SETTINGS_EXPANDED_KEY = 'dsh.ui-testing.settings.expanded';
9384
+ const AXIS_ORDER = ['support', 'enabled', 'dependency', 'connection', 'target', 'capability'];
9385
+ const AXIS_LABEL = {
9386
+ support: '工程支持', enabled: '用户启用', dependency: '依赖可用',
9387
+ connection: '连接可信', target: '目标绑定', capability: '能力就绪',
9388
+ };
9389
+ /** M0 §5.2 status vocabulary → the short text the page shows. Unknown values fall through. */
9390
+ const AXIS_TEXT = {
9391
+ support: { supported: '已实现', unsupported: '未支持', planned: '未接入', 'not-probed': '未探测' },
9392
+ enabled: { true: '已启用', false: '未启用', 'not-probed': '未知' },
9393
+ dependency: { ok: '可用', degraded: '降级', failed: '不可用', 'not-probed': '未探测' },
9394
+ connection: { ok: '可信', failed: '失败', 'not-probed': '未探测' },
9395
+ target: { bound: '已绑定', unbound: '未绑定', stale: '已失效', 'not-probed': '未探测' },
9396
+ capability: { available: '可用', partial: '部分可用', unavailable: '不可用', unsupported: '不支持', 'not-probed': '未探测' },
9397
+ };
9398
+ function axisText(axis, status) {
9399
+ const table = AXIS_TEXT[axis] || {};
9400
+ return table[status] || status || '未知';
9401
+ }
9402
+
9403
+ /** One-line, human-readable detail for an axis tooltip (never invents missing facts). */
9404
+ function axisDetail(axis) {
9405
+ const parts = [];
9406
+ if (axis.targetId) parts.push('目标 ' + axis.targetId);
9407
+ if (axis.checkedAt) {
9408
+ const at = new Date(axis.checkedAt);
9409
+ parts.push('检查时间 ' + (Number.isNaN(at.getTime()) ? String(axis.checkedAt) : at.toISOString()));
9410
+ }
9411
+ if (axis.evidenceKind) parts.push('证据 ' + axis.evidenceKind);
9412
+ if (axis.checks && typeof axis.checks === 'object') {
9413
+ // readiness 轴历史上的 check 用 `ok`,诊断 run 的探测用 `pass`:两种都算通过(大小写不敏感),
9414
+ // 只有真正没通过的项才列进提示,别再把「六条全 pass」说成「未通过」。
9415
+ const statusOf = (check) => {
9416
+ if (typeof check === 'string') return check;
9417
+ if (check && typeof check === 'object' && typeof check.status === 'string') return check.status;
9418
+ return '';
9419
+ };
9420
+ const passed = (status) => ['ok', 'pass'].includes(String(status || '').toLowerCase());
9421
+ const rows = Object.entries(axis.checks).map(([name, check]) => ({
9422
+ name,
9423
+ status: statusOf(check),
9424
+ reason: check && typeof check === 'object' ? check.reason || '' : '',
9425
+ }));
9426
+ const failed = rows.filter((row) => !passed(row.status));
9427
+ if (failed.length) {
9428
+ parts.push('未通过 ' + failed.map((row) => row.name + '=' + (row.status || 'unknown') + (row.reason ? '(' + row.reason + ')' : '')).join(', '));
9429
+ } else if (rows.length) {
9430
+ parts.push('全部通过 ' + rows.map((row) => row.name + '=' + row.status).join(', '));
9431
+ }
9432
+ }
9433
+ return parts.join(' · ');
9434
+ }
9435
+
9436
+ /* ---- Provider diagnostics page: topology / six axes / event stream / duties ---------------- */
9437
+
9438
+ /**
9439
+ * Prototype display order for the six axes on the diagnostics page. `AXIS_ORDER` stays the
9440
+ * data/probe order (other code and the acceptance script read it), this one is presentation
9441
+ * only: it reorders the same six keys, it never adds or drops an axis.
9442
+ */
9443
+ const DIAG_AXIS_ORDER = ['support', 'target', 'enabled', 'capability', 'dependency', 'connection'];
9444
+ /** Diagnostics page polls the existing `/config?view=global`; no new Host endpoint. */
9445
+ const DIAG_POLL_MS = 5000;
9446
+ const DIAG_EVENT_LIMIT = 30;
9447
+ const DIAG_EVENT_ROWS = 8;
9448
+
9449
+ /** Node ids are fixed by the prototype: five logical links from agent to target. */
9450
+ const DIAG_DUTY_TEXT = {
9451
+ agent: '理解任务目标,发起工具调用并消费诊断结果',
9452
+ controller: '统一调度,选择能力与执行路径',
9453
+ interface: '通过通信接口传递命令、事件与会话数据',
9454
+ backend: '调用浏览器调试执行后端执行操作',
9455
+ target: '提供实际运行状态与调试证据',
9456
+ };
9457
+
9458
+ /** 14px inline SVG per axis/node — inline paths, never an external font, image or sprite. */
9459
+ function diagSvg(children) {
9460
+ return h('svg', {
9461
+ width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
9462
+ strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
9463
+ }, ...children);
9464
+ }
9465
+ function diagAxisIcon(axisKey) {
9466
+ if (axisKey === 'support') {
9467
+ return diagSvg([h('path', { key: 'a', d: 'm12 3 8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4Z' }),
9468
+ h('path', { key: 'b', d: 'm8 12 2.5 2.5L16 9' })]);
9469
+ }
9470
+ if (axisKey === 'target') {
9471
+ return diagSvg([h('path', { key: 'a', d: 'M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1' }),
9472
+ h('path', { key: 'b', d: 'M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1.1-1.1' })]);
9473
+ }
9474
+ if (axisKey === 'enabled') {
9475
+ return diagSvg([h('circle', { key: 'a', cx: 12, cy: 12, r: 9 }), h('path', { key: 'b', d: 'M12 7v5l3 2' })]);
9476
+ }
9477
+ if (axisKey === 'capability') {
9478
+ return diagSvg([h('path', { key: 'a', d: 'M4 5h16v14H4z' }),
9479
+ h('path', { key: 'b', d: 'M8 9h8M8 12.5h5M8 16h7' })]);
9480
+ }
9481
+ if (axisKey === 'dependency') {
9482
+ return diagSvg([h('circle', { key: 'a', cx: 12, cy: 12, r: 4 }),
9483
+ h('path', { key: 'b', d: 'M12 3v3M5.6 5.6l2.1 2.1M3 12h3M5.6 18.4l2.1-2.1M12 18v3M18.4 18.4l-2.1-2.1M18 12h3M18.4 5.6l-2.1 2.1' })]);
9484
+ }
9485
+ return diagSvg([h('path', { key: 'a', d: 'M12 3 20 7v5c0 4.5-3.2 7.5-8 9-4.8-1.5-8-4.5-8-9V7l8-4Z' }),
9486
+ h('path', { key: 'b', d: 'M9 12h6' })]);
9487
+ }
9488
+ function diagNodeIcon(nodeId) {
9489
+ if (nodeId === 'agent') {
9490
+ return diagSvg([h('path', { key: 'a', d: 'M12 3 14 8l5-2-2 5 4 3-5 1-1 5-3-4-5 2 2-5-4-3 5-1 2-5Z' }),
9491
+ h('circle', { key: 'b', cx: 12, cy: 12, r: 2 })]);
9492
+ }
9493
+ if (nodeId === 'controller') {
9494
+ return diagSvg([h('rect', { key: 'a', x: 4, y: 4, width: 16, height: 16, rx: 3 }),
9495
+ h('path', { key: 'b', d: 'M8 9h8M8 13h5M8 17h8' })]);
9496
+ }
9497
+ if (nodeId === 'interface') {
9498
+ return diagSvg([h('path', { key: 'a', d: 'M8 8 4 12l4 4M16 8l4 4-4 4M14 5l-4 14' })]);
9499
+ }
9500
+ if (nodeId === 'backend') {
9501
+ return diagSvg([h('rect', { key: 'a', x: 3, y: 4, width: 18, height: 13, rx: 2 }),
9502
+ h('path', { key: 'b', d: 'M8 21h8M12 17v4M7 9h4M7 12h8' })]);
9503
+ }
9504
+ return diagSvg([h('circle', { key: 'a', cx: 12, cy: 12, r: 8 }),
9505
+ h('path', { key: 'b', d: 'M4 12h16M12 4a13 13 0 0 1 0 16M12 4a13 13 0 0 0 0 16' })]);
9506
+ }
9507
+
9508
+ /**
9509
+ * A Host readiness `checkedAt` (ms number, numeric string or ISO string) as an ISO string, or
9510
+ * `''` when the value cannot be parsed. Timestamps are never invented: an unusable value shows
9511
+ * as `--:--:--` instead of the current clock.
9512
+ */
9513
+ function diagIsoAt(value) {
9514
+ if (value === null || value === undefined || value === '') return '';
9515
+ const numeric = typeof value === 'number' ? value : (/^\d{6,}$/u.test(String(value)) ? Number(value) : null);
9516
+ const at = numeric === null ? new Date(String(value)) : new Date(numeric);
9517
+ return Number.isNaN(at.getTime()) ? '' : at.toISOString();
9518
+ }
9519
+ /** `HH:MM:SS` for the local clock; `--:--:--` when the timestamp is missing or unparseable. */
9520
+ function diagClock(iso) {
9521
+ const at = new Date(String(iso || ''));
9522
+ if (!iso || Number.isNaN(at.getTime())) return '--:--:--';
9523
+ const pad = (value) => String(value).padStart(2, '0');
9524
+ return pad(at.getHours()) + ':' + pad(at.getMinutes()) + ':' + pad(at.getSeconds());
9525
+ }
9526
+ /**
9527
+ * The machine-readable shape of a non-ready node: the Host's own reason code when it sent one
9528
+ * (`no-target`, `executable-missing`, `provider-not-selected`, …), else `<axis>-<status>`.
9529
+ */
9530
+ function diagReasonCode(axisKey, axis) {
9531
+ const reason = axis && axis.reason ? String(axis.reason).trim() : '';
9532
+ if (/^[a-z][a-z0-9-]{1,}$/iu.test(reason)) return reason;
9533
+ return axisKey + '-' + ((axis && axis.status) || 'not-probed');
9534
+ }
9535
+ /** What a node says under its name: the Host's reason code, else the axis status text. */
9536
+ function diagStatusText(axisKey, axis) {
9537
+ const reason = axis && axis.reason ? String(axis.reason).trim() : '';
9538
+ if (/^[a-z][a-z0-9-]{1,}$/iu.test(reason)) return reason;
9539
+ if (axis && axis.status) return axisText(axisKey, axis.status);
9540
+ return '未探测';
9541
+ }
9542
+
9543
+ /* ---- Provider diagnostics: one real Host run (NDJSON) → five states ------------------ */
9544
+
9545
+ /**
9546
+ * 五态:本页所有节点/边只说这五个词,且只由 Host 的 stage 事件决定。
9547
+ *
9548
+ * 中文按英语状态本身直译,**不用**「待处理」这类动作语气:`WARN` 是本次 run 已经得出的
9549
+ * 结论(后面紧跟 `provider-mismatch`、`executable-missing` 这类原因码),写成「待处理」
9550
+ * 会被读成「还没点开始诊断」。还没跑出结论的那一屏另有独立说法(`待诊断` / `诊断未完成`),
9551
+ * 见 `diagRunlessText`,与五态不共用词。
9552
+ */
9553
+ const DIAG_STATES = ['PASS', 'WARN', 'FAIL', 'SKIPPED', 'UNKNOWN'];
9554
+ const DIAG_STATE_TEXT = { PASS: '正常', WARN: '警告', FAIL: '异常', SKIPPED: '跳过', UNKNOWN: '未知' };
9555
+ const DIAG_STAGE_IDS = ['1', '2', '3', '4', '5'];
9556
+ const DIAG_RUN_ROWS = 8;
9557
+ /** 能力就绪的检查项最多显示三行;每张卡片都按三行占位(悬停看全部)。 */
9558
+ const DIAG_CHECK_LINES = 3;
9559
+ /** 最近一次 run,按 envId/providerId 保留:重进诊断页显示它,但不重放动画、不自动重跑。 */
9560
+ const DIAG_RUNS = new Map();
9561
+ const diagRunKey = (groupId, providerId) => (groupId || '') + '/' + (providerId || '');
9562
+ /**
9563
+ * 五态在界面上**只写中文**:大写状态码是给调用方 agent 的,留在 `data-uts-*-state` 属性与
9564
+ * NDJSON 里,不挤进可见文案。没有中文的非五态值(如事件流的 `INFO`)原样返回,不编造。
9565
+ */
9566
+ function diagStateLabel(state) {
9567
+ const key = String(state || '').toUpperCase();
9568
+ return DIAG_STATE_TEXT[key] || String(state || '');
9569
+ }
9570
+ /**
9571
+ * 还没有 run 结果时的说法,**不是第六个状态**,只是「本轮还没有结论」:
9572
+ * `待诊断` = 这一屏还没跑过任何 run;`诊断未完成` = 跑过但没拿到结果(错误另起一行说明)。
9573
+ * 有结论时返回空串,调用方回落到五态文案。这样「没开始」和「已经诊断出问题」用词不同,
9574
+ * 不会再把 `WARN · provider-mismatch` 读成「等你操作」。
9575
+ */
9576
+ function diagRunlessText(run) {
9577
+ const current = run || {};
9578
+ if ((current.events || []).length) return '';
9579
+ if (current.phase === 'running') return '';
9580
+ return current.error ? '诊断未完成' : '待诊断';
9581
+ }
9582
+
9583
+ /**
9584
+ * 状态码的中英对照:**英文码是 Host 的机器契约**(`data-uts-*`、NDJSON、工具返回值里一律
9585
+ * 保持原样,agent 直接读),**中文只给前端客户看**。界面上渲染成 `中文(english-code)`,
9586
+ * 两种写法同时在场:人读得懂,agent 也解析得到。
9587
+ *
9588
+ * 收录范围 = 诊断页可能出现的一切码:① `integration/diagnose.js` 五阶段的原因;
9589
+ * ② 就绪六轴来自 `core/readiness.js` / `integration/cross-environment.js` 的原因;
9590
+ * ③ 页面自身造的两个(`no-run`、`run-in-progress`)。工具级错误码(`index.js` 里的
9591
+ * `reason`)不进这张表——它们不出现在本页。
9592
+ *
9593
+ * 表没收录的码**不猜**:原样显示英文;`tool-call-*`、`stage-<n>` 这两个动态族按前缀兜底。
9594
+ * 词表与 `docs/diagnose-reason-codes.md` 必须逐条一致,由
9595
+ * `tests/contracts/diagnose-status-text.test.mjs` 强制。
9596
+ */
9597
+ const DIAG_REASON_ZH = {
9598
+ // ① 诊断 run 的五个阶段
9599
+ 'scope-unresolved': '无法确定当前作用域(不知道要查哪个工作区)',
9600
+ 'registration-unreadable': '读取工具注册信息失败',
9601
+ 'registration-broken': '工具声明与实际注册对不上',
9602
+ 'tool-call-not-attempted': '没能发起工具调用',
9603
+ 'tool-call-failed': '工具调用失败',
9604
+ 'tool-not-admitted': '本作用域未放行这些工具',
9605
+ 'config-invalid': '宿主配置无效',
9606
+ 'adapter-unresolved': '没取到该运行环境的执行适配器',
9607
+ 'no-runtime-entry': '插件还没建立运行时入口',
9608
+ 'busy': '插件正在处理别的操作,稍后再试',
9609
+ 'browser-not-running': '浏览器未运行',
9610
+ 'provider-mismatch': '实际在跑的浏览器与所选运行环境不一致',
9611
+ 'cdp-protocol-error': '浏览器调试协议返回错误',
9612
+ 'cancelled': '本轮检查已被取消',
9613
+ 'executable-missing': '没找到该浏览器的可执行文件',
9614
+ 'identity-mismatch': '启动起来的浏览器不是所选的那一个',
9615
+ 'identity-unverified': '拿不到浏览器版本信息,无法确认身份',
9616
+ 'blocked-by-provider-mismatch': '上游运行环境不一致,这一段没执行',
9617
+ 'blocked-by-stage-1': '第 1 段失败,这一段没执行',
9618
+ 'blocked-by-stage-3': '第 3 段未通过,这一段没执行',
9619
+ 'stage-timeout': '这一段超时,没拿到结果',
9620
+ 'target-list-failed': '读取标签页列表失败',
9621
+ 'no-bound-target': '还没有绑定调试目标',
9622
+ 'readonly-eval-failed': '只读求值失败(在页面里跑只读脚本出错)',
9623
+ 'readonly-eval-unexpected': '只读求值的结果不是预期的 1',
9624
+ 'target-not-in-list': '目标已不在标签页列表里(可能已被关闭)',
9625
+ 'capability-partial': '部分能力不可用',
9626
+ // ② 就绪六轴(readiness / cross-environment / 运行环境自身)
9627
+ 'not-probed': '未探测',
9628
+ 'probe-failed': '探测失败',
9629
+ 'environment-disabled': '环境总开关已关闭',
9630
+ 'adapter-not-registered': '该环境还没接入适配器',
9631
+ 'no-capabilities': '没有上报能力清单',
9632
+ 'no-runtime': '宿主里还没有这个环境的运行时',
9633
+ 'no-target': '还没有目标',
9634
+ 'target-not-bound': '目标未绑定',
9635
+ 'target-unbound': '目标已解绑',
9636
+ 'target-gone': '调试目标已消失',
9637
+ 'cdp-not-attached': '还没连上浏览器调试端口',
9638
+ 'collector-missing': '缺少网络采集器',
9639
+ 'frame-source-missing': '没有可用的帧来源',
9640
+ 'input-unsupported': '该浏览器后端不支持模拟输入',
9641
+ 'provider-not-selected': '未选择该运行环境',
9642
+ 'provider-disabled': '该运行环境已停用',
9643
+ 'provider-driver-pending': '该运行环境的驱动待接入',
9644
+ 'provider-not-implemented': '该运行环境尚未实现',
9645
+ 'provider-dependency-not-verified': '还没验证该运行环境的依赖',
9646
+ 'provider-missing': '没有这个运行环境',
9647
+ 'capability-disabled': '该能力已关闭',
9648
+ 'capability-unknown': '未知的能力名',
9649
+ 'cold': '浏览器还没启动',
9650
+ 'host-idle-no-transport': '宿主空闲,没有可用的传输通道',
9651
+ 'session-missing': '调试会话已不存在',
9652
+ 'no-session': '没有调试会话',
9653
+ 'aborted': '已中止',
9654
+ // core/contracts.js 的 WEB_PROVIDER_REASONS(驱动拒绝 / 就绪 / 工具错误共用同一批拼写)
9655
+ 'driver-pending': '该运行环境的驱动待接入',
9656
+ 'unknown-provider': '没有这个运行环境',
9657
+ 'provider-unsupported': '该运行环境不受支持',
9658
+ 'override-invalid': '指定的浏览器路径不可用',
9659
+ // ③ 页面自身造的状态(不是 Host 的码)
9660
+ 'no-run': '还没有诊断结果',
9661
+ 'run-in-progress': '诊断进行中',
9662
+ };
9663
+ /**
9664
+ * 术语对照:这些词在**界面上一律用中文**,英文原样只留在 `data-uts-*`、NDJSON 和代码里
9665
+ * (agent 与排障用)。翻译按上下文取用户能看懂的词,不做字对字直译。
9666
+ */
9667
+ const DIAG_TERM_ZH = {
9668
+ 'run': '本轮',
9669
+ 'Host': '宿主',
9670
+ 'Failed to fetch': '连不上宿主(请求失败)',
9671
+ 'stage-start': '阶段开始',
9672
+ 'stage-end': '阶段结束',
9673
+ 'readonly-eval': '只读求值',
9674
+ 'target-list': '目标列表',
9675
+ 'snapshot': '快照',
9676
+ 'screenshot': '截图',
9677
+ 'computed-style': '计算样式',
9678
+ 'network-read': '网络记录',
9679
+ 'pass': '通过',
9680
+ 'fail': '失败',
9681
+ 'skip': '跳过',
9682
+ 'ok': '通过',
9683
+ 'PASS': '正常',
9684
+ };
9685
+ /** 术语 / 原因码 / 检查项 → 中文;都没有译文时原样返回英文。 */
9686
+ function diagTermLabel(term) {
9687
+ const key = String(term || '').trim();
9688
+ if (!key) return '';
9689
+ if (Object.prototype.hasOwnProperty.call(DIAG_TERM_ZH, key)) return DIAG_TERM_ZH[key];
9690
+ return diagReasonLabel(key);
9691
+ }
9692
+ /** 码 → 中文;动态族按前缀兜底,未收录返回空串(调用方原样显示英文)。 */
9693
+ function diagReasonZh(code) {
9694
+ const key = String(code || '').trim();
9695
+ if (!key) return '';
9696
+ if (Object.prototype.hasOwnProperty.call(DIAG_REASON_ZH, key)) return DIAG_REASON_ZH[key];
9697
+ if (/^tool-call-/u.test(key)) return '工具调用失败';
9698
+ if (/^stage-\d$/u.test(key)) return '该段没有给出原因';
9699
+ return '';
9700
+ }
9701
+ /**
9702
+ * 原因码在界面上**只写中文**;英文原码是给调用方 agent 的,留在 `data-uts-stage-reason` /
9703
+ * `data-uts-axis-reason-code` / `data-uts-event-reason` 与 NDJSON 的 `reason` 里。
9704
+ * 没有译文时原样显示英文码,绝不编造中文。
9705
+ */
9706
+ function diagReasonLabel(code) {
9707
+ const key = String(code || '').trim();
9708
+ if (!key) return '';
9709
+ return diagReasonZh(key) || key;
9710
+ }
9711
+ /* ---- Diagnostics overlay (frame-wide `shell.overlay`) ---------------------------------- */
9712
+ // The diagnostics page used to live inside the settings modal, which is far too narrow for the
9713
+ // five-node chain. It now opens in its own frame-wide overlay; the settings page only keeps the
9714
+ // entry buttons. State is module-level so both entry points live in the same bundle.
9715
+ const diagOverlayStore = {
9716
+ state: { open: false, groupId: '', providerId: '', trigger: null },
9717
+ listeners: new Set(),
9718
+ };
9719
+ function diagOverlayGet() { return diagOverlayStore.state; }
9720
+ function diagOverlaySet(next) {
9721
+ diagOverlayStore.state = Object.assign({}, diagOverlayStore.state, next);
9722
+ for (const listener of Array.from(diagOverlayStore.listeners)) {
9723
+ try { listener(); } catch { /* ignore */ }
9724
+ }
9725
+ }
9726
+ function diagOverlaySubscribe(listener) {
9727
+ diagOverlayStore.listeners.add(listener);
9728
+ return () => { diagOverlayStore.listeners.delete(listener); };
9729
+ }
9730
+ function openDiagnostics(groupId, providerId, trigger) {
9731
+ // Prefer the element that actually opened the overlay (the card button): a keyboard/secondary
9732
+ // activation may leave document.activeElement on <body>, and focus must come back to the card.
9733
+ const active = trigger || (typeof document !== 'undefined' ? document.activeElement : null);
9734
+ diagOverlaySet({ open: true, groupId: groupId || '', providerId: providerId || '', trigger: active || null });
9735
+ }
9736
+ function closeDiagnostics() {
9737
+ diagOverlaySet({ open: false });
9738
+ }
9739
+ function useDiagnosticsOverlay() {
9740
+ if (typeof React.useSyncExternalStore === 'function') {
9741
+ return React.useSyncExternalStore(diagOverlaySubscribe, diagOverlayGet, diagOverlayGet);
9742
+ }
9743
+ const [state, setState] = React.useState(diagOverlayGet);
9744
+ React.useEffect(() => diagOverlaySubscribe(() => setState(diagOverlayGet())), []);
9745
+ return state;
9746
+ }
9747
+
9748
+ const diagEmptyRun = (providerId) => ({
9749
+ runId: '', provider: providerId || '', mode: '', phase: 'idle',
9750
+ events: [], stages: {}, stagePhase: {}, axes: null, overall: null,
9751
+ actualCalls: [], sideEffects: [], configRevision: null,
9752
+ startedAt: null, finishedAt: null, error: '',
9753
+ });
9754
+
9755
+ function diagRequestId() {
9756
+ try {
9757
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
9758
+ } catch { /* fall through */ }
9759
+ return 'diag-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
9760
+ }
9761
+
9762
+ /**
9763
+ * Start one real diagnosis run and stream its NDJSON events.
9764
+ *
9765
+ * `observe` (default) never starts a browser; `start` is only used after the user explicitly
9766
+ * asks for it. The caller owns the AbortController: leaving the page aborts the stream (the
9767
+ * Host then stops the run — it does not keep executing behind a closed page).
9768
+ */
9769
+ async function startDiagnoseRun({ provider, mode, signal, onEvent }) {
9770
+ const response = await fetch(HOST_BASE + '/diagnose', {
9771
+ method: 'POST',
9772
+ credentials: 'same-origin',
9773
+ cache: 'no-store',
9774
+ // `/diagnose` is a scoped-GUI surface (an unscoped call is 401 by design). The settings page
9775
+ // works on the global/default scope, so it asks for `unassigned` explicitly and never lets
9776
+ // the Host guess with `recent`.
9777
+ headers: {
9778
+ 'content-type': 'application/json',
9779
+ 'X-UI-Testing-Scope-Version': SCOPE_VERSION,
9780
+ 'X-UI-Testing-Scope': 'unassigned',
9781
+ },
9782
+ body: JSON.stringify({ provider: provider || undefined, mode: mode || 'observe', requestId: diagRequestId() }),
9783
+ signal,
9784
+ });
9785
+ if (!response.ok) {
9786
+ const payload = await response.json().catch(() => ({}));
9787
+ const error = new Error(payload.error || ('诊断请求失败:' + response.status));
9788
+ error.code = payload.code || 'DIAGNOSE_REQUEST_FAILED';
9789
+ error.status = response.status;
9790
+ throw error;
9791
+ }
9792
+ if (!response.body || typeof response.body.getReader !== 'function') {
9793
+ throw Object.assign(new Error('诊断响应不是可读流'), { code: 'DIAGNOSE_STREAM_UNAVAILABLE' });
9794
+ }
9795
+ const reader = response.body.getReader();
9796
+ const decoder = new TextDecoder();
9797
+ let buffer = '';
9798
+ for (;;) {
9799
+ const { done, value } = await reader.read();
9800
+ if (done) break;
9801
+ buffer += decoder.decode(value, { stream: true });
9802
+ let index;
9803
+ while ((index = buffer.indexOf('\n')) >= 0) {
9804
+ const line = buffer.slice(0, index).trim();
9805
+ buffer = buffer.slice(index + 1);
9806
+ if (!line) continue;
9807
+ let event;
9808
+ try { event = JSON.parse(line); } catch { continue; }
9809
+ if (event && typeof event === 'object' && typeof onEvent === 'function') onEvent(event);
9810
+ }
9811
+ }
9812
+ return true;
9813
+ }
9814
+
9815
+ /** Fold one NDJSON event into the run state (pure; used by both live streaming and replay). */
9816
+ function diagApplyEvent(run, event) {
9817
+ const next = Object.assign({}, run);
9818
+ next.events = (run.events || []).concat([event]);
9819
+ next.stagePhase = Object.assign({}, run.stagePhase);
9820
+ next.stages = Object.assign({}, run.stages);
9821
+ if (event.type === 'run-start') {
9822
+ next.runId = event.runId || '';
9823
+ next.provider = event.provider || run.provider;
9824
+ next.mode = event.mode || run.mode;
9825
+ next.phase = 'running';
9826
+ next.startedAt = event.startedAt || null;
9827
+ next.configRevision = event.configRevision ?? null;
9828
+ next.error = '';
9829
+ } else if (event.type === 'stage-start') {
9830
+ next.stagePhase[event.stageId] = 'running';
9831
+ } else if (event.type === 'stage-end') {
9832
+ next.stagePhase[event.stageId] = 'finished';
9833
+ next.stages[event.stageId] = {
9834
+ stageId: event.stageId,
9835
+ phase: 'finished',
9836
+ status: DIAG_STATES.includes(event.status) ? event.status : 'UNKNOWN',
9837
+ reason: event.reason || null,
9838
+ blockedBy: event.blockedBy ?? null,
9839
+ startedAt: event.startedAt ?? null,
9840
+ finishedAt: event.finishedAt ?? null,
9841
+ durationMs: event.durationMs === undefined ? null : event.durationMs,
9842
+ checks: Array.isArray(event.checks) ? event.checks : [],
9843
+ evidenceRef: event.evidenceRef || null,
9844
+ seq: event.seq,
9845
+ };
9846
+ } else if (event.type === 'run-end') {
9847
+ next.phase = 'finished';
9848
+ next.finishedAt = event.finishedAt || null;
9849
+ next.axes = event.axes || null;
9850
+ next.overall = event.overall || null;
9851
+ next.actualCalls = Array.isArray(event.actualCalls) ? event.actualCalls : [];
9852
+ next.sideEffects = Array.isArray(event.sideEffects) ? event.sideEffects : [];
9853
+ next.stages = Object.assign({}, next.stages, event.stages || {});
9854
+ next.stagePhase = Object.assign({}, next.stagePhase);
9855
+ for (const stageId of DIAG_STAGE_IDS) next.stagePhase[stageId] = 'finished';
9856
+ }
9857
+ return next;
9858
+ }
9859
+
9860
+ /**
9861
+ * Node/edge model for one run.
9862
+ *
9863
+ * Node state = that stage's own status (UNKNOWN until the stage reports). An edge is decided by
9864
+ * **both** ends: a FAIL above stops the flow (FAIL), a skipped downstream segment is SKIPPED, and
9865
+ * anything still unknown keeps the edge UNKNOWN. Nothing here can turn green without a stage
9866
+ * that actually finished PASS.
9867
+ */
9868
+ /**
9869
+ * 诊断日志左侧的「操作名」:界面写中文,`data-uts-event-operation` 仍保留英文 id
9870
+ * (`run-start` / `stage-N` / `run-end`),agent 与排障不受影响。
9871
+ */
9872
+ function diagOperationLabel(operation) {
9873
+ const key = String(operation || '');
9874
+ if (key === 'run-start') return '本轮开始';
9875
+ if (key === 'run-end') return '本轮结束';
9876
+ const stage = /^stage-(\d+)$/u.exec(key);
9877
+ return stage ? '第 ' + stage[1] + ' 段' : key;
9878
+ }
9879
+
9880
+ /** 调试节点的完整信息(悬停提示):角色/名称、职责、状态、原因码、阻塞与耗时。 */
9881
+ function diagNodeTitle(node, statusText, stage, phase) {
9882
+ return [
9883
+ String(node.role || '') + ' · ' + String(node.name || ''),
9884
+ String(node.sub || ''),
9885
+ '状态:' + String(statusText || ''),
9886
+ stage && stage.reason ? '原因码:' + String(stage.reason) : '',
9887
+ stage && stage.blockedBy !== null && stage.blockedBy !== undefined ? '阻塞于阶段 ' + String(stage.blockedBy) : '',
9888
+ stage && stage.durationMs !== null && stage.durationMs !== undefined ? '耗时 ' + String(stage.durationMs) + 'ms' : '',
9889
+ phase === 'running' ? '本段正在检查' : '',
9890
+ ].filter(Boolean).join('\n');
9891
+ }
9892
+
9893
+ function diagRunModel(run, target) {
9894
+ const current = run || diagEmptyRun(target && target.id);
9895
+ const stages = current.stages || {};
9896
+ const stageOf = (id) => stages[id] || null;
9897
+ const stateOf = (id) => (stageOf(id) ? stageOf(id).status : 'UNKNOWN');
9898
+ const chain = (target && target.chain) || {};
9899
+ const interfaceName = chain.communication ? String(chain.communication) : 'CDP';
9900
+ const interfaceNameWithSuffix = /接口$/.test(interfaceName) ? interfaceName : interfaceName + '接口';
9901
+ const backendName = chain.backend ? String(chain.backend) : 'Chromium DevTools';
9902
+ const targetName = target && (target.name || target.id) ? String(target.name || target.id) : 'Chrome';
9903
+ const nodeSubs = {
9904
+ 1: '检查宿主集成、工具注册及调用通道',
9905
+ 2: '检查插件 Core、配置和调度能力',
9906
+ 3: '执行通信接口探测',
9907
+ 4: '检查执行后端及依赖可用性',
9908
+ 5: '查询目标列表,验证目标绑定与连接状态',
9909
+ };
9910
+ const nodes = [
9911
+ { index: 1, id: 'agent', name: 'DeepSeekHarness', role: 'AI代理', sub: nodeSubs[1], state: stateOf('1'), stage: stageOf('1') },
9912
+ { index: 2, id: 'controller', name: 'dsh-agent-team-ui-testing', role: '调试控制器', sub: nodeSubs[2], state: stateOf('2'), stage: stageOf('2') },
9913
+ { index: 3, id: 'interface', name: interfaceNameWithSuffix, role: '通信接口', sub: nodeSubs[3], state: stateOf('3'), stage: stageOf('3') },
9914
+ { index: 4, id: 'backend', name: backendName, role: '调试后端', sub: nodeSubs[4], state: stateOf('4'), stage: stageOf('4') },
9915
+ { index: 5, id: 'target', name: targetName + '页面', role: '目标运行环境', sub: nodeSubs[5], state: stateOf('5'), stage: stageOf('5') },
9916
+ ];
9917
+ const edgeState = (up, down) => {
9918
+ if (up === 'FAIL' || down === 'FAIL') return 'FAIL';
9919
+ if (down === 'SKIPPED') return 'SKIPPED';
9920
+ if (up === 'UNKNOWN' || down === 'UNKNOWN') return 'UNKNOWN';
9921
+ if (up === 'WARN' || down === 'WARN') return 'WARN';
9922
+ return 'PASS';
9923
+ };
9924
+ // A dot only runs on a segment whose downstream stage really finished PASS in this run.
9925
+ const liveEdge = (downIndex) => {
9926
+ const stage = stageOf(String(downIndex));
9927
+ return Boolean(stage && stage.status === 'PASS' && (current.stagePhase || {})[String(downIndex)] === 'finished');
9928
+ };
9929
+ const edges = [
9930
+ { index: 1, state: edgeState(nodes[0].state, nodes[1].state), label: '指令', live: liveEdge(2) },
9931
+ { index: 2, state: edgeState(nodes[1].state, nodes[2].state), label: '标准化命令', live: liveEdge(3) },
9932
+ { index: 3, state: edgeState(nodes[2].state, nodes[3].state), label: interfaceName + ' 通信', live: liveEdge(4) },
9933
+ { index: 4, state: edgeState(nodes[3].state, nodes[4].state), label: liveEdge(5) ? interfaceName + ' 会话正常' : '等待目标绑定', live: liveEdge(5) },
9934
+ ];
9935
+ for (const edge of edges) if (edge.state !== 'PASS') edge.live = false;
9936
+ const duties = nodes.map((node) => ({ node: node.id, name: node.name, text: DIAG_DUTY_TEXT[node.id] || '' }));
9937
+ const overall = current.overall
9938
+ ? { status: current.overall.status || 'UNKNOWN', reason: current.overall.reason || '' }
9939
+ : { status: current.phase === 'running' ? 'UNKNOWN' : 'UNKNOWN', reason: current.phase === 'running' ? 'run-in-progress' : 'no-run' };
9940
+ return { run: current, nodes, edges, duties, overall, stageOf };
9941
+ }
9942
+
9943
+ /** Event rows: one per stage-end plus the run markers, newest first, bounded to 8. */
9944
+ function diagRunRows(run) {
9945
+ const current = run || diagEmptyRun('');
9946
+ const rows = [];
9947
+ for (const event of current.events || []) {
9948
+ if (event.type === 'run-start') {
9949
+ rows.push({ kind: 'run-start', seq: event.seq, at: event.startedAt || null, operation: 'run-start', runId: event.runId || '', status: 'UNKNOWN', reason: null, blockedBy: null, durationMs: null, evidenceRef: null });
9950
+ } else if (event.type === 'stage-end') {
9951
+ rows.push({
9952
+ kind: 'stage', stageId: event.stageId, seq: event.seq, at: event.finishedAt || event.startedAt || null,
9953
+ operation: 'stage-' + event.stageId, runId: event.runId || '',
9954
+ status: DIAG_STATES.includes(event.status) ? event.status : 'UNKNOWN',
9955
+ reason: event.reason || null, blockedBy: event.blockedBy ?? null,
9956
+ durationMs: event.durationMs === undefined ? null : event.durationMs,
9957
+ evidenceRef: event.evidenceRef || null,
9958
+ });
9959
+ } else if (event.type === 'run-end') {
9960
+ rows.push({ kind: 'run-end', seq: event.seq, at: event.finishedAt || null, operation: 'run-end', runId: event.runId || '', status: (event.overall && event.overall.status) || 'UNKNOWN', reason: (event.overall && event.overall.reason) || null, blockedBy: null, durationMs: event.durationMs === undefined ? null : event.durationMs, evidenceRef: null });
9961
+ }
9962
+ }
9963
+ return rows
9964
+ .sort((left, right) => (right.seq || 0) - (left.seq || 0))
9965
+ .slice(0, DIAG_RUN_ROWS);
9966
+ }
9967
+
9968
+ /**
9969
+ * Normalize the Host readiness payload (`{checkedAt, environments:[...]}` or a map keyed by
9970
+ * environment id). Each axis may be a bare status or `{status, reason}`; missing axes stay
9971
+ * absent so the page can say "not probed" instead of inventing a value.
9972
+ */
9973
+ /**
9974
+ * Read the six axes out of one Host readiness entry (environment-level or provider-level).
9975
+ * The Host returns a nested `axes` object (M4 contract); older/defensive shapes may still be
9976
+ * flat, so the nested value is read first and the flat field is the fallback.
9977
+ */
9978
+ function axesOfHostEntry(entry) {
9979
+ const axes = {};
9980
+ if (!entry || typeof entry !== 'object') return axes;
9981
+ for (const axis of AXIS_ORDER) {
9982
+ const rawValue = entry.axes && entry.axes[axis] !== undefined
9983
+ ? entry.axes[axis]
9984
+ : entry[axis];
9985
+ if (rawValue === undefined || rawValue === null) continue;
9986
+ if (typeof rawValue === 'object') {
9987
+ const status = rawValue.status === undefined || rawValue.status === null ? '' : String(rawValue.status);
9988
+ if (!status) continue;
9989
+ // A13: keep the *evidence* the Host measured, not just a status word. Dropping
9990
+ // target/evidence/expiresAt here made "which capability is missing / which target was
9991
+ // probed / when" unanswerable in the page even though the Host had measured it.
9992
+ axes[axis] = {
9993
+ status,
9994
+ reason: rawValue.reason ? String(rawValue.reason) : '',
9995
+ checkedAt: Number.isFinite(rawValue.checkedAt) ? rawValue.checkedAt : null,
9996
+ expiresAt: Number.isFinite(rawValue.expiresAt) ? rawValue.expiresAt : null,
9997
+ targetId: rawValue.target && typeof rawValue.target === 'object' && rawValue.target.targetId
9998
+ ? String(rawValue.target.targetId)
9999
+ : '',
10000
+ evidenceKind: rawValue.evidence && typeof rawValue.evidence === 'object' && rawValue.evidence.kind
10001
+ ? String(rawValue.evidence.kind)
10002
+ : '',
10003
+ checks: rawValue.evidence && typeof rawValue.evidence === 'object'
10004
+ && rawValue.evidence.checks && typeof rawValue.evidence.checks === 'object'
10005
+ ? rawValue.evidence.checks
10006
+ : null,
10007
+ };
10008
+ } else {
10009
+ axes[axis] = { status: String(rawValue), reason: '', checkedAt: null, expiresAt: null, targetId: '', evidenceKind: '', checks: null };
10010
+ }
10011
+ }
10012
+ return axes;
10013
+ }
10014
+
10015
+ function normalizeReadiness(readiness) {
10016
+ const map = {};
10017
+ if (!readiness || typeof readiness !== 'object') return map;
10018
+ const raw = readiness.environments;
10019
+ let list = null;
10020
+ if (Array.isArray(raw)) list = raw;
10021
+ else if (raw && typeof raw === 'object') list = Object.keys(raw).map((id) => Object.assign({ environment: id }, raw[id]));
10022
+ if (!list) return map;
10023
+ for (const entry of list) {
10024
+ if (!entry || typeof entry !== 'object') continue;
10025
+ const id = String(entry.environment || entry.id || entry.env || '');
10026
+ if (!id) continue;
10027
+ const axes = axesOfHostEntry(entry);
10028
+ // Slice ② also reports per-provider axes (`readiness.environments[web].providers.<id>.axes`).
10029
+ // Each card reads its own provider's result instead of sharing Chrome's connection/target/
10030
+ // capability axes — a disabled provider is therefore not rendered as "ready".
10031
+ const providers = {};
10032
+ const rawProviders = entry.providers && typeof entry.providers === 'object' ? entry.providers : null;
10033
+ if (rawProviders) {
10034
+ for (const [providerId, providerEntry] of Object.entries(rawProviders)) {
10035
+ if (!providerEntry || typeof providerEntry !== 'object') continue;
10036
+ providers[providerId] = {
10037
+ axes: axesOfHostEntry(providerEntry),
10038
+ checkedAt: String(providerEntry.checkedAt || entry.checkedAt || readiness.checkedAt || ''),
10039
+ reason: providerEntry.reason ? String(providerEntry.reason) : '',
10040
+ };
10041
+ }
10042
+ }
10043
+ map[id] = {
10044
+ axes,
10045
+ providers,
10046
+ checkedAt: String(entry.checkedAt || readiness.checkedAt || ''),
10047
+ reason: entry.reason ? String(entry.reason) : '',
10048
+ };
10049
+ }
10050
+ return map;
10051
+ }
10052
+
10053
+ /** Static engineering support, used only when the Host sends no support axis. */
10054
+ function staticSupportOf(groupId) {
10055
+ const group = TARGET_ENV_GROUPS.find((item) => item.id === groupId);
10056
+ if (!group) return 'planned';
10057
+ if (group.targets.some((target) => target.state === 'supported')) return 'supported';
10058
+ if (group.targets.some((target) => target.state === 'unsupported')) return 'unsupported';
10059
+ return 'planned';
10060
+ }
10061
+
10062
+ /** `true`/`false` from the Host config, or `null` when the config carries no environment map. */
10063
+ function enabledFromConfig(config, envId) {
10064
+ const envs = config && config.global && config.global.environments;
10065
+ if (!envs || typeof envs !== 'object') return null;
10066
+ const entry = envs[envId];
10067
+ return Boolean(entry && entry.enabled === true);
10068
+ }
10069
+
10070
+ /**
10071
+ * Per-provider switch values straight from the Host config.
10072
+ *
10073
+ * Returns `null` when the config carries no provider map at all (old Host / unreadable config),
10074
+ * so the page can say "unknown" instead of pretending the switch is off. The map is the single
10075
+ * source of truth: closing the target-environment master switch never rewrites these values.
10076
+ */
10077
+ function providersFromConfig(config, envId) {
10078
+ const envs = config && config.global && config.global.environments;
10079
+ const entry = envs && typeof envs === 'object' ? envs[envId] : null;
10080
+ const providers = entry && typeof entry.providers === 'object' && entry.providers ? entry.providers : null;
10081
+ if (!providers) return null;
10082
+ const out = {};
10083
+ for (const [providerId, value] of Object.entries(providers)) {
10084
+ out[providerId] = Boolean(value && value.enabled === true);
10085
+ }
10086
+ return out;
10087
+ }
10088
+
10089
+ /**
10090
+ * The execution provider the Host resolved for this scope. Read from `resolved.provider`, never
10091
+ * inferred from the checkboxes: the switch says "may use", this says "will use".
10092
+ */
10093
+ function currentProviderFromState(state) {
10094
+ const resolved = state && state.resolved && typeof state.resolved === 'object' ? state.resolved : null;
10095
+ return resolved && typeof resolved.provider === 'string' ? resolved.provider : '';
10096
+ }
10097
+
10098
+ /**
10099
+ * Six axes for one provider card, read from `readiness.environments[env].providers.<id>.axes`.
10100
+ * When the Host reports nothing for that provider, every axis stays `not-probed` with a reason —
10101
+ * a card never borrows another provider's connection/target/capability evidence.
10102
+ */
10103
+ function providerAxes(state, envId, providerId) {
10104
+ const entry = state && state.readiness ? state.readiness[envId] : null;
10105
+ const providerEntry = entry && entry.providers ? entry.providers[providerId] : null;
10106
+ const hostAxes = providerEntry && providerEntry.axes ? providerEntry.axes : null;
10107
+ const configLoaded = Boolean(state && state.revision !== null && !state.error);
10108
+ return AXIS_ORDER.map((axis) => {
10109
+ if (axis === 'enabled') {
10110
+ if (configLoaded) {
10111
+ const providers = providersFromConfig(state.config, envId);
10112
+ if (providers === null) {
10113
+ return { axis, status: 'not-probed', reason: '宿主配置未包含运行环境清单', source: 'unavailable' };
10114
+ }
10115
+ return providers[providerId] === true
10116
+ ? { axis, status: 'true', reason: '宿主配置已启用该运行环境', source: 'host' }
10117
+ : { axis, status: 'false', reason: '宿主配置未启用该运行环境', source: 'host' };
10118
+ }
10119
+ return { axis, status: 'not-probed', reason: '宿主配置不可用,未读取到该运行环境的启用状态', source: 'unavailable' };
10120
+ }
10121
+ const host = hostAxes ? hostAxes[axis] : null;
10122
+ if (host && host.status) {
10123
+ return {
10124
+ axis,
10125
+ status: host.status,
10126
+ reason: host.reason || '来自宿主就绪结果',
10127
+ source: 'host',
10128
+ checkedAt: host.checkedAt ?? null,
10129
+ expiresAt: host.expiresAt ?? null,
10130
+ targetId: host.targetId || '',
10131
+ evidenceKind: host.evidenceKind || '',
10132
+ checks: host.checks || null,
10133
+ };
10134
+ }
10135
+ return { axis, status: 'not-probed', reason: '宿主未提供该运行环境的探测结果', source: 'unavailable' };
10136
+ });
10137
+ }
10138
+
10139
+ /**
10140
+ * Six independent axes for one environment. Nothing is merged into a single "valid" flag:
10141
+ * unsupported, disabled and not-probed stay distinguishable, and a missing Host answer is
10142
+ * reported as not-probed with a reason rather than a ready state.
10143
+ */
10144
+ function environmentAxes(state, groupId) {
10145
+ const entry = state && state.readiness ? state.readiness[groupId] : null;
10146
+ const hostAxes = entry && entry.axes ? entry.axes : {};
10147
+ const configLoaded = Boolean(state && state.revision !== null && !state.error);
10148
+ return AXIS_ORDER.map((axis) => {
10149
+ // `enabled` is the user's saved intent: the Host config revision wins over a readiness
10150
+ // snapshot, so a successful PUT is visible immediately (the write response has no readiness).
10151
+ if (axis === 'enabled') {
10152
+ if (configLoaded) {
10153
+ const enabled = enabledFromConfig(state.config, groupId);
10154
+ if (enabled === null) {
10155
+ return { axis, status: 'not-probed', reason: '宿主配置未包含环境策略', source: 'unavailable' };
10156
+ }
10157
+ return {
10158
+ axis,
10159
+ status: enabled ? 'true' : 'false',
10160
+ reason: enabled ? '宿主配置已启用该环境' : '宿主配置未启用该环境',
10161
+ source: 'host',
10162
+ };
10163
+ }
10164
+ if (state && state.error) {
10165
+ return { axis, status: 'not-probed', reason: '宿主配置读取失败,无法确认用户启用状态', source: 'unavailable' };
10166
+ }
10167
+ const hostEnabled = hostAxes.enabled;
10168
+ if (hostEnabled && hostEnabled.status) {
10169
+ return { axis, status: hostEnabled.status, reason: hostEnabled.reason || '来自宿主就绪结果', source: 'host' };
10170
+ }
10171
+ return { axis, status: 'not-probed', reason: '宿主配置不可用,未读取到用户启用状态', source: 'unavailable' };
10172
+ }
10173
+ const host = hostAxes[axis];
10174
+ if (host && host.status) {
10175
+ // Carry the measured detail through to the DOM: which target was probed, when it was
10176
+ // checked, and (for the capability axis) which per-capability checks passed (A13).
10177
+ return {
10178
+ axis,
10179
+ status: host.status,
10180
+ reason: host.reason || '来自宿主就绪结果',
10181
+ source: 'host',
10182
+ checkedAt: host.checkedAt ?? null,
10183
+ expiresAt: host.expiresAt ?? null,
10184
+ targetId: host.targetId || '',
10185
+ evidenceKind: host.evidenceKind || '',
10186
+ checks: host.checks || null,
10187
+ };
10188
+ }
10189
+ if (axis === 'support') {
10190
+ return { axis, status: staticSupportOf(groupId), reason: '静态候选清单(宿主未提供 support 探测)', source: 'static' };
10191
+ }
10192
+ return { axis, status: 'not-probed', reason: '宿主未提供该轴的探测结果', source: 'unavailable' };
10193
+ });
10194
+ }
10195
+
10196
+ const EMPTY_HOST_CONFIG = Object.freeze({
10197
+ loading: true, error: '', revision: null, config: null, resolved: null, readiness: {}, checkedAt: '',
10198
+ // Local observation time of the last *successful* read (ms). `checkedAt` is the Host's probe
10199
+ // time; this one answers "how long ago did this page last hear from the Host".
10200
+ lastReadAt: 0,
10201
+ });
10202
+
10203
+ /** Adopt one Host config payload; a payload without a numeric revision is a failed read. */
10204
+ function mergeHostConfig(previous, payload) {
10205
+ const revision = payload && typeof payload.revision === 'number' ? payload.revision : null;
10206
+ if (revision === null) {
10207
+ return Object.assign({}, previous, {
10208
+ loading: false,
10209
+ error: '宿主未提供配置接口(响应缺少 revision)',
10210
+ });
10211
+ }
10212
+ const readiness = payload.readiness !== undefined ? normalizeReadiness(payload.readiness) : previous.readiness;
10213
+ const checkedAt = payload.readiness && payload.readiness.checkedAt
10214
+ ? String(payload.readiness.checkedAt)
10215
+ : previous.checkedAt;
10216
+ return {
10217
+ loading: false,
10218
+ error: '',
10219
+ revision,
10220
+ config: payload.config !== undefined ? payload.config : previous.config,
10221
+ resolved: payload.resolved !== undefined ? payload.resolved : previous.resolved,
10222
+ readiness,
10223
+ checkedAt,
10224
+ lastReadAt: previous.lastReadAt || 0,
10225
+ };
10226
+ }
10227
+
10228
+ /**
10229
+ * 宿主配置类错误的英文原因 → 中文。宿主返回的是机器可读英文(`environment android is
10230
+ * planned and cannot be selected in v1` 等),直接上屏对用户没有信息量;这里按已知模式翻译,
10231
+ * 未识别的原样保留(不猜),原文仍留在悬停提示里。
10232
+ */
10233
+ function configErrorText(message) {
10234
+ const raw = String(message || '').trim();
10235
+ if (!raw) return '';
10236
+ let match = /^environment\s+(\S+)\s+is planned and cannot be selected in v1/iu.exec(raw);
10237
+ if (match) return 'v1 还不支持启用 ' + match[1] + ':该环境仍是规划中';
10238
+ match = /^environment\s+(\S+)\s+is test-only/iu.exec(raw);
10239
+ if (match) return match[1] + ' 仅供测试使用';
10240
+ match = /^unknown environment:\s*(\S+)/iu.exec(raw);
10241
+ if (match) return '没有这个目标环境:' + match[1];
10242
+ match = /^unknown provider:\s*(\S+)/iu.exec(raw);
10243
+ if (match) return '没有这个运行环境:' + match[1];
10244
+ return raw;
10245
+ }
10246
+
10247
+ function hostConfigError(error) {
10248
+ const code = String((error && error.code) || '');
10249
+ if (code === 'CONFIG_TIMEOUT') return '读取宿主配置超时(' + HOST_CONFIG_TIMEOUT_MS + 'ms 内未完成)';
10250
+ if (code === 'CONFIG_CONFLICT') return '配置已被其他窗口修改(CONFIG_CONFLICT)';
10251
+ if (code === 'CONFIG_UNAVAILABLE' || (error && error.status === 404)) {
10252
+ return '宿主未提供配置接口,无法读取或保存配置';
10253
+ }
10254
+ const detail = error && error.message ? String(error.message) : '';
10255
+ // `Failed to fetch` 是浏览器原话,对用户没有信息量:说清「连不上谁」。
10256
+ if (/failed to fetch/iu.test(detail)) return '连不上宿主:请求没送到 DSH(可能已经停止运行)';
10257
+ return '读取宿主配置失败' + (detail ? ':' + detail : '');
10258
+ }
10259
+
10260
+ /**
10261
+ * Read one Host config view and keep the Host revision as the only truth. `scope` is `null` on
10262
+ * the settings page (legacy/default, no scope headers) and the panel's own snapshot in the
10263
+ * panel. A failed read leaves the caller usable but exposes `error`; nothing reports ready.
10264
+ *
10265
+ * `options` is optional and every field defaults to the original single-read behaviour, so the
10266
+ * existing call sites (`useHostConfig(scope, 'session')`, `useHostConfig(null, 'global')`) are
10267
+ * unchanged:
10268
+ * - `pollMs` > 0 keeps re-reading on that interval while the component is mounted. A tick is
10269
+ * skipped when the document is hidden and when a request is already in flight, so
10270
+ * at most one GET is ever outstanding and the page is silent in the background.
10271
+ * - `initial` seeds the very first state (the list page already holds a Host read), which is
10272
+ * what keeps a sub-page from flashing "not probed" for one frame.
10273
+ * - `silent` never toggles `loading` (no spinner/blank flicker on a poll); a failed poll keeps
10274
+ * the previous payload and only sets `error`, which the page renders as `stale`.
10275
+ */
10276
+ /**
10277
+ * 同页面配置变更总线:设置页写完配置后通知调试视图重读,避免两个面板靠轮询或等刷新才同步。
10278
+ * 订阅方只做「重新读一次」,不做本地合并——宿主 revision 仍是唯一真值。
10279
+ */
10280
+ const hostConfigBus = { listeners: new Set() };
10281
+ function publishHostConfigChange() {
10282
+ for (const listener of Array.from(hostConfigBus.listeners)) {
10283
+ try { listener(); } catch { /* 一个订阅者出错不影响其它订阅者 */ }
10284
+ }
10285
+ }
10286
+ function useHostConfigChange(listener) {
10287
+ const ref = React.useRef(listener);
10288
+ ref.current = listener;
10289
+ React.useEffect(() => {
10290
+ const handler = () => { if (typeof ref.current === 'function') ref.current(); };
10291
+ hostConfigBus.listeners.add(handler);
10292
+ return () => { hostConfigBus.listeners.delete(handler); };
10293
+ }, []);
10294
+ }
10295
+
10296
+ function useHostConfig(scope, view, options) {
10297
+ const opts = options && typeof options === 'object' ? options : {};
10298
+ const pollMs = Number.isFinite(opts.pollMs) && opts.pollMs > 0 ? Number(opts.pollMs) : 0;
10299
+ const silent = opts.silent === true;
10300
+ const initial = opts.initial && typeof opts.initial === 'object' ? opts.initial : null;
10301
+ const [state, setState] = React.useState(() => (
10302
+ initial ? Object.assign({}, initial, { loading: false }) : EMPTY_HOST_CONFIG
10303
+ ));
10304
+ const stateRef = React.useRef(state);
10305
+ stateRef.current = state;
10306
+ const epochRef = React.useRef(0);
10307
+ // The in-flight marker is a request **identity**, not a boolean: a superseded request that
10308
+ // finishes late must not clear the marker of the request that replaced it (M5).
10309
+ const inFlightRef = React.useRef(0);
10310
+ const requestIdRef = React.useRef(0);
10311
+ // How many snapshots *this instance* successfully adopted. The diagnostics page uses it to
10312
+ // tell "a real fresh Host read" from the caller-supplied `initial` snapshot (M3).
10313
+ const [reads, setReads] = React.useState(0);
10314
+ const load = React.useCallback((signal) => {
10315
+ const epoch = ++epochRef.current;
10316
+ const requestId = ++requestIdRef.current;
10317
+ inFlightRef.current = requestId;
10318
+ const settle = () => { if (inFlightRef.current === requestId) inFlightRef.current = 0; };
10319
+ if (!silent) setState((current) => Object.assign({}, current, { loading: true, error: '' }));
10320
+ getHostConfig(scope, view, signal).then((payload) => {
10321
+ settle();
10322
+ if (epoch !== epochRef.current) return;
10323
+ const merged = mergeHostConfig(stateRef.current, payload);
10324
+ // `lastReadAt` is the "last complete, valid config adopted" time: a response that only
10325
+ // carries an error (e.g. no numeric revision) must not move it forward (M4).
10326
+ if (!merged.error && merged.revision !== null) merged.lastReadAt = Date.now();
10327
+ setState(merged);
10328
+ if (!merged.error && merged.revision !== null) setReads((value) => value + 1);
10329
+ }).catch((error) => {
10330
+ settle();
10331
+ if (epoch !== epochRef.current) return;
10332
+ if (error && error.name === 'AbortError') return;
10333
+ setState(Object.assign({}, stateRef.current, { loading: false, error: hostConfigError(error) }));
10334
+ });
10335
+ }, [scope, view, silent]);
10336
+ React.useEffect(() => {
10337
+ let controller = null;
10338
+ if (typeof AbortController === 'function') controller = new AbortController();
10339
+ const visible = () => !(typeof document !== 'undefined' && document.visibilityState === 'hidden');
10340
+ // The page promises "hidden means no request", so even the first read waits for visibility.
10341
+ if (!pollMs || visible()) load(controller ? controller.signal : undefined);
10342
+ let timer = null;
10343
+ if (pollMs) {
10344
+ timer = setInterval(() => {
10345
+ // Background tabs keep their data but stop asking the Host for more.
10346
+ if (!visible()) return;
10347
+ if (inFlightRef.current !== 0) return;
10348
+ load(controller ? controller.signal : undefined);
10349
+ }, pollMs);
10350
+ }
10351
+ return () => {
10352
+ if (timer !== null) clearInterval(timer);
10353
+ epochRef.current += 1;
10354
+ // Abort the current request (its own deadline timer is cleared by the request itself).
10355
+ if (controller) controller.abort();
10356
+ };
10357
+ }, [load, pollMs]);
10358
+ const reload = React.useCallback(() => load(undefined), [load]);
10359
+ const adopt = React.useCallback((payload) => {
10360
+ const next = mergeHostConfig(stateRef.current, payload);
10361
+ setState(next);
10362
+ return next;
10363
+ }, []);
10364
+ return { state, reload, adopt, reads };
10365
+ }
10366
+
10367
+ /** A CAS conflict carries the latest revision: adopt it, or re-read when it does not. */
10368
+ function adoptHostConflict(hostConfig, error) {
10369
+ const payload = error && error.payload;
10370
+ if (payload && typeof payload.revision === 'number') {
10371
+ hostConfig.adopt(payload);
10372
+ // Without the full config the write response cannot settle the page: re-read it.
10373
+ if (payload.config === undefined) hostConfig.reload();
10374
+ return;
10375
+ }
10376
+ hostConfig.reload();
10377
+ }
10378
+
10379
+ function readExpandedGroups() {
10380
+ try {
10381
+ const raw = window.localStorage.getItem(SETTINGS_EXPANDED_KEY);
10382
+ if (!raw) return {};
10383
+ const parsed = JSON.parse(raw);
10384
+ if (!parsed || typeof parsed !== 'object') return {};
10385
+ const next = {};
10386
+ for (const key of Object.keys(parsed)) if (parsed[key] === true) next[key] = true;
10387
+ return next;
10388
+ } catch (error) {
10389
+ return {};
10390
+ }
10391
+ }
10392
+
10393
+ function writeExpandedGroups(map) {
10394
+ try {
10395
+ window.localStorage.setItem(SETTINGS_EXPANDED_KEY, JSON.stringify(map));
10396
+ } catch (error) {
10397
+ /* Storage may be unavailable (private mode / policy); in-memory state still works. */
10398
+ }
10399
+ }
10400
+
10401
+ /**
10402
+ * 开关组件:受控时把变更交给调用方(例如提交 Host),非受控时保留旧的本地预览行为。
10403
+ * 未支持/未接入或 Host 配置不可用时保持禁用(灰态),避免让人以为已经可启用。
10404
+ */
10405
+ function TargetEnvSwitch(props) {
10406
+ const [on, setOn] = React.useState(props.checked === true);
10407
+ const controlled = typeof props.onChange === 'function';
10408
+ const title = props.title || (props.disabled
10409
+ ? '当前不可启用'
10410
+ : controlled ? '仅筛选当前页面显示的目标' : '前端预览:启用状态暂不保存');
10411
+ return h('label', {
10412
+ className: 'uts-switch',
10413
+ title,
10414
+ 'data-uts-switch-busy': props.busy ? 'true' : 'false',
10415
+ },
10416
+ h('input', {
10417
+ type: 'checkbox',
10418
+ role: 'switch',
10419
+ className: 'uts-switch-input',
10420
+ 'aria-label': props.label,
10421
+ checked: controlled ? props.checked === true : on,
10422
+ disabled: props.disabled,
10423
+ onChange: (event) => controlled ? props.onChange(event.target.checked) : setOn(event.target.checked),
10424
+ }),
10425
+ h('span', { className: 'uts-switch-track', 'aria-hidden': true }, h('span', { className: 'uts-switch-thumb' })));
10426
+ }
10427
+
10428
+ /**
10429
+ * 第五节点目标卡片:名称 + 启用分开关,页脚左侧「可用」(已实现)/「未支持 · 未接入」(灰字占位)、右侧「诊断」。
10430
+ *
10431
+ * 不再显示的东西:当前使用 / 未选中标签(开关已经反映启用状态)、已实现 / 未支持 / 未接入
10432
+ * 标签(改用「可用」一枚)、接口 / 执行端(③④,在诊断页)、依赖状态(诊断页六轴之一)。
10433
+ * 不显示任何写死版本:启用状态来自 Host 配置,目标状态来自 Host 就绪结果。
10434
+ */
10435
+ function TargetEnvCard(props) {
10436
+ const target = props.target || {};
10437
+ const configReady = props.configReady === true;
10438
+ const busy = props.busy === true;
10439
+ const providerSupported = target.state === 'supported';
10440
+ // 未实现卡片左槽位的灰色占位:工程未支持 vs 还没接入,措辞不混用。
10441
+ const stateText = target.state === 'unsupported' ? '未支持' : '未接入';
10442
+ // 总闸(目标环境)与分开关(运行环境)是两层:分开关只在总闸打开时才可操作,
10443
+ // 但关闭总闸不会改写各 provider 已保存的值。
10444
+ const masterEnabled = props.masterEnabled === true;
10445
+ const providerCurrent = props.providerCurrent === true;
10446
+ const switchDisabled = !providerSupported || !configReady || busy || !masterEnabled;
10447
+ const switchTitle = !providerSupported
10448
+ ? '当前运行环境未实现,不能启用'
10449
+ : !configReady
10450
+ ? (props.configError || '宿主配置不可用,无法提交启用状态')
10451
+ : busy ? '正在提交到宿主…'
10452
+ : !masterEnabled ? '目标环境总开关已关闭:已保存的开关值保留,重新打开后恢复'
10453
+ : '提交到宿主(按宿主版本号保存)';
10454
+ return h('div', {
10455
+ className: 'uts-item',
10456
+ 'data-uts-target': target.id,
10457
+ 'data-uts-provider': target.id,
10458
+ 'data-uts-provider-state': providerSupported ? 'supported' : 'disabled',
10459
+ 'data-uts-provider-current': providerCurrent ? 'true' : 'false',
10460
+ 'data-uts-state': target.state,
10461
+ 'data-uts-node': 'target',
10462
+ },
10463
+ h('div', { className: 'uts-card' },
10464
+ h('div', { className: 'uts-card-head' },
10465
+ h('span', { className: 'uts-card-title' },
10466
+ h('span', { className: 'uts-card-name' }, target.name)),
10467
+ // 开关挪到卡片右上角:卡片里不再有“用户启用:”这类说明文字;
10468
+ // 「当前使用 / 未选中」也不再单独标一枚标签——开关本身就反映了启用状态。
10469
+ // (provider 仍是 Host 解析出来的单选值,只是不再在卡片上重复写一遍。)
10470
+ h('span', { className: 'uts-card-switch', 'data-uts-switch-provider': target.id },
10471
+ h(TargetEnvSwitch, {
10472
+ label: '是否启用运行环境:' + target.name,
10473
+ checked: props.providerEnabled === true,
10474
+ disabled: switchDisabled,
10475
+ busy,
10476
+ title: switchTitle,
10477
+ onChange: (next) => {
10478
+ if (typeof props.onToggleProvider === 'function') props.onToggleProvider(target.id, next);
10479
+ },
10480
+ }))),
10481
+ // 说明统一压到两行(超出省略,完整文字在 title 里):卡片不再因为说明多一行就高低不齐。
10482
+ target.note ? h('div', { className: 'uts-card-note', title: target.note }, target.note) : null,
10483
+ // 卡片页脚:左边是状态槽(已实现一枚绿色「可用」;未实现一行灰字「未支持 / 未接入」),
10484
+ // 右边固定「诊断」按钮,永远贴右下角。原来的 已实现/未支持/未接入 标签样式(药丸)不再用,
10485
+ // 未实现只作灰字占位,避免它和「可用」抢同一层的视觉重量。
10486
+ h('div', { className: 'uts-card-foot' },
10487
+ providerSupported
10488
+ ? h('span', {
10489
+ className: 'uts-tag uts-available-tag',
10490
+ 'data-uts-tag': 'supported',
10491
+ 'data-uts-available': 'true',
10492
+ }, '可用')
10493
+ // 未实现也占住左槽位(灰色小字,非标签):不然整行只剩右侧按钮,左轻右重。
10494
+ : h('span', {
10495
+ className: 'uts-card-state',
10496
+ 'data-uts-card-state': target.state || 'planned',
10497
+ }, stateText),
10498
+ h('div', { className: 'uts-card-actions' },
10499
+ h('button', {
10500
+ type: 'button',
10501
+ className: 'uts-diagnose-btn',
10502
+ 'data-uts-diagnose': target.id,
10503
+ title: '单独查看 ' + target.name + ' 的六轴诊断',
10504
+ onClick: (event) => {
10505
+ if (typeof props.onDiagnose === 'function') props.onDiagnose(target.id, event && event.currentTarget);
10506
+ },
10507
+ }, '诊断')))));
10508
+ }
10509
+
10510
+ /**
10511
+ * 一个环境分组:默认收齐(用户 2026-11-07 要求),点分组标题展开/收起该环境的候选卡片。
10512
+ * 六轴就绪条始终可见(不受折叠影响);标题是 h4 里的 button(`aria-expanded`/`aria-controls`),
10513
+ * 卡片区域按需渲染为 region。
10514
+ */
10515
+ function TargetEnvGroup(props) {
10516
+ const group = props.group;
10517
+ const expanded = props.expanded === true;
10518
+ const axes = props.axes || [];
10519
+ const regionId = 'uts-group-' + group.id;
10520
+ return h('div', {
10521
+ className: 'uts-group',
10522
+ 'data-uts-group': group.id,
10523
+ 'data-uts-expanded': expanded ? 'true' : 'false',
10524
+ },
10525
+ h('h4', { className: 'uts-group-heading' },
10526
+ h('button', {
10527
+ type: 'button',
10528
+ className: 'uts-group-toggle',
10529
+ 'aria-expanded': expanded,
10530
+ 'aria-controls': regionId,
10531
+ 'data-uts-group-toggle': group.id,
10532
+ onClick: () => props.onToggle(group.id),
10533
+ },
10534
+ h('span', { className: 'uts-group-title' }, group.title),
10535
+ h('span', { className: 'uts-group-count' }, group.targets.length + ' 个目标'),
10536
+ h('span', { className: 'uts-group-chevron', 'aria-hidden': true }, h(IconChevron, null))),
10537
+ // 目标环境总开关:与上面的展开/收起是两个不同控件(不同节点、不同属性),
10538
+ // 关闭它只停用整块执行,不抹掉各运行环境已保存的开关值。
10539
+ h('div', {
10540
+ className: 'uts-group-env',
10541
+ 'data-uts-env-switch': group.id,
10542
+ 'data-uts-env-enabled': props.envEnabled === true ? 'true' : 'false',
10543
+ },
10544
+ h(TargetEnvSwitch, {
10545
+ label: '目标环境总开关:' + group.title,
10546
+ checked: props.envEnabled === true,
10547
+ disabled: props.configReady !== true || props.busy === true,
10548
+ busy: props.busy === true,
10549
+ title: props.configReady !== true
10550
+ ? (props.configError || '宿主配置不可用,无法提交启用状态')
10551
+ : props.busy === true
10552
+ ? '正在提交到宿主…'
10553
+ : '关闭后停用整块执行;各运行环境已保存的开关值保留',
10554
+ onChange: (next) => {
10555
+ if (typeof props.onToggleEnv === 'function') props.onToggleEnv(next);
10556
+ },
10557
+ }))),
10558
+ expanded
10559
+ ? h('div', { className: 'uts-cards', id: regionId, role: 'region', 'aria-label': group.title },
10560
+ group.targets.length
10561
+ ? group.targets.map((target) => {
10562
+ // 卡片只读 Host 配置(启用开关)与静态实现状态;六轴与 ③④ 在它自己的诊断页,
10563
+ // 因此这里不再把 axes 传下去——同一事实只在一处渲染。
10564
+ return h(TargetEnvCard, {
10565
+ key: target.id,
10566
+ target,
10567
+ masterEnabled: props.envEnabled === true,
10568
+ providerEnabled: props.providers ? props.providers[target.id] === true : false,
10569
+ providerCurrent: props.currentProvider === target.id,
10570
+ configReady: props.configReady,
10571
+ configError: props.configError,
10572
+ busy: props.busy,
10573
+ onDiagnose: props.onDiagnose,
10574
+ onToggleProvider: props.onToggleProvider,
10575
+ });
10576
+ })
10577
+ : h('p', { className: 'uts-empty' }, '暂未接入'))
10578
+ : null);
10579
+ }
10580
+
10581
+ /**
10582
+ * 二级页:单个运行环境(provider)的诊断。
10583
+ *
10584
+ * 打开就**真实跑一次 Host 诊断**(`POST /plugins/ui-testing/diagnose`,NDJSON):节点/边只用
10585
+ * Host 的五个 stage 结果说话(PASS/WARN/FAIL/SKIPPED/UNKNOWN),动画只由 stage-start /
10586
+ * stage-end 推动,六轴与事件流都来自本 run 的 run-end。没有 run 就没有绿色:这里不拿
10587
+ * readiness 快照冒充「已检查」。
10588
+ */
10589
+ function TargetEnvDiagnostics(props) {
10590
+ const group = props.group || { id: '', title: '', targets: [] };
10591
+ const target = props.target || {};
10592
+ const runKey = diagRunKey(group.id, target.id);
10593
+ const [run, setRun] = React.useState(() => DIAG_RUNS.get(runKey) || diagEmptyRun(target.id));
10594
+ // readiness 只用于「就绪快照」时间戳与(没有 run 时的)六轴兜底,run 期间不轮询。
10595
+ const hostConfig = useHostConfig(null, 'global', {
10596
+ pollMs: run.phase === 'running' ? 0 : DIAG_POLL_MS,
10597
+ initial: props.initialState,
10598
+ silent: true,
10599
+ });
10600
+ const state = hostConfig.state;
10601
+ const lastGoodRef = React.useRef(
10602
+ props.initialState && props.initialState.revision !== null && !props.initialState.error
10603
+ ? props.initialState
10604
+ : null,
10605
+ );
10606
+ if (!state.error && state.revision !== null) lastGoodRef.current = state;
10607
+ const stateError = String(state.error || '');
10608
+ const live = stateError ? (lastGoodRef.current || state) : state;
10609
+ const abortRef = React.useRef(null);
10610
+ const aliveRef = React.useRef(true);
10611
+
10612
+ const applyRun = React.useCallback((next) => {
10613
+ if (!aliveRef.current) return;
10614
+ setRun(next);
10615
+ DIAG_RUNS.set(runKey, next);
10616
+ }, [runKey]);
10617
+
10618
+ const startRun = React.useCallback((mode) => {
10619
+ try {
10620
+ startRunUnsafe(mode);
10621
+ } catch (error) {
10622
+ const message = '诊断 run 启动失败:' + String((error && error.message) || error);
10623
+ try { console.error('[ui-testing] ' + message, error); } catch { /* ignore */ }
10624
+ applyRun(Object.assign(diagEmptyRun(target.id), { phase: 'finished', error: message }));
10625
+ }
10626
+ }, [applyRun, target.id]);
10627
+ // The run keeps its own AbortController: the readiness polling controller must never abort it.
10628
+ const startRunUnsafe = React.useCallback((mode) => {
10629
+ if (abortRef.current) { try { abortRef.current.abort(); } catch { /* ignore */ } }
10630
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
10631
+ abortRef.current = controller;
10632
+ let current = Object.assign(diagEmptyRun(target.id), { phase: 'running', provider: target.id, mode });
10633
+ applyRun(current);
10634
+ startDiagnoseRun({
10635
+ provider: target.id,
10636
+ mode,
10637
+ signal: controller ? controller.signal : undefined,
10638
+ onEvent: (event) => {
10639
+ current = diagApplyEvent(current, event);
10640
+ applyRun(Object.assign({}, current));
10641
+ },
10642
+ }).then(() => {
10643
+ if (!aliveRef.current) return;
10644
+ if (current.phase !== 'finished') {
10645
+ current = Object.assign({}, current, { phase: 'finished' });
10646
+ applyRun(current);
10647
+ }
10648
+ }).catch((error) => {
10649
+ if (!aliveRef.current) return;
10650
+ if (error && error.name === 'AbortError') return;
10651
+ current = Object.assign({}, current, { phase: 'finished', error: hostConfigError(error) });
10652
+ applyRun(current);
10653
+ });
10654
+ }, [applyRun, target.id]);
10655
+
10656
+ React.useEffect(() => {
10657
+ aliveRef.current = true;
10658
+ // 每次进入诊断页(= 每次点卡片「诊断」)都发起一次新的 observe run:用户点诊断就是要
10659
+ // 真实走一遍工具调用,缓存只用于「离开/失败时把上次结果回显出来」,绝不阻止新 run。
10660
+ // 旧动画不会被重放:动画只由本次 run 的 stage 事件打开。
10661
+ const stored = DIAG_RUNS.get(runKey);
10662
+ if (stored && stored.phase === 'finished' && stored.error) setRun(stored);
10663
+ startRun('observe');
10664
+ return () => {
10665
+ aliveRef.current = false;
10666
+ if (abortRef.current) { try { abortRef.current.abort(); } catch { /* ignore */ } }
10667
+ };
10668
+ }, [runKey, startRun]);
10669
+
10670
+ const model = diagRunModel(run, target);
10671
+ const rows = diagRunRows(run);
10672
+ const overall = model.overall;
10673
+ // 本轮还没有结论时(没跑过 / 没跑出结果)统一说 `待诊断` / `诊断未完成`,不用五态里的词。
10674
+ const runless = diagRunlessText(run);
10675
+ const activeStage = DIAG_STAGE_IDS.find((stageId) => (run.stagePhase || {})[stageId] === 'running') || '';
10676
+ // 胶囊文案(可见)与标题共用同一份文本:胶囊窄的时候悬停仍能看到全句。
10677
+ const overallText = run.phase === 'running'
10678
+ ? '诊断进行中 · 第 ' + (activeStage || '?') + ' 阶段'
10679
+ : (runless || (diagStateLabel(overall.status) + (overall.reason ? ' · ' + diagReasonLabel(overall.reason) : '')));
10680
+ // ③ WARN browser-not-running 时才提供「启动浏览器后继续诊断」,用户点之前绝不启动。
10681
+ const coldBrowser = (run.stages || {})['3'] && run.stages['3'].status === 'WARN'
10682
+ && String(run.stages['3'].reason || '').includes('browser-not-running');
10683
+ // A run is in flight until its stream closes: the action is disabled meanwhile.
10684
+ const runBusy = run.phase === 'running';
10685
+ // "Already connected" = this run really reached the CDP stage (③ PASS).
10686
+ const hasConnection = Boolean((run.stages || {})['3'] && run.stages['3'].status === 'PASS');
10687
+ const runId = run.runId || '';
10688
+ const axesSource = run.axes
10689
+ ? DIAG_AXIS_ORDER.map((axisKey) => {
10690
+ const value = run.axes[axisKey];
10691
+ const status = typeof value === 'string' ? value : (value && value.status) || 'not-probed';
10692
+ // `run-end.axes.checks` is keyed by *check id* (`readonly-eval`, `target-list`), never by
10693
+ // axis: it is only evidence for the capability axis (which is what those checks measured).
10694
+ const rawChecks = run.axes.checks && typeof run.axes.checks === 'object' && !Array.isArray(run.axes.checks)
10695
+ ? run.axes.checks : null;
10696
+ return {
10697
+ axis: axisKey,
10698
+ status,
10699
+ reason: '来自本轮诊断',
10700
+ source: 'run',
10701
+ runId,
10702
+ checkedAt: run.finishedAt || null,
10703
+ checks: axisKey === 'capability' && rawChecks
10704
+ ? Object.fromEntries(Object.entries(rawChecks).map(([name, value]) => [name, { status: typeof value === 'string' ? value : String((value && value.status) || 'unknown') }]))
10705
+ : null,
10706
+ targetId: '',
10707
+ };
10708
+ })
10709
+ : providerAxes(live, group.id || '', target.id || '');
10710
+ // 没有真正读过 readiness 时不要编一个时间(06:00 那种 epoch 文案会被当成真实读数)。
10711
+ const updatedIso = live.lastReadAt ? diagIsoAt(live.lastReadAt) : '';
10712
+ const runTimeIso = diagIsoAt(run.finishedAt || run.startedAt || 0);
10713
+
10714
+ return h('section', {
10715
+ className: 'uts-diagnostics-body',
10716
+ 'data-uts-diagnostics': target.id || '',
10717
+ 'data-uts-diagnostics-group': group.id || '',
10718
+ 'data-uts-diagnostics-state': target.state || '',
10719
+ 'data-uts-diag-poll': String(DIAG_POLL_MS),
10720
+ 'data-uts-diag-stale': stateError ? 'true' : 'false',
10721
+ 'data-uts-diag-updated': updatedIso,
10722
+ 'data-uts-run-id': runId,
10723
+ 'data-uts-run-phase': run.phase || 'idle',
10724
+ 'data-uts-run-mode': run.mode || '',
10725
+ 'data-uts-run-source': run.axes ? 'run' : 'readiness',
10726
+ },
10727
+ h('p', { className: 'uts-page-desc', 'data-uts-diag-scope': 'run' },
10728
+ '打开本页会真实执行一次诊断检查(不会启动浏览器),结果按五个阶段逐条返回;'
10729
+ + '需要浏览器时可用下方动作显式启动,离开本页会中止本次检查。'
10730
+ + '本页只反映当前作用域这一次诊断的结果,不代表其它会话中的调试目标不可用。'),
10731
+ h('div', { className: 'uts-diag-summary' },
10732
+ h('span', {
10733
+ className: 'uts-diag-overall',
10734
+ 'data-uts-diag-overall': 'true',
10735
+ 'data-uts-overall': overall.status,
10736
+ 'data-uts-overall-reason': overall.reason || '',
10737
+ // 证据不删只收:本次 run / 就绪快照 / 副作用 / 实际调用 从可见文本挪到胶囊的悬停提示与
10738
+ // 机器可读属性上,界面保持干净但读得到同样的值。
10739
+ 'data-uts-run-mode': run.mode || '',
10740
+ 'data-uts-diag-updated-clock': updatedIso ? diagClock(updatedIso) : '',
10741
+ 'data-uts-diag-side-effects': (run.sideEffects || []).map((effect) => effect.kind).join(','),
10742
+ 'data-uts-diag-actual-calls': (run.actualCalls || []).map((call) => call.tool).join(','),
10743
+ title: [
10744
+ '状态:' + overallText,
10745
+ run.phase === 'running'
10746
+ ? '正在读取宿主事件流'
10747
+ : (run.mode ? '本轮:' + run.mode + (runTimeIso ? ' · ' + diagClock(runTimeIso) : '') : '尚无诊断记录'),
10748
+ updatedIso ? '就绪快照 ' + diagClock(updatedIso) : '',
10749
+ (run.sideEffects || []).length ? '副作用 ' + (run.sideEffects || []).map((effect) => effect.kind).join(' · ') : '',
10750
+ (run.actualCalls || []).length ? '实际调用 ' + (run.actualCalls || []).map((call) => call.tool).join(' · ') : '',
10751
+ ].filter(Boolean).join(';'),
10752
+ role: 'status',
10753
+ },
10754
+ h('i', { className: 'uts-diag-overall-dot', 'aria-hidden': true }),
10755
+ h('span', { 'data-uts-overall-text': 'true' }, overallText)),
10756
+ run.error
10757
+ ? h('p', { className: 'uts-diag-stale', 'data-uts-diag-error': 'true' }, run.error)
10758
+ : null,
10759
+ stateError
10760
+ ? h('p', { className: 'uts-diag-stale', 'data-uts-diag-error': 'true' },
10761
+ '就绪快照读取失败(不影响本轮结果):' + stateError)
10762
+ : null,
10763
+ // 常驻动作:打开页面本身只做 observe(不启动浏览器),"开始诊断"才手动触发一轮 start。
10764
+ // 浏览器没在跑时由这一轮自己启动;已连接时只重新检查,不重启。run 进行中禁用,避免并发两轮。
10765
+ h('div', { className: 'uts-diag-action', 'data-uts-diag-action': 'start' },
10766
+ h('button', {
10767
+ type: 'button',
10768
+ className: 'uts-link-btn',
10769
+ 'data-uts-diag-start': 'true',
10770
+ 'data-uts-diag-run': 'start',
10771
+ 'data-uts-action-disabled': runBusy ? 'true' : 'false',
10772
+ 'data-uts-diag-action-note': runBusy ? 'busy' : coldBrowser || !hasConnection ? 'cold' : 'warm',
10773
+ // 说明文字不再占版面:搬到按钮的悬停提示里(三种分支同一套文案)。
10774
+ title: runBusy
10775
+ ? '本次诊断正在进行中,完成后再开始下一轮。'
10776
+ : (coldBrowser || !hasConnection
10777
+ ? '会启动所选浏览器(' + (target.name || target.id) + ')并创建受管调试页;不会接管其它会话的标签。'
10778
+ : '会重新执行五个阶段的检查,不会重启浏览器。'),
10779
+ disabled: runBusy,
10780
+ onClick: () => { if (!runBusy) startRun('start'); },
10781
+ }, '开始诊断'))),
10782
+
10783
+ /* ① 实时调试拓扑:五节点 + 四条边(横排),全部取本次 run 的 stage 结果 */
10784
+ h('section', { className: 'uts-diag-panel', 'data-uts-diag-section': 'topology' },
10785
+ h('div', { className: 'uts-diag-panel-head' },
10786
+ h('h3', { className: 'uts-diag-panel-title' }, '调试流程')),
10787
+ h('div', { className: 'uts-diag-chain-scroll' },
10788
+ h('div', {
10789
+ className: 'uts-diag-chain',
10790
+ 'data-uts-topology': 'true',
10791
+ 'data-uts-topology-state': overall.status,
10792
+ 'data-uts-run-phase': run.phase || 'idle',
10793
+ }, (() => {
10794
+ const cells = [h('div', {
10795
+ key: 'rail',
10796
+ className: 'uts-diag-rail',
10797
+ 'data-uts-diag-rail': 'true',
10798
+ 'aria-hidden': true,
10799
+ }, h('i', { className: 'uts-diag-rail-dot', 'data-uts-diag-rail-dot': 'true' }))];
10800
+ model.nodes.forEach((node, index) => {
10801
+ const stage = node.stage || null;
10802
+ const phase = (run.stagePhase || {})[String(node.index)] || 'idle';
10803
+ // 没有 run 结果时说「待诊断 / 诊断未完成」,有结果才用五态 + 原因码(中文在前、
10804
+ // 英文码在括号里,agent 仍可从 data-uts-stage-reason 读到原码)。
10805
+ const nodeStatusText = phase === 'running'
10806
+ ? '检查中…'
10807
+ : (runless || (diagStateLabel(node.state) + (stage && stage.reason ? ' · ' + diagReasonLabel(stage.reason) : '')));
10808
+ cells.push(h('div', {
10809
+ key: 'node-' + node.index,
10810
+ className: 'uts-diag-node',
10811
+ 'data-uts-node': String(node.index),
10812
+ 'data-uts-node-id': node.id,
10813
+ 'data-uts-node-state': node.state,
10814
+ 'data-uts-node-note': node.sub,
10815
+ 'data-uts-stage': String(node.index),
10816
+ 'data-uts-stage-status': node.state,
10817
+ 'data-uts-stage-phase': phase,
10818
+ 'data-uts-stage-reason': (stage && stage.reason) || '',
10819
+ 'data-uts-stage-blocked-by': stage && stage.blockedBy !== null && stage.blockedBy !== undefined ? String(stage.blockedBy) : '',
10820
+ 'data-uts-stage-duration': stage && stage.durationMs !== null && stage.durationMs !== undefined ? String(stage.durationMs) : '',
10821
+ // 卡片里 name / 职责 / 状态都可能被截断:悬停给出完整四行,不留信息黑洞。
10822
+ title: diagNodeTitle(node, nodeStatusText, stage, phase),
10823
+ },
10824
+ h('div', { className: 'uts-diag-node-top' },
10825
+ h('span', { className: 'uts-diag-node-index' }, '步骤 ' + node.index),
10826
+ h('span', { className: 'uts-diag-node-glyph', 'aria-hidden': true }, diagNodeIcon(node.id))),
10827
+ h('div', { className: 'uts-diag-node-role' }, node.role),
10828
+ h('div', { className: 'uts-diag-node-name', 'data-uts-node-name': node.name, title: node.name }, node.name),
10829
+ h('div', { className: 'uts-diag-node-sub', 'data-uts-node-sub': node.sub, title: node.sub }, node.sub),
10830
+ h('div', { className: 'uts-diag-node-status' },
10831
+ h('i', { className: 'uts-diag-dot', 'aria-hidden': true }),
10832
+ h('span', { 'data-uts-node-status': node.state }, nodeStatusText))));
10833
+ const edge = model.edges[index];
10834
+ if (edge) {
10835
+ cells.push(h('div', {
10836
+ key: 'edge-' + edge.index,
10837
+ className: 'uts-diag-edge',
10838
+ 'data-uts-edge': String(edge.index),
10839
+ 'data-uts-edge-state': edge.state,
10840
+ 'data-uts-edge-label': edge.label,
10841
+ 'data-uts-edge-live': edge.live ? 'true' : 'false',
10842
+ },
10843
+ h('span', { className: 'uts-diag-edge-line', 'aria-hidden': true },
10844
+ h('i', { className: 'uts-diag-edge-dot', 'data-uts-edge-dot': 'true' })),
10845
+ edge.state === 'FAIL'
10846
+ ? h('span', { className: 'uts-diag-edge-break', 'aria-hidden': true }, '×')
10847
+ : null,
10848
+ h('span', { className: 'uts-diag-edge-label' }, edge.label)));
10849
+ }
10850
+ });
10851
+ return cells;
10852
+ })())),
10853
+ h('p', { className: 'uts-diag-flow' },
10854
+ '动画只跟随宿主事件回填:这一段开始检查时对应步骤脉冲,检查结束后该段静止;'
10855
+ + '只有本轮结论为「正常」的段才继续走球;结论是「异常」的,小球停在那个步骤上并写明原因。')),
10856
+
10857
+ /* ② 六轴:来自 run-end.axes(原词表),没有 run 时才回退 readiness */
10858
+ h('section', { className: 'uts-diag-panel', 'data-uts-diag-section': 'axes' },
10859
+ h('div', { className: 'uts-diag-panel-head' },
10860
+ h('h3', { className: 'uts-diag-panel-title' }, '各模块诊断状态')),
10861
+ h('div', { className: 'uts-diag-axes', 'data-uts-axes': target.id || '' },
10862
+ axesSource.map((axis, order) => h('div', {
10863
+ key: axis.axis,
10864
+ className: 'uts-diag-axis',
10865
+ 'data-uts-axis': axis.axis,
10866
+ 'data-uts-status': axis.status,
10867
+ 'data-uts-axis-order': String(order),
10868
+ 'data-uts-axis-provider': target.id || '',
10869
+ 'data-uts-axis-source': axis.source || 'readiness',
10870
+ 'data-uts-axis-run-id': axis.runId || '',
10871
+ 'data-uts-axis-target': axis.targetId || '',
10872
+ 'data-uts-axis-checked-at': axis.checkedAt ? String(axis.checkedAt) : '',
10873
+ title: axisDetail(axis),
10874
+ },
10875
+ h('span', { className: 'uts-diag-axis-icon', 'aria-hidden': true }, diagAxisIcon(axis.axis)),
10876
+ h('div', { className: 'uts-diag-axis-body' },
10877
+ h('div', { className: 'uts-diag-axis-head' },
10878
+ h('span', { className: 'uts-diag-axis-name' }, AXIS_LABEL[axis.axis] || axis.axis),
10879
+ h('span', { className: 'uts-diag-axis-value' }, axisText(axis.axis, axis.status))),
10880
+ h('code', {
10881
+ className: 'uts-diag-axis-reason',
10882
+ 'data-uts-axis-reason': axis.axis,
10883
+ // 英文码单独留一个属性:界面上是「中文(code)」,agent 读这个属性拿纯码。
10884
+ 'data-uts-axis-reason-code': axis.reason || '',
10885
+ }, diagReasonLabel(axis.reason) || '无附加原因'),
10886
+ (() => {
10887
+ // 六条探测逐行列出;完整列表(含英文 id)在悬停提示里。
10888
+ // 没有检查项的卡片也渲染这块(空内容),保证六张卡片都按三行占位。
10889
+ const rawChecks = axis.checks && typeof axis.checks === 'object' ? axis.checks : {};
10890
+ const rows = Object.entries(rawChecks)
10891
+ .map(([name, check]) => ({ name, status: String(check && check.status ? check.status : check) }));
10892
+ // 界面上的检查项名与状态都写中文(只读求值:跳过),英文 id 留给属性与悬停提示。
10893
+ const zh = rows.map((row) => diagTermLabel(row.name) + ':' + diagTermLabel(row.status));
10894
+ // 一行一项、最多三行;还有几条用 `+n` 写在第三行末尾(完整列表见悬停提示)。
10895
+ const shown = zh.slice(0, DIAG_CHECK_LINES);
10896
+ const hidden = zh.length - shown.length;
10897
+ const truncated = hidden > 0;
10898
+ const pieces = shown.map((textValue, index) => {
10899
+ const last = index === shown.length - 1;
10900
+ if (!(truncated && last)) {
10901
+ return h('span', { key: 'item-' + index, className: 'uts-diag-axis-checks-item' }, textValue);
10902
+ }
10903
+ // 「+n」单独一个 span:更小字号 + 更淡的颜色,读起来是计数标签,不是又一条描述。
10904
+ return h('span', { key: 'item-' + index, className: 'uts-diag-axis-checks-item' },
10905
+ textValue,
10906
+ h('span', {
10907
+ className: 'uts-diag-axis-checks-more',
10908
+ 'data-uts-axis-checks-more': String(hidden),
10909
+ }, '+' + hidden));
10910
+ });
10911
+ return h('code', {
10912
+ className: 'uts-diag-axis-checks',
10913
+ 'data-uts-axis-checks': axis.axis,
10914
+ 'data-uts-axis-checks-total': String(rows.length),
10915
+ // agent 读纯英文对:readonly-eval: skip · target-list: skip · …
10916
+ 'data-uts-axis-checks-en': rows.map((row) => row.name + ': ' + row.status).join(' · '),
10917
+ // 被省略的条数:界面上写成「+n」,外部断言可读这个值
10918
+ 'data-uts-axis-checks-truncated': truncated ? String(hidden) : '',
10919
+ title: rows.map((row) => diagTermLabel(row.name) + '(' + row.name + '):' + diagTermLabel(row.status)).join(' · '),
10920
+ }, pieces);
10921
+ })()))))),
10922
+
10923
+ /* ③ 诊断日志 + ④ 调试节点职责 */
10924
+ h('div', { className: 'uts-diag-bottom' },
10925
+ h('section', { className: 'uts-diag-panel', 'data-uts-diag-section': 'events' },
10926
+ h('div', { className: 'uts-diag-panel-head' },
10927
+ h('h3', { className: 'uts-diag-panel-title' }, '诊断日志')),
10928
+ h('div', {
10929
+ className: 'uts-diag-events',
10930
+ 'data-uts-events': 'true',
10931
+ 'data-uts-event-count': String((run.events || []).length),
10932
+ },
10933
+ rows.length
10934
+ ? rows.map((row) => {
10935
+ // Host 的时间戳是 Date.now() 的数字,先统一转 ISO,否则 `diagClock` 解析失败 →
10936
+ // 每行都显示 `--:--:--`。DOM 属性也写 ISO,方便外部核对。
10937
+ const atIso = diagIsoAt(row.at);
10938
+ const observedIso = diagIsoAt(run.finishedAt);
10939
+ // run-start 不是检查结果,别拿「未知」冒充状态:文案「开始」+ INFO 中性灰点。
10940
+ const statusKey = row.kind === 'run-start' ? 'INFO' : row.status;
10941
+ const statusText = row.kind === 'run-start' ? '开始' : diagStateLabel(row.status);
10942
+ return h('div', {
10943
+ key: 'event-' + (row.seq || 0),
10944
+ className: 'uts-diag-event',
10945
+ 'data-uts-event': 'true',
10946
+ 'data-uts-event-kind': row.kind,
10947
+ 'data-uts-event-axis': row.operation,
10948
+ 'data-uts-event-time': atIso,
10949
+ 'data-uts-event-time-source': 'host',
10950
+ 'data-uts-event-host-checked-at': atIso,
10951
+ 'data-uts-event-observed-at': observedIso,
10952
+ 'data-uts-event-from': '',
10953
+ 'data-uts-event-to': statusKey,
10954
+ 'data-uts-event-status': statusKey,
10955
+ 'data-uts-event-run-id': row.runId || '',
10956
+ 'data-uts-event-seq': String(row.seq || ''),
10957
+ 'data-uts-event-operation': row.operation,
10958
+ 'data-uts-event-evidence-ref': row.evidenceRef || '',
10959
+ // 英文码单独留属性(agent 读纯码),可见文本走中英对照。
10960
+ 'data-uts-event-reason': row.reason || '',
10961
+ title: diagOperationLabel(row.operation) + ' · ' + statusText + (row.reason ? ' · ' + diagReasonLabel(row.reason) : ''),
10962
+ },
10963
+ // SKIPPED 阶段从未执行,Host 就没有 finishedAt:显示「—」而不是假时间或占位符。
10964
+ h('time', { className: 'uts-diag-event-time' }, atIso ? diagClock(atIso) : '—'),
10965
+ h('i', { className: 'uts-diag-event-pin', 'data-uts-event-status': statusKey, 'aria-hidden': true }),
10966
+ h('div', { className: 'uts-diag-event-text' },
10967
+ diagOperationLabel(row.operation) + ' · ' + statusText
10968
+ + (row.reason ? ' · ' + diagReasonLabel(row.reason) : '')
10969
+ // 上游阻塞:写中文,英文键 blockedBy 只留在属性与 NDJSON 里。
10970
+ + (row.blockedBy ? ' · 阻塞于第 ' + row.blockedBy + ' 段' : '')
10971
+ + ' · ' + (row.durationMs === null || row.durationMs === undefined ? '—' : Math.round(row.durationMs * 10) / 10 + 'ms')));
10972
+ })
10973
+ : h('p', { className: 'uts-diag-empty', 'data-uts-events-empty': 'true' }, '尚未取得宿主诊断结果'))),
10974
+ h('section', { className: 'uts-diag-panel', 'data-uts-diag-section': 'duties' },
10975
+ h('div', { className: 'uts-diag-panel-head' },
10976
+ h('h3', { className: 'uts-diag-panel-title' }, '步骤职责')),
10977
+ h('div', { className: 'uts-diag-duties', 'data-uts-duties': 'true' },
10978
+ model.duties.map((duty) => h('div', {
10979
+ key: 'duty-' + duty.node,
10980
+ className: 'uts-diag-duty',
10981
+ 'data-uts-duty': 'true',
10982
+ 'data-uts-duty-node': duty.node,
10983
+ },
10984
+ h('span', { className: 'uts-diag-duty-arrow', 'aria-hidden': true }, '↗'),
10985
+ h('span', { className: 'uts-diag-duty-text' },
10986
+ h('strong', null, duty.name),
10987
+ ' ' + duty.text)))))));
10988
+ }
10989
+
10990
+ /**
10991
+ * 诊断弹层(DSH 的 frame-wide `shell.overlay` 插槽)。
10992
+ *
10993
+ * 设置弹层太窄,横排五节点链路放不下;这里用一个独立的全宽覆盖层。插槽层本身是
10994
+ * click-through,所以条目自己开 `pointer-events`。关闭方式:✕ 按钮、Esc、点遮罩;
10995
+ * 关闭即中止进行中的 run(内容组件卸载时 abort)。
10996
+ */
10997
+ function DiagnosticsOverlay() {
10998
+ const overlay = useDiagnosticsOverlay();
10999
+ const open = overlay.open === true;
11000
+ const closeRef = React.useRef(null);
11001
+ React.useEffect(() => {
11002
+ if (!open) return undefined;
11003
+ const trigger = overlay.trigger;
11004
+ const previousOverflow = document.body.style.overflow;
11005
+ document.body.style.overflow = 'hidden';
11006
+ const onKeyDown = (event) => {
11007
+ if (event.key === 'Escape' || event.key === 'Esc') {
11008
+ event.stopPropagation();
11009
+ event.preventDefault();
11010
+ closeDiagnostics();
11011
+ }
11012
+ };
11013
+ document.addEventListener('keydown', onKeyDown, true);
11014
+ const focusTimer = setTimeout(() => {
11015
+ if (closeRef.current && typeof closeRef.current.focus === 'function') closeRef.current.focus();
11016
+ }, 0);
11017
+ return () => {
11018
+ document.removeEventListener('keydown', onKeyDown, true);
11019
+ document.body.style.overflow = previousOverflow;
11020
+ clearTimeout(focusTimer);
11021
+ if (trigger && trigger.isConnected && typeof trigger.focus === 'function') trigger.focus();
11022
+ };
11023
+ }, [open, overlay.trigger]);
11024
+ if (!open) return null;
11025
+ const group = TARGET_ENV_GROUPS.find((item) => item.id === overlay.groupId)
11026
+ || { id: overlay.groupId, title: overlay.groupId, targets: [] };
11027
+ const target = (group.targets || []).find((item) => item.id === overlay.providerId)
11028
+ || { id: overlay.providerId, name: overlay.providerId, state: 'planned' };
11029
+ const titleText = (target.name || target.id) + ' 诊断';
11030
+ const tag = target.state === 'supported' ? '已实现' : target.state === 'unsupported' ? '未支持' : '未接入';
11031
+ const tree = h('div', {
11032
+ className: 'uts-diag-modal-overlay',
11033
+ 'data-uts-diag-modal-overlay': 'true',
11034
+ onClick: (event) => {
11035
+ if (event.target === event.currentTarget) closeDiagnostics();
11036
+ },
11037
+ },
11038
+ h('div', {
11039
+ className: 'uts-diag-modal-panel',
11040
+ 'data-uts-diag-modal': target.id || '',
11041
+ role: 'dialog',
11042
+ 'aria-modal': 'true',
11043
+ 'aria-label': titleText,
11044
+ onClick: (event) => event.stopPropagation(),
11045
+ },
11046
+ h('header', { className: 'uts-diag-modal-head' },
11047
+ h('div', { className: 'uts-diag-modal-title' },
11048
+ h('h2', { className: 'uts-page-title' }, titleText),
11049
+ h('span', { className: 'uts-tag', 'data-uts-tag': target.state || 'planned' }, tag)),
11050
+ h('button', {
11051
+ type: 'button',
11052
+ className: 'uts-link-btn uts-diag-modal-close',
11053
+ 'data-uts-diagnostics-back': 'true',
11054
+ ref: closeRef,
11055
+ onClick: () => closeDiagnostics(),
11056
+ }, '关闭')),
11057
+ h('div', { className: 'uts-diag-modal-body' },
11058
+ h(TargetEnvDiagnostics, { group, target }))));
11059
+ // `shell.overlay` items can end up inside a stacking context that sits below the settings
11060
+ // modal; portalling to <body> is the only placement that is reliably on top.
11061
+ if (reactDom && typeof reactDom.createPortal === 'function' && typeof document !== 'undefined') {
11062
+ return reactDom.createPortal(tree, document.body);
11063
+ }
11064
+ return tree;
11065
+ }
11066
+
11067
+ /**
11068
+ * 设置页正文(DSH 设置弹层的独立分区)。标题/描述在这里渲染,因为 settings.section
11069
+ * 只提供导航标签,不提供页面标题;下面才是「目标环境配置」分组。
11070
+ * 配置真值来自 Host:读取失败显示 `[data-uts-config-error]`,启用开关按 revision 提交。
11071
+ */
11072
+ function TargetEnvSettingsPage() {
11073
+ const hostConfig = useHostConfig(null, 'global');
11074
+ const state = hostConfig.state;
11075
+ const [hideUnimplemented, setHideUnimplemented] = React.useState(false);
11076
+ const [expanded, setExpanded] = React.useState(readExpandedGroups);
11077
+ const [submittingEnv, setSubmittingEnv] = React.useState('');
11078
+ const [submittingProvider, setSubmittingProvider] = React.useState('');
11079
+ const [notice, setNotice] = React.useState(null);
11080
+ // 二级诊断页:记录 (环境, 运行环境),为空时显示环境分组列表。
11081
+ const [diagnosing, setDiagnosing] = React.useState(null);
11082
+ const aliveRef = React.useRef(true);
11083
+ React.useEffect(() => () => { aliveRef.current = false; }, []);
11084
+ const configReady = state.revision !== null && !state.error;
11085
+ const groups = hideUnimplemented
11086
+ ? TARGET_ENV_GROUPS.map((group) => ({ ...group, targets: group.targets.filter((target) => target.state === 'supported') }))
11087
+ .filter((group) => group.targets.length > 0)
11088
+ : TARGET_ENV_GROUPS;
11089
+ function toggleGroup(id) {
11090
+ setExpanded((current) => {
11091
+ const next = Object.assign({}, current);
11092
+ if (next[id]) delete next[id];
11093
+ else next[id] = true;
11094
+ writeExpandedGroups(next);
11095
+ return next;
11096
+ });
11097
+ }
11098
+ function toggleEnvironment(group, next) {
11099
+ if (!configReady || submittingEnv) return;
11100
+ setSubmittingEnv(group.id);
11101
+ setNotice(null);
11102
+ putHostConfig({ environments: { [group.id]: { enabled: next } } }, state.revision).then((payload) => {
11103
+ if (!aliveRef.current) return;
11104
+ const merged = hostConfig.adopt(payload);
11105
+ if (payload.config === undefined) hostConfig.reload();
11106
+ publishHostConfigChange();
11107
+ setSubmittingEnv('');
11108
+ setNotice(merged.error
11109
+ ? { kind: 'error', text: configErrorText(merged.error) }
11110
+ : { kind: 'ok', text: (next ? '已启用 ' : '已禁用 ') + group.title + ' · revision ' + merged.revision });
11111
+ }).catch((error) => {
11112
+ if (!aliveRef.current) return;
11113
+ setSubmittingEnv('');
11114
+ if (error && error.code === 'CONFIG_CONFLICT') {
11115
+ adoptHostConflict(hostConfig, error);
11116
+ setNotice({
11117
+ kind: 'conflict',
11118
+ text: '配置已被其他窗口修改(CONFIG_CONFLICT);已按最新 revision 重新读取,本次切换未保存',
11119
+ });
11120
+ return;
11121
+ }
11122
+ setNotice({
11123
+ kind: 'error',
11124
+ text: '保存失败,已回滚为宿主当前值:' + (configErrorText(error && error.message) || '未知错误'),
11125
+ });
11126
+ });
11127
+ }
11128
+ /**
11129
+ * 运行环境分开关:只写 `environments.<env>.providers.<id>.enabled`,与总开关互不影响。
11130
+ * 关闭总开关时不会调用这里,因此各 provider 的已保存值保持不变。
11131
+ */
11132
+ function toggleProvider(group, providerId, next) {
11133
+ if (!configReady || submittingEnv || submittingProvider) return;
11134
+ setSubmittingProvider(providerId);
11135
+ setNotice(null);
11136
+ const patch = { environments: { [group.id]: { providers: { [providerId]: { enabled: next } } } } };
11137
+ putHostConfig(patch, state.revision).then((payload) => {
11138
+ if (!aliveRef.current) return;
11139
+ const merged = hostConfig.adopt(payload);
11140
+ if (payload.config === undefined) hostConfig.reload();
11141
+ publishHostConfigChange();
11142
+ setSubmittingProvider('');
11143
+ const target = (group.targets || []).find((item) => item.id === providerId);
11144
+ const name = target && target.name ? target.name : providerId;
11145
+ setNotice(merged.error
11146
+ ? { kind: 'error', text: configErrorText(merged.error) }
11147
+ : { kind: 'ok', text: (next ? '已启用运行环境 ' : '已禁用运行环境 ') + name + ' · revision ' + merged.revision });
11148
+ }).catch((error) => {
11149
+ if (!aliveRef.current) return;
11150
+ setSubmittingProvider('');
11151
+ if (error && error.code === 'CONFIG_CONFLICT') {
11152
+ adoptHostConflict(hostConfig, error);
11153
+ setNotice({
11154
+ kind: 'conflict',
11155
+ text: '配置已被其他窗口修改(CONFIG_CONFLICT);已按最新 revision 重新读取,本次切换未保存',
11156
+ });
11157
+ return;
11158
+ }
11159
+ setNotice({
11160
+ kind: 'error',
11161
+ text: '保存失败,已回滚为宿主当前值:' + (configErrorText(error && error.message) || '未知错误'),
11162
+ });
11163
+ });
11164
+ }
11165
+ const errorText = notice && notice.kind !== 'ok' ? notice.text : state.error;
11166
+ return h('section', {
11167
+ className: 'uts-settings',
11168
+ 'data-uts-settings': 'page',
11169
+ 'data-uts-hide-unimplemented': String(hideUnimplemented),
11170
+ 'data-uts-config-revision': state.revision === null ? '' : String(state.revision),
11171
+ 'data-uts-config-status': state.error ? 'error' : state.revision === null ? 'loading' : 'ready',
11172
+ },
11173
+ h('style', null, TARGET_ENV_CSS),
11174
+ h('header', { className: 'uts-head' },
11175
+ h('div', { className: 'uts-head-row' },
11176
+ h('h2', { className: 'uts-page-title' }, 'UI 测试'),
11177
+ // 不再提供「重新读取」按钮:面板一打开就会自动读一次 Host 配置
11178
+ // (useHostConfig 挂载即 GET /config?view=global),CONFIG_CONFLICT 时也会自动 adopt/reload。
11179
+ h('div', { className: 'uts-config-actions' },
11180
+ h('div', { className: 'uts-filter' },
11181
+ h('span', null, '隐藏未实现'),
11182
+ h(TargetEnvSwitch, { label: '隐藏未实现', checked: hideUnimplemented, onChange: setHideUnimplemented })))),
11183
+ h('p', { className: 'uts-page-desc' }, '面向 DeepSeekHarness 的前端 GUI 调试与测试插件:Web / Chromium 已实现;Windows/Linux/macOS 桌面、Android、TUI、HarmonyOS、小程序、游戏与 3D 共九种调试环境规划中。'),
11184
+ errorText
11185
+ ? h('p', {
11186
+ className: 'uts-config-error',
11187
+ role: 'alert',
11188
+ 'data-uts-config-error': notice && notice.kind ? notice.kind : 'read',
11189
+ }, errorText)
11190
+ : null,
11191
+ notice && notice.kind === 'ok'
11192
+ ? h('p', { className: 'uts-config-meta', role: 'status', 'data-uts-config-notice': 'ok' }, notice.text)
11193
+ : null),
11194
+ h('div', { className: 'uts-body' },
11195
+ h('div', { className: 'uts-groups' }, groups.length
11196
+ ? groups.map((group) => {
11197
+ const axes = environmentAxes(state, group.id);
11198
+ const targetAxis = axes.find((axis) => axis.axis === 'target') || { status: 'not-probed', reason: '' };
11199
+ const readinessEntry = state.readiness ? state.readiness[group.id] : null;
11200
+ return h(TargetEnvGroup, {
11201
+ key: group.id,
11202
+ group,
11203
+ axes,
11204
+ checkedAt: readinessEntry ? readinessEntry.checkedAt : '',
11205
+ expanded: expanded[group.id] === true,
11206
+ onToggle: toggleGroup,
11207
+ envEnabled: enabledFromConfig(state.config, group.id) === true,
11208
+ providers: providersFromConfig(state.config, group.id),
11209
+ currentProvider: currentProviderFromState(state),
11210
+ configReady,
11211
+ configError: state.loading ? '正在读取宿主配置…' : state.error,
11212
+ busy: submittingEnv === group.id || submittingProvider !== '',
11213
+ targetAxis,
11214
+ onDiagnose: (providerId, trigger) => openDiagnostics(group.id, providerId, trigger),
11215
+ onToggleEnv: (next) => toggleEnvironment(group, next),
11216
+ onToggleProvider: (providerId, next) => toggleProvider(group, providerId, next),
11217
+ });
11218
+ })
11219
+ : h('p', { className: 'uts-empty' }, '暂无已实现的目标'))));
11220
+ }
11221
+ return {
11222
+ inject: ['slots'],
11223
+ apply(ctx) {
11224
+ // 面板组件在模块作用域定义,这里把根 ctx 交给它按需读取 DSH 服务(聊天框追加)。
11225
+ pluginContext = ctx;
11226
+ // 选元素引用的 `@` 来源:候选=最近选取的元素;chip 显示紧凑标签,模型文本走 codec.serialize。
11227
+ registerElementReferenceSource(ctx);
11228
+ // 发送后的用户消息里,同样的元素块折叠成紧凑 chip(模型文本不变)。
11229
+ registerUserTextElementReference(ctx);
11230
+
11231
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: ID }, DebugView));
11232
+
11233
+ // 用户 2026-11-06:在 DSH 设置弹层里注册独立设置页(设置 → UI 测试 → 目标环境配置)。
11234
+ // 只注册前端页面;本轮不探测环境、不写配置、不新增 Host 路由或工具。
11235
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
11236
+ name: 'settings.section',
11237
+ id: 'ui-testing',
11238
+ order: 50,
11239
+ label: () => 'UI 测试',
11240
+ }, TargetEnvSettingsPage));
11241
+
11242
+ // 诊断页放在 frame-wide 覆盖层(`shell.overlay`)里:设置弹层太窄,横排五节点链路放不下。
11243
+ // 该插槽层本身是 click-through,条目自己开 pointer-events(见 .uts-diag-modal-overlay)。
11244
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
11245
+ name: 'shell.overlay',
11246
+ id: 'ui-testing.diagnostics',
11247
+ order: 50,
11248
+ }, DiagnosticsOverlay));
7674
11249
 
7675
11250
  // 左侧「更多」下拉里的一行:打开调试视图,不再占用左侧图标栏
7676
11251
  function MoreRow(props) {