@zq-silk/yui 1.1.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/ARCHITECTURE.md +2 -1
  2. package/ARCHITECTURE.zh-CN.md +2 -1
  3. package/README.md +2 -0
  4. package/dist/cli/commandCatalog.js +344 -324
  5. package/dist/cli/commandDiscovery.js +69 -0
  6. package/dist/cli/completion.js +22 -227
  7. package/dist/cli/dynamicCompletion.js +12 -6
  8. package/dist/cli/invocationAuthority.js +2 -2
  9. package/dist/cli/invocationRouter.js +18 -7
  10. package/dist/cli/managedDiagnostics.js +2 -1
  11. package/dist/cli/updatePorts.js +0 -5
  12. package/dist/cli.js +184 -130
  13. package/dist/commands/capabilityCommands.js +5 -5
  14. package/dist/commands/controllerCommands.js +2 -1
  15. package/dist/commands/globalRoleCommands.js +86 -44
  16. package/dist/commands/grantCommands.js +48 -8
  17. package/dist/commands/taskCommands.js +157 -196
  18. package/dist/commands/taskContextCommand.js +29 -6
  19. package/dist/commands/taskFactCommands.js +10 -69
  20. package/dist/commands/taskInputCommands.js +26 -35
  21. package/dist/commands/taskPublicationCommands.js +2 -35
  22. package/dist/commands/taskPublicationVerifyCommand.js +1 -2
  23. package/dist/commands/taskUpstreamCommands.js +2 -1
  24. package/dist/context/runContextPack.js +8 -29
  25. package/dist/context/sessionBootstrapManifest.js +5 -101
  26. package/dist/context/taskContext.js +104 -41
  27. package/dist/controller/clientRuntime.js +16 -8
  28. package/dist/controller/fileSchedulerStoreAdapter.js +24 -0
  29. package/dist/controller/runtime.js +6 -0
  30. package/dist/core/controllerClient.js +46 -21
  31. package/dist/core/protocol.js +12 -7
  32. package/dist/doctor/doctor.js +4 -3
  33. package/dist/errors/cliError.js +4 -4
  34. package/dist/errors/cliFailure.js +213 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +1 -4
  36. package/dist/grant/capabilityGrant.js +13 -0
  37. package/dist/grant/taskAuthorization.js +127 -0
  38. package/dist/kernel/accessAssessment.js +1 -0
  39. package/dist/kernel/builtinCapabilities.js +88 -9
  40. package/dist/kernel/capabilityRegistry.js +64 -19
  41. package/dist/kernel/kernelPorts.js +2 -2
  42. package/dist/nativeAgent/agent.js +173 -0
  43. package/dist/nativeAgent/contracts.js +1 -0
  44. package/dist/nativeAgent/demo.js +29 -0
  45. package/dist/nativeAgent/index.js +3 -0
  46. package/dist/nativeAgent/mockProvider.js +43 -0
  47. package/dist/nativeAgent/textTools.js +156 -0
  48. package/dist/nativeAgent/validation.js +95 -0
  49. package/dist/output/boundedRead.js +118 -0
  50. package/dist/plugins/pluginService.js +98 -8
  51. package/dist/release/releaseWorkflowEngine.js +8 -1
  52. package/dist/resources/projectResourceService.js +23 -1
  53. package/dist/runtime/managedCaller.js +2 -2
  54. package/dist/runtime/managedIdentity.js +6 -0
  55. package/dist/runtime/runtimeCoherence.js +10 -8
  56. package/dist/storage/sqliteSchema.js +10 -0
  57. package/dist/storage/storageVersions.js +1 -1
  58. package/dist/storage/taskStore.js +3 -0
  59. package/dist/surface/surfaceContributions.js +3 -0
  60. package/dist/task/leaderArchive.js +111 -0
  61. package/dist/task/leaderArchiveAuthority.js +24 -0
  62. package/dist/task/taskAuthority.js +5 -10
  63. package/dist/tmux/commandExecutor.js +7 -5
  64. package/dist/web/assets/assetManifest.js +38 -22
  65. package/dist/web/assets/client/api.js +115 -0
  66. package/dist/web/assets/client/app.js +555 -863
  67. package/dist/web/assets/client/components.js +323 -897
  68. package/dist/web/assets/client/detail.js +333 -0
  69. package/dist/web/assets/client/dock.js +225 -0
  70. package/dist/web/assets/client/dom.js +55 -3
  71. package/dist/web/assets/client/evidence.js +172 -0
  72. package/dist/web/assets/client/format.js +25 -14
  73. package/dist/web/assets/client/forms.js +230 -0
  74. package/dist/web/assets/client/i18n.js +1020 -791
  75. package/dist/web/assets/client/overview.js +115 -0
  76. package/dist/web/assets/client/records.js +63 -0
  77. package/dist/web/assets/client/sections.js +313 -0
  78. package/dist/web/assets/client/sidebar.js +117 -0
  79. package/dist/web/assets/client/theme.js +61 -22
  80. package/dist/web/assets/icons.js +39 -0
  81. package/dist/web/assets/shell.js +124 -118
  82. package/dist/web/assets/styles/base.js +41 -0
  83. package/dist/web/assets/styles/components.js +167 -0
  84. package/dist/web/assets/styles/layout.js +49 -77
  85. package/dist/web/assets/styles/markdown.js +16 -24
  86. package/dist/web/assets/styles/responsive.js +33 -44
  87. package/dist/web/assets/styles/tokens.js +65 -89
  88. package/dist/web/assets/styles/views.js +289 -0
  89. package/dist/web/webServer.js +2 -1
  90. package/docs/architecture/README.md +1 -0
  91. package/docs/architecture/README.zh-CN.md +1 -0
  92. package/docs/cli-information-contract.md +107 -0
  93. package/docs/cli-information-contract.zh-CN.md +81 -0
  94. package/docs/managed-turn-and-session-runtime.md +9 -0
  95. package/docs/managed-turn-and-session-runtime.zh-CN.md +6 -0
  96. package/docs/plugin-sdk.md +7 -3
  97. package/docs/plugin-sdk.zh-CN.md +5 -2
  98. package/docs/release-workflow.md +42 -9
  99. package/docs/release-workflow.zh-CN.md +30 -7
  100. package/docs/storage-baseline.md +5 -0
  101. package/docs/testing/verification-levels.md +2 -2
  102. package/i18n/README.zh-CN.md +2 -0
  103. package/package.json +1 -1
  104. package/skills/yui-leader/SKILL.md +8 -1
  105. package/skills/yui-leader/references/authorization.md +64 -0
  106. package/skills/yui-leader/references/execution.md +18 -4
  107. package/skills/yui-leader/references/task-plugins.md +4 -2
  108. package/skills/yui-operator/SKILL.md +9 -2
  109. package/skills/yui-runtime/SKILL.md +77 -9
  110. package/skills/yui-runtime/references/publication.md +3 -1
  111. package/dist/web/assets/client/taskSummary.js +0 -350
  112. package/dist/web/assets/client/taskSurface.js +0 -616
  113. package/dist/web/assets/client/view.js +0 -608
  114. package/dist/web/assets/styles/cards.js +0 -247
  115. package/dist/web/assets/styles/widgets.js +0 -168
@@ -1,591 +1,287 @@
1
- export const APP_SCRIPT = `
2
- import { Terminal } from "/assets/vendor/xterm.mjs";
3
- import { FitAddon } from "/assets/vendor/addon-fit.mjs";
1
+ export const APP_SCRIPT = String.raw `
2
+ // Application controller: view state, URL routing, the refresh loop, the
3
+ // sidebar | center | dock layout, keyboard shortcuts and dialogs. Everything
4
+ // kept here is view state; Task facts always come from the loopback API.
4
5
  import { createI18n } from "/assets/js/i18n.js";
5
6
  import { createThemeController } from "/assets/js/theme.js";
6
- import { updateTaskObservations } from "/assets/js/task-summary.js";
7
- import {
8
- renderError,
9
- renderFilters,
10
- renderLoading,
11
- renderOverview,
12
- renderTaskDetail,
13
- renderTasks
14
- } from "/assets/js/view.js";
15
-
16
- const elements = {
17
- locale: document.querySelector("#locale-select"),
18
- theme: document.querySelector("#theme-select"),
19
- refresh: document.querySelector("#refresh"),
20
- operatorTerminal: document.querySelector("#operator-terminal"),
21
- search: document.querySelector("#search"),
22
- filters: document.querySelector("#status-filters"),
23
- tasks: document.querySelector("#task-list"),
24
- catalogNext: document.querySelector("#catalog-next"),
25
- catalogReset: document.querySelector("#catalog-reset"),
26
- catalogCount: document.querySelector("#catalog-count"),
27
- catalogAttentionReset: document.querySelector("#catalog-attention-reset"),
28
- detail: document.querySelector("#detail"),
29
- mainCol: document.querySelector(".main-col"),
30
- topbar: document.querySelector(".topbar"),
31
- detailBack: document.querySelector("#detail-back"),
32
- detailTabs: document.querySelector("#detail-tabs"),
33
- conversationToggle: document.querySelector("#conversation-toggle"),
34
- conversationPanel: document.querySelector("#conversation-panel"),
35
- conversationContent: document.querySelector("#conversation-content"),
36
- conversationClose: document.querySelector("#conversation-close"),
37
- conversationSwap: document.querySelector("#conversation-swap"),
38
- paneDivider: document.querySelector("#pane-divider"),
39
- pageTitle: document.querySelector("#page-title"),
40
- toast: document.querySelector("#toast"),
41
- lastSync: document.querySelector("#last-sync"),
42
- terminalPanel: document.querySelector("#terminal-panel"),
43
- terminalHost: document.querySelector("#terminal-host"),
44
- terminalTitle: document.querySelector("#terminal-title"),
45
- terminalState: document.querySelector("#terminal-state span"),
46
- terminalClose: document.querySelector("#terminal-close")
7
+ import { api, releaseMutation } from "/assets/js/api.js";
8
+ import { formatClock } from "/assets/js/format.js";
9
+ import { h } from "/assets/js/dom.js";
10
+ import { renderAttentionBar, renderFilters, renderTasks, STATUS_FILTERS, ATTENTION_KINDS } from "/assets/js/sidebar.js";
11
+ import { renderOverview } from "/assets/js/overview.js";
12
+ import { renderTaskDetail, selectTab, updateObservation, TABS } from "/assets/js/detail.js";
13
+ import { renderDiscussion, discussionHasUnsent, createTerminalController, sameTarget } from "/assets/js/dock.js";
14
+ import { entriesOf } from "/assets/js/records.js";
15
+
16
+ const $ = function (selector) { return document.querySelector(selector); };
17
+ const el = {
18
+ sidebar: $(".sidebar"), center: $("#center"), detail: $("#detail"),
19
+ search: $("#search"), filters: $("#status-filters"), attention: $("#attention-bar"), tasks: $("#task-list"),
20
+ catalogNext: $("#catalog-next"), catalogReset: $("#catalog-reset"), catalogCount: $("#catalog-count"),
21
+ catalogAttentionReset: $("#catalog-attention-reset"),
22
+ refresh: $("#refresh"), sync: $("#sync-state"), lastSync: $("#last-sync"), toast: $("#toast"),
23
+ operator: $("#operator-terminal"), settingsOpen: $("#settings-open"), settings: $("#settings-dialog"),
24
+ locale: $("#locale-select"), themeOptions: $("#theme-options"),
25
+ dock: $("#dock"), divider: $("#dock-divider"), dockSwap: $("#dock-swap"), dockClose: $("#dock-close"),
26
+ dockTabDiscussion: $("#dock-tab-discussion"), dockTabSession: $("#dock-tab-session"),
27
+ discussion: $("#dock-discussion"), session: $("#dock-session"),
28
+ terminalHost: $("#terminal-host"), terminalEmpty: $("#terminal-empty"), terminalState: $("#terminal-state"),
29
+ sessionTargets: $("#session-targets"), terminalCli: $("#terminal-cli")
47
30
  };
48
31
 
49
- const token = document.querySelector('meta[name="yui-web-token"]').content;
50
32
  const state = {
51
- tasks: [],
52
- counts: null,
53
- attention: [],
54
- catalogAttention: null,
55
- catalogScope: null,
56
- catalogQuery: "",
57
- sessionOverview: null,
58
- sessionLoading: false,
59
- sessionError: null,
60
- catalogAll: false,
61
- catalogTotal: 0,
62
- catalogCursor: null,
63
- nextCursor: null,
64
- attentionFilter: null,
65
- generatedAt: null,
66
- filter: "all",
67
- query: "",
68
- selected: null,
69
- detail: null,
70
- detailKey: null
33
+ tasks: [], counts: null, attention: [], catalogAttention: null, catalogScope: null, catalogQuery: "",
34
+ catalogAll: false, catalogTotal: 0, catalogCursor: null, nextCursor: null,
35
+ sessionOverview: null, sessionLoading: false, sessionError: null,
36
+ attentionFilter: null, generatedAt: null, filter: "all", query: "",
37
+ selected: null, detail: null, detailKey: null, activeTab: "overview"
71
38
  };
72
- const VALID_FILTERS = ["all", "active", "draft", "completed", "cancelled", "archived"];
73
- const VALID_ATTENTION = ["openInputs", "pendingOperations", "unknownOperations", "executionSignals"];
74
- let terminalSession = null;
75
- let terminalStateKey = "terminal.closed";
76
- const submittedRequests = new Set();
77
- let conversationVisible = true;
78
- let paneWidth = Number(localStorage.getItem("yui.conversation.width")) || 400;
79
- const paneSide = localStorage.getItem("yui.conversation.side") === "left" ? "left" : "right";
80
- document.body.classList.toggle("pane-left", paneSide === "left");
81
-
82
- const i18n = createI18n(elements.locale);
83
- createThemeController(elements.theme);
84
39
 
85
- // --- URL / history ---------------------------------------------------------
86
- // Reflect the current view (selected task, status filter, search query) in the
87
- // query string so the browser back/forward buttons work and the page can be
88
- // deep-linked / refreshed without losing context.
89
- function readQuery() {
90
- return new URLSearchParams(window.location.search);
40
+ function readPreference(key, fallback) {
41
+ try { return localStorage.getItem(key) || fallback; } catch { return fallback; }
91
42
  }
92
-
93
- function buildSearch(params) {
94
- const entries = Array.from(params.entries()).filter(function (entry) {
95
- return entry[1] !== "" && entry[1] !== null && entry[1] !== undefined;
96
- });
97
- if (!entries.length) return "";
98
- return "?" + entries.map(function (entry) {
99
- return encodeURIComponent(entry[0]) + "=" + encodeURIComponent(entry[1]);
100
- }).join("&");
43
+ function writePreference(key, value) {
44
+ try { localStorage.setItem(key, value); } catch {}
101
45
  }
102
-
103
- function applyUrl(params, options) {
104
- const replace = options && options.replace;
105
- const search = buildSearch(params);
46
+ const dock = {
47
+ open: readPreference("yui.dock.open", "true") !== "false",
48
+ mode: "discussion",
49
+ center: readPreference("yui.dock.side", "end") === "center",
50
+ width: Number(readPreference("yui.dock.width", "440")) || 440,
51
+ // On narrow screens the dock is a full-screen sheet opened explicitly per
52
+ // Task, never restored from the desktop preference.
53
+ sheet: false
54
+ };
55
+ const narrow = window.matchMedia("(max-width: 900px)");
56
+
57
+ const i18n = createI18n(el.locale);
58
+ const t = i18n.t;
59
+ const locale = i18n.getLocale;
60
+ const theme = createThemeController(el.themeOptions, t);
61
+ const terminal = createTerminalController({
62
+ host: el.terminalHost, empty: el.terminalEmpty, state: el.terminalState, targets: el.sessionTargets, cli: el.terminalCli
63
+ }, t, locale, showToast);
64
+ theme.subscribe(function () { terminal.retheme(); });
65
+
66
+ let toastTimer = null;
67
+ function showToast(message) {
68
+ el.toast.textContent = message;
69
+ el.toast.classList.add("show");
70
+ window.clearTimeout(toastTimer);
71
+ toastTimer = window.setTimeout(function () { el.toast.classList.remove("show"); }, 3200);
72
+ }
73
+
74
+ // --- URL / history ---------------------------------------------------------------
75
+ // The selected Task, section, status filter, search and attention filter live
76
+ // in the query string so back/forward, refresh and deep links keep context.
77
+ function readQuery() { return new URLSearchParams(window.location.search); }
78
+ function applyUrl(params, replace) {
79
+ const entries = Array.from(params.entries()).filter(function (entry) { return entry[1] !== ""; });
80
+ const search = entries.length ? "?" + entries.map(function (entry) {
81
+ return encodeURIComponent(entry[0]) + "=" + encodeURIComponent(entry[1]);
82
+ }).join("&") : "";
106
83
  const url = window.location.pathname + search + window.location.hash;
107
- if (replace) {
108
- history.replaceState({ task: params.get("task") || null }, "", url);
109
- } else {
110
- history.pushState({ task: params.get("task") || null }, "", url);
111
- }
112
- }
113
-
114
- function urlTaskId() {
115
- return readQuery().get("task") || null;
116
- }
117
-
118
- // The visible detail section is part of the URL (?task=X&section=exec) so a
119
- // refresh or a shared link lands on the same section. Writes always replace —
120
- // section moves are not history entries.
121
- function setSectionParam(targetId) {
122
- if (!state.detail) return;
123
- const params = readQuery();
124
- const section = String(targetId).replace(/^detail-/, "");
125
- if (params.get("section") === section) return;
126
- params.set("section", section);
127
- applyUrl(params, { replace: true });
128
- }
129
-
130
- let sectionParamTimer = null;
131
- function queueSectionParamSync(targetId) {
132
- if (sectionParamTimer !== null) window.clearTimeout(sectionParamTimer);
133
- sectionParamTimer = window.setTimeout(function () {
134
- sectionParamTimer = null;
135
- setSectionParam(targetId);
136
- }, 250);
84
+ history[replace ? "replaceState" : "pushState"]({ task: params.get("task") || null }, "", url);
137
85
  }
138
-
139
- function syncUrlFromState(options) {
86
+ function urlTaskId() { return readQuery().get("task") || null; }
87
+ function syncUrl(replace) {
140
88
  const params = readQuery();
141
89
  if (state.selected) params.set("task", state.selected);
142
- else {
143
- params.delete("task");
144
- params.delete("section");
145
- }
146
- if (state.filter && state.filter !== "all") params.set("filter", state.filter);
147
- else params.delete("filter");
148
- if (state.query) params.set("q", state.query);
149
- else params.delete("q");
150
- if (state.attentionFilter) params.set("attention", state.attentionFilter);
151
- else params.delete("attention");
152
- if (state.catalogAll) params.set("all", "true");
153
- else params.delete("all");
154
- applyUrl(params, options);
90
+ else { params.delete("task"); params.delete("section"); }
91
+ if (state.filter !== "all") params.set("filter", state.filter); else params.delete("filter");
92
+ if (state.query) params.set("q", state.query); else params.delete("q");
93
+ if (state.attentionFilter) params.set("attention", state.attentionFilter); else params.delete("attention");
94
+ if (state.catalogAll) params.set("all", "true"); else params.delete("all");
95
+ applyUrl(params, replace);
96
+ }
97
+ function setSectionParam(section) {
98
+ const params = readQuery();
99
+ if (!state.selected || params.get("section") === section) return;
100
+ if (section === "overview") params.delete("section"); else params.set("section", section);
101
+ applyUrl(params, true);
155
102
  }
156
103
 
157
- function detailActions() {
104
+ // --- Detail rendering -------------------------------------------------------------
105
+ function detailContext() {
158
106
  return {
159
- conversationHost: elements.conversationContent,
107
+ activeTab: state.activeTab,
108
+ onTab: switchTab,
109
+ onBack: clearSelection,
110
+ showDock: toggleDock,
111
+ openSession: function (roleName) { openSession({ scope: "task", taskId: state.selected, roleName: roleName }); },
160
112
  answerInput: answerInput,
161
- openTerminal: openTerminal,
162
- inspect: inspectRecord,
163
- artifacts: taskId => requestJson("/api/tasks/" + encodeURIComponent(taskId) + "/artifacts"),
164
- evidence: taskId => requestJson("/api/tasks/" + encodeURIComponent(taskId) + "/evidence"),
165
- readArtifact: (taskId, path, commit) => requestJson("/api/tasks/" + encodeURIComponent(taskId)
166
- + "/artifacts?" + new URLSearchParams({ path, commit })),
167
- sendMessage: (taskId, body, requestId, intent) => submitMutation(taskId + "/messages",
168
- "/api/tasks/" + encodeURIComponent(taskId) + "/messages",
169
- { body, requestId, ...(intent === undefined ? {} : { intent }) }),
170
- controlInput: (taskId, payload, requestId) => submitMutation(taskId + "/control/" + requestId,
171
- "/api/tasks/" + encodeURIComponent(taskId) + "/control", payload),
172
- updateTask: (taskId, patch, requestId) => submitMutation(taskId + "/metadata",
173
- "/api/tasks/" + encodeURIComponent(taskId) + "/metadata", { patch, requestId }),
174
- panels: (taskId) => requestJson("/api/tasks/" + encodeURIComponent(taskId) + "/panels",
175
- { signal: AbortSignal.timeout(3000) }),
176
- readPanel: (taskId, ref, input) => requestJson("/api/tasks/" + encodeURIComponent(taskId) + "/panels", {
177
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ref, input }),
178
- signal: AbortSignal.timeout(3000)
179
- })
113
+ inspect: api.inspect,
114
+ artifacts: api.artifacts,
115
+ readArtifact: api.artifact,
116
+ evidence: api.evidence,
117
+ panels: api.panels,
118
+ readPanel: api.readPanel,
119
+ control: api.control,
120
+ updateTask: api.updateTask,
121
+ afterWrite: function () { refreshDashboard({ quiet: true }); }
180
122
  };
181
123
  }
182
-
183
- function updateMetrics() {
184
- elements.lastSync.textContent = state.generatedAt
185
- ? new Intl.DateTimeFormat(i18n.getLocale(), { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(state.generatedAt))
186
- : "—";
187
- }
188
-
189
- function setDetailActive(active) {
190
- document.body.classList.toggle("detail-active", active);
191
- elements.detailBack.hidden = !active;
192
- if (elements.detailTabs) elements.detailTabs.hidden = !active;
193
- if (elements.pageTitle) {
194
- if (active) {
195
- elements.pageTitle.textContent = state.detail
196
- ? state.detail.task.title
197
- : (state.tasks.find(function (task) { return task.id === state.selected; }) || { title: "…" }).title;
198
- elements.pageTitle.dataset.i18n = "";
199
- } else {
200
- elements.pageTitle.textContent = i18n.t("page.title");
201
- elements.pageTitle.dataset.i18n = "page.title";
202
- }
203
- }
204
- updateConversationLayout();
205
- }
206
-
207
- function maxPaneWidth() {
208
- return Math.max(280, Math.min(640,
209
- window.innerWidth - elements.tasks.closest(".sidebar").offsetWidth - 460 - 8));
210
- }
211
-
212
- function setPaneWidth(width, persist, keepPreference) {
213
- const effectiveWidth = Math.max(280, Math.min(maxPaneWidth(), width));
214
- if (!keepPreference) paneWidth = effectiveWidth;
215
- document.documentElement.style.setProperty("--pane-w", effectiveWidth + "px");
216
- elements.paneDivider.setAttribute("aria-valuemax", String(maxPaneWidth()));
217
- elements.paneDivider.setAttribute("aria-valuenow", String(effectiveWidth));
218
- if (persist) localStorage.setItem("yui.conversation.width", String(effectiveWidth));
219
- }
220
-
221
- function updateConversationLayout() {
222
- const visible = !!state.selected && conversationVisible && !terminalPanelOpen();
223
- document.body.classList.toggle("conversation-active", visible);
224
- elements.conversationPanel.hidden = !visible;
225
- elements.paneDivider.hidden = !visible;
226
- elements.conversationToggle.hidden = !state.selected || terminalPanelOpen();
227
- elements.conversationToggle.setAttribute("aria-expanded", String(visible));
228
- elements.conversationToggle.textContent = i18n.t(visible ? "conversation.hide" : "conversation.show");
229
- elements.conversationToggle.setAttribute("aria-label", elements.conversationToggle.textContent);
230
- setPaneWidth(paneWidth, false, true);
231
- }
232
-
233
- function showOverview() {
234
- renderOverview(elements.detail, state, i18n.t, i18n.getLocale(), selectTask);
235
- elements.mainCol.scrollTop = 0;
124
+ function dockActions() {
125
+ return {
126
+ sendMessage: api.sendMessage,
127
+ inspect: api.inspect,
128
+ afterWrite: function () { refreshDashboard({ quiet: true }); }
129
+ };
236
130
  }
237
131
 
238
- // Fingerprint of the rendered detail payload. Quiet polling re-renders the
239
- // whole detail on every tick; skipping identical payloads keeps local UI state
240
- // (expanded blocks, list pages) alive and avoids DOM churn.
241
- let renderedDetailKey = null;
242
-
243
- function detailKeyOf(detail) {
244
- const text = JSON.stringify(detail);
132
+ // Fingerprint of the rendered payload. Quiet polling skips identical reads so
133
+ // local view state (open blocks, list pages, drafts) survives.
134
+ function fingerprint(value) {
135
+ const text = JSON.stringify(value);
245
136
  let hash = 0;
246
- for (let i = 0; i < text.length; i += 1) {
247
- hash = (hash * 31 + text.charCodeAt(i)) | 0;
248
- }
137
+ for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) | 0;
249
138
  return text.length + ":" + hash;
250
139
  }
251
-
252
- // The optional observation arrives after the core snapshot has already been
253
- // rendered, so the render key has to cover the observed facts the detail
254
- // actually draws or they would stay stale until the core snapshot changed.
255
- // Only the rendered identity is included: observation timestamps advance on
256
- // every poll and would re-render the detail continuously.
257
- function runtimeSignatureOf(detail) {
258
- const roles = (detail.runtime && detail.runtime.roles) || [];
259
- return detail.runtimeStatus + "|" + JSON.stringify([
260
- detail.runtime?.sessions?.sessions, detail.runtime?.remoteDelivery,
261
- detail.runtime?.execution?.attention, detail.runtime?.execution?.blockers,
262
- detail.runtime?.execution?.next
263
- ]) + "|" + roles.map(function (role) {
264
- const session = role.runtimeSession;
265
- return role.name + ":" + (session
266
- ? session.nativeSessionId + "/" + session.status
267
- : "-");
140
+ // Only the observed identity participates: observation timestamps advance on
141
+ // every poll and would otherwise re-render continuously.
142
+ function runtimeSignature(detail) {
143
+ const runtime = detail.runtime;
144
+ return detail.runtimeStatus + "|" + JSON.stringify(runtime ? [
145
+ runtime.sessions && runtime.sessions.sessions, runtime.remoteDelivery,
146
+ runtime.execution && runtime.execution.attention, runtime.execution && runtime.execution.blockers,
147
+ runtime.execution && runtime.execution.next, runtime.execution && runtime.execution.status
148
+ ] : null) + "|" + ((runtime && runtime.roles) || []).map(function (role) {
149
+ return role.name + ":" + (role.runtimeSession ? role.runtimeSession.nativeSessionId + "/" + role.runtimeSession.status : "-") + ":" + role.status;
268
150
  }).join(",");
269
151
  }
270
152
 
153
+ let renderedDetailKey = null;
154
+ let renderedDiscussionKey = null;
155
+ function centerIsBusy() {
156
+ const active = document.activeElement;
157
+ const editing = active && (["INPUT", "SELECT", "TEXTAREA"].includes(active.tagName) || active.isContentEditable);
158
+ return el.detail.dataset.taskId === (state.detail && state.detail.task.id)
159
+ && (el.detail.querySelector('[data-unsent="true"]') || el.detail.querySelector('[data-reading="true"]')
160
+ || editing && el.center.contains(active));
161
+ }
271
162
  function renderCurrentDetail(force) {
272
- if (state.detail) {
273
- // Polling must not replace an unsent draft or an in-flight form, including
274
- // after the user has moved focus. This is view state, never Task state.
275
- if (elements.detail.dataset.taskId === state.detail.task.id
276
- && (elements.detail.querySelector('[data-unsent="true"]')
277
- || elements.detail.querySelector('[data-reading="true"]')
278
- || elements.detail.contains(document.activeElement)
279
- || elements.conversationContent.querySelector('[data-unsent="true"]')
280
- || elements.conversationContent.contains(document.activeElement))) return;
281
- const key = i18n.getLocale() + "|" + state.detailKey
282
- + "|" + runtimeSignatureOf(state.detail);
283
- if (!force && key === renderedDetailKey) return;
284
- const openSections = Array.from(elements.detail.querySelectorAll("details[data-view-key][open]"))
285
- .map(element => element.dataset.viewKey);
286
- const oldFeed = elements.conversationContent.querySelector(".conversation-feed");
287
- const feedScroll = oldFeed ? oldFeed.scrollTop : 0;
288
- const atFeedEnd = oldFeed && oldFeed.scrollHeight - oldFeed.clientHeight - feedScroll < 32;
289
- renderTaskDetail(
290
- elements.detail,
291
- state.detail,
292
- i18n.t,
293
- i18n.getLocale(),
294
- detailActions()
295
- );
296
- elements.detail.querySelectorAll("details[data-view-key]").forEach(element => {
297
- element.open = openSections.includes(element.dataset.viewKey);
298
- });
299
- const newFeed = elements.conversationContent.querySelector(".conversation-feed");
300
- if (newFeed) newFeed.scrollTop = atFeedEnd ? newFeed.scrollHeight : feedScroll;
301
- renderedDetailKey = key;
302
- } else if (!state.selected) {
303
- renderedDetailKey = null;
304
- showOverview();
163
+ if (!state.detail) {
164
+ if (!state.selected) { renderedDetailKey = null; showOverview(); }
165
+ return;
305
166
  }
306
- }
307
-
308
- function renderDynamicContent() {
309
- renderFilters(elements.filters, state, i18n.t, function (filter) {
310
- state.filter = filter;
311
- state.attentionFilter = null;
312
- state.catalogAll = false;
313
- syncUrlFromState({ replace: false });
314
- resetCatalog();
315
- });
316
- elements.catalogNext.disabled = !state.nextCursor || refreshing;
317
- elements.catalogCount.textContent = i18n.t("catalog.count")
318
- .replace("{shown}", String(state.tasks.length)).replace("{total}", String(state.catalogTotal))
319
- + (state.attentionFilter ? " · " + i18n.t("catalog." + state.attentionFilter) : "");
320
- elements.catalogAttentionReset.hidden = !state.attentionFilter;
321
- const savedTaskScroll = elements.tasks.scrollTop;
322
- renderTasks(elements.tasks, state, i18n.t, i18n.getLocale(), selectTask);
323
- elements.tasks.scrollTop = savedTaskScroll;
324
- const preserveScroll = state.detail !== null && elements.mainCol.scrollTop > 0;
325
- const savedScrollTop = elements.mainCol.scrollTop;
326
- renderCurrentDetail();
327
- if (preserveScroll) elements.mainCol.scrollTop = savedScrollTop;
328
- syncTabHighlight();
329
- updateMetrics();
330
- updateStickyOffsets();
331
- }
332
-
333
- function updateStickyOffsets() {
334
- const root = document.documentElement;
335
- if (elements.topbar) {
336
- root.style.setProperty("--topbar-h", elements.topbar.offsetHeight + "px");
167
+ renderDock();
168
+ // Polling must not replace an unsent draft or an in-flight form, including
169
+ // after focus moved. This is view state, never Task state.
170
+ if (centerIsBusy()) return;
171
+ const key = locale() + "|" + state.detailKey + "|" + runtimeSignature(state.detail);
172
+ if (!force && key === renderedDetailKey) return;
173
+ const openSections = Array.from(el.detail.querySelectorAll("details[data-view-key][open]")).map(function (d) { return d.dataset.viewKey; });
174
+ const focusedTab = el.detail.querySelector(".tab:focus");
175
+ const focusedTabId = focusedTab && focusedTab.id;
176
+ const scroll = el.detail.dataset.taskId === state.detail.task.id ? el.center.scrollTop : 0;
177
+ renderTaskDetail(el.detail, state.detail, t, locale(), detailContext());
178
+ el.detail.querySelectorAll("details[data-view-key]").forEach(function (d) { d.open = openSections.includes(d.dataset.viewKey); });
179
+ if (focusedTabId) el.detail.querySelector("#" + CSS.escape(focusedTabId)).focus({ preventScroll: true });
180
+ el.center.scrollTop = scroll;
181
+ renderedDetailKey = key;
182
+ syncDockButtons();
183
+ }
184
+ function renderDock() {
185
+ if (!state.detail) return;
186
+ const key = locale() + "|" + state.detail.task.id + "|" + state.detailKey + "|" + state.detail.task.status;
187
+ if (key !== renderedDiscussionKey || el.discussion.dataset.taskId !== state.detail.task.id) {
188
+ renderDiscussion(el.discussion, state.detail, t, locale(), dockActions());
189
+ renderedDiscussionKey = key;
337
190
  }
338
- if (elements.detailTabs && !elements.detailTabs.hidden) {
339
- root.style.setProperty("--tabs-h", elements.detailTabs.offsetHeight + "px");
191
+ updateSessionTargets();
192
+ }
193
+ function updateSessionTargets() {
194
+ const targets = [];
195
+ if (state.detail) {
196
+ const runtimeRoles = (state.detail.runtime && state.detail.runtime.roles) || [];
197
+ entriesOf(state.detail.core, "role").forEach(function (entry) {
198
+ const runtimeRole = runtimeRoles.find(function (role) { return role.name === entry.ref.refId; });
199
+ targets.push({ scope: "task", taskId: state.detail.task.id, roleName: entry.ref.refId,
200
+ live: !!(runtimeRole && runtimeRole.runtimeSession && runtimeRole.runtimeSession.nativeSessionId) });
201
+ });
340
202
  }
203
+ targets.push({ scope: "global", roleName: "operator" });
204
+ const current = terminal.current();
205
+ if (current && !targets.some(function (target) { return sameTarget(target, current); })) targets.unshift(current);
206
+ terminal.setTargets(targets);
341
207
  }
342
208
 
343
- function updateActiveTabFromScroll() {
344
- if (!state.detail || !elements.detailTabs) return;
345
- const tabs = Array.from(elements.detailTabs.querySelectorAll(".tab"));
346
- if (!tabs.length) return;
347
- const root = elements.mainCol;
348
- const rootTop = root.getBoundingClientRect().top;
349
- // Sections sit below the sticky topbar + tab bar (see .anchor scroll-margin),
350
- // so "current" means the last section whose top crossed that stacked offset.
351
- const stickyH = (elements.topbar ? elements.topbar.offsetHeight : 0)
352
- + (elements.detailTabs.hidden ? 0 : elements.detailTabs.offsetHeight);
353
- const threshold = stickyH + 8;
354
- let activeId = tabs[0].dataset.target;
355
- let bestTop = -Infinity;
356
- tabs.forEach(function (tab) {
357
- const id = tab.dataset.target;
358
- if (!id) return;
359
- const el = root.querySelector("#" + id);
360
- if (!el) return;
361
- const top = el.getBoundingClientRect().top - rootTop;
362
- if (top <= threshold && top > bestTop) {
363
- bestTop = top;
364
- activeId = id;
365
- }
366
- });
367
- tabs.forEach(function (tab) {
368
- tab.classList.toggle("is-active", tab.dataset.target === activeId);
209
+ function showOverview() {
210
+ delete el.detail.dataset.taskId;
211
+ renderOverview(el.detail, state, t, locale(), {
212
+ select: selectTask,
213
+ filterAttention: pickAttention,
214
+ readSessions: readPageSessions,
215
+ openOperator: openOperator
369
216
  });
370
- if (state.detail) queueSectionParamSync(activeId);
371
- }
372
-
373
- function syncTabHighlight() {
374
- // Compute the active tab from the current scroll position. The module-level
375
- // scroll listener keeps it in sync afterwards.
376
- updateActiveTabFromScroll();
377
- }
378
-
379
- function showToast(message) {
380
- elements.toast.textContent = message;
381
- elements.toast.classList.add("show");
382
- window.setTimeout(function () { elements.toast.classList.remove("show"); }, 3200);
383
- }
384
-
385
- function clearSelection() {
386
- if (!canLeaveDetail()) return;
387
- state.selected = null;
388
- state.detail = null;
389
- setDetailActive(false);
390
- elements.conversationContent.replaceChildren();
391
- syncUrlFromState({ replace: !urlTaskId() });
392
- showOverview();
393
- const savedTaskScroll = elements.tasks.scrollTop;
394
- renderTasks(elements.tasks, state, i18n.t, i18n.getLocale(), selectTask);
395
- elements.tasks.scrollTop = savedTaskScroll;
396
217
  }
397
218
 
398
- async function requestJson(path, options) {
399
- const response = await fetch(path, {
400
- ...options,
401
- headers: {
402
- accept: "application/json",
403
- "x-yui-web-token": token,
404
- ...(options && options.headers ? options.headers : {})
405
- }
406
- });
407
- if (!response.ok) {
408
- let message = "HTTP " + response.status;
409
- let disposition = "unknown";
410
- try {
411
- const body = await response.json();
412
- if (body && body.error) message = body.error;
413
- if (body && body.disposition === "not-submitted") disposition = "not-submitted";
414
- } catch {}
415
- throw Object.assign(new Error(message), { disposition });
416
- }
417
- return response.json();
219
+ function switchTab(tab, options) {
220
+ if (!TABS.includes(tab)) return;
221
+ state.activeTab = tab;
222
+ selectTab(el.detail, tab);
223
+ if (!(options && options.keepScroll)) el.center.scrollTop = 0;
224
+ setSectionParam(tab);
418
225
  }
419
-
420
- async function submitMutation(key, path, body) {
421
- if (submittedRequests.has(key)) throw new Error("An earlier submission is unresolved; read current facts.");
422
- submittedRequests.add(key);
423
- try {
424
- const receipt = await requestJson(path, {
425
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body)
426
- });
427
- submittedRequests.delete(key);
428
- return receipt;
429
- } catch (error) {
430
- if (error.disposition === "not-submitted") submittedRequests.delete(key);
431
- throw error;
432
- }
226
+ // A section may name a tab or any anchored block inside one (for example
227
+ // "reviews" → the Work tab, scrolled to #detail-reviews).
228
+ function applySection(section) {
229
+ if (!section || TABS.includes(section)) { switchTab(section || "overview"); return; }
230
+ const anchor = el.detail.querySelector("#detail-" + CSS.escape(section));
231
+ const panel = anchor && anchor.closest(".tab-panel");
232
+ if (!panel) { switchTab("overview"); return; }
233
+ state.activeTab = panel.id.replace(/^panel-/, "");
234
+ selectTab(el.detail, state.activeTab);
235
+ anchor.scrollIntoView({ block: "start" });
433
236
  }
434
237
 
238
+ // --- Loading ----------------------------------------------------------------------
435
239
  async function loadTaskDetail(taskId, navigate) {
436
- // navigate marks an explicit user navigation (task selection, deep link,
437
- // back/forward). Only a navigation may move the viewport; the 5s background
438
- // refresh (navigate=false) preserves the current scroll, so a quiet tick
439
- // never yanks the page back to the URL section.
440
240
  if (navigate) {
441
- renderLoading(elements.detail, i18n.t, "loading.detail");
442
- elements.mainCol.scrollTop = 0;
241
+ el.detail.replaceChildren(h("div.page", null, h("div.loading-block", null, h("span.spinner"), h("span", null, t("loading.detail")))));
242
+ delete el.detail.dataset.taskId;
243
+ el.center.scrollTop = 0;
443
244
  }
444
- const savedScrollTop = elements.mainCol.scrollTop;
445
- const base = "/api/tasks/" + encodeURIComponent(taskId);
446
- // Rendering consumes the current snapshot, not event pages. Reconnect uses
447
- // this same read; the independent delta API retains its fixed-bound contract.
448
- const core = await requestJson(base + "/context");
245
+ // Rendering consumes the current Context snapshot, not event pages.
246
+ const core = await api.context(taskId);
449
247
  const taskEntry = core.records.find(function (entry) { return entry.ref.store === "task"; });
450
248
  if (!taskEntry) throw new Error("Task reference unavailable.");
451
- const task = taskEntry.omitted ? (await inspectRecord(taskId, taskEntry.ref)).value : taskEntry.value;
249
+ const task = taskEntry.omitted ? (await api.inspect(taskId, taskEntry.ref)).value : taskEntry.value;
250
+ if (state.selected !== taskId) return;
452
251
  const previous = state.detail && state.detail.task.id === taskId ? state.detail : null;
453
252
  const detail = {
454
- task, core,
253
+ task: task, core: core,
455
254
  viewState: previous ? previous.viewState : {},
456
- runtime: previous && previous.runtime,
255
+ runtime: previous ? previous.runtime : null,
457
256
  runtimeStatus: previous ? previous.runtimeStatus : "waiting",
458
257
  runtimeObservedAt: previous ? previous.runtimeObservedAt : new Date().toISOString()
459
258
  };
460
- if (state.selected !== taskId) return;
461
259
  state.detail = detail;
462
- state.detailKey = detailKeyOf(core);
463
- renderCurrentDetail();
464
- // Optional observation does not participate in core readiness or cursor.
465
- void requestJson(base, { signal: AbortSignal.timeout(1000) }).then(function (runtime) {
260
+ state.detailKey = fingerprint(core);
261
+ renderCurrentDetail(navigate);
262
+ if (navigate) applySection(readQuery().get("section"));
263
+ // The optional observation never participates in core readiness.
264
+ api.observation(taskId).then(function (runtime) {
466
265
  if (state.detail !== detail) return;
467
266
  detail.runtime = runtime;
468
267
  detail.runtimeStatus = "available";
469
268
  detail.runtimeObservedAt = new Date().toISOString();
470
- updateRuntimePanel(detail);
269
+ observationArrived(detail);
471
270
  }).catch(function () {
472
271
  if (state.detail !== detail) return;
473
272
  detail.runtime = null;
474
273
  detail.runtimeStatus = "unavailable";
475
274
  detail.runtimeObservedAt = new Date().toISOString();
476
- updateRuntimePanel(detail);
275
+ observationArrived(detail);
477
276
  });
478
- // Reveal the tab bar before measuring/scroll so anchors land correctly.
479
- setDetailActive(true);
480
- updateStickyOffsets();
481
- // Only an explicit navigation may reposition the viewport. On navigation a
482
- // section in the URL wins over the top; a background refresh keeps the reader
483
- // where they are so the page does not jump on every poll.
484
- if (navigate) {
485
- const section = readQuery().get("section");
486
- const anchor = section ? elements.detail.querySelector("#detail-" + section) : null;
487
- if (anchor) {
488
- anchor.scrollIntoView({ block: "start" });
489
- } else {
490
- elements.mainCol.scrollTop = savedScrollTop;
491
- }
492
- } else {
493
- elements.mainCol.scrollTop = savedScrollTop;
494
- }
495
- syncTabHighlight();
496
277
  }
497
-
498
- function updateRuntimePanel(detail) {
499
- updateTaskObservations(elements.detail, detail, i18n.t, i18n.getLocale());
500
- const status = elements.detail.querySelector("[data-runtime-status]");
501
- const value = elements.detail.querySelector("[data-runtime-value]");
502
- if (status) status.textContent = detail.runtimeStatus + " · " + detail.runtimeObservedAt;
503
- if (value) value.textContent = detail.runtime
504
- ? JSON.stringify({ roles: detail.runtime.roles, runtimeHealth: detail.runtime.runtimeHealth }, null, 2)
505
- : "";
506
- // Patching the panel in place is not enough: the observation also feeds facts
507
- // the detail rendered before it arrived. Re-render when the observed identity
508
- // has actually changed — renderCurrentDetail still compares the render key,
509
- // so an unchanged observation costs nothing and an unsent form is preserved.
278
+ function observationArrived(detail) {
279
+ if (el.detail.dataset.taskId === detail.task.id) updateObservation(el.detail, detail, t, locale(), detailContext());
280
+ // Patching slots is not enough when the observed identity changed facts the
281
+ // detail drew earlier; the render key decides whether a re-render is due.
510
282
  if (state.detail === detail) renderCurrentDetail();
511
283
  }
512
284
 
513
- async function inspectRecord(taskId, ref) {
514
- const query = new URLSearchParams({ store: ref.store, ref: ref.refId });
515
- if (ref.digest) query.set("digest", ref.digest);
516
- return requestJson("/api/tasks/" + encodeURIComponent(taskId) + "/inspect?" + query);
517
- }
518
-
519
- async function selectTask(taskId) {
520
- // Re-selecting the already visible Task is not navigation. Keep the same
521
- // draft and fixed artifact selection, just as an ordinary refresh does.
522
- if (state.selected === taskId && state.detail) return;
523
- if (state.selected !== taskId && !canLeaveDetail()) {
524
- syncUrlFromState({ replace: true });
525
- return;
526
- }
527
- // Switching tasks drops the previous section; staying on the same task
528
- // (URL-driven loads, popstate) keeps it.
529
- if (urlTaskId() !== taskId) {
530
- const params = readQuery();
531
- params.delete("section");
532
- applyUrl(params, { replace: true });
533
- }
534
- state.selected = taskId;
535
- state.detail = null;
536
- conversationVisible = true;
537
- elements.conversationContent.replaceChildren();
538
- renderedDetailKey = null;
539
- // When the selection is driven by the URL (initial load or popstate), the
540
- // URL already reflects the task id, so replace instead of pushing a
541
- // duplicate history entry.
542
- syncUrlFromState({ replace: state.selected === urlTaskId() });
543
- setDetailActive(true);
544
- const savedTaskScroll = elements.tasks.scrollTop;
545
- renderTasks(elements.tasks, state, i18n.t, i18n.getLocale(), selectTask);
546
- elements.tasks.scrollTop = savedTaskScroll;
547
- try {
548
- await loadTaskDetail(taskId, true);
549
- } catch {
550
- if (state.selected !== taskId) return;
551
- renderError(elements.detail, i18n.t("errors.detail"));
552
- showToast(i18n.t("errors.detail"));
553
- }
554
- }
555
-
556
- function canLeaveDetail() {
557
- return !(elements.detail.querySelector('[data-unsent="true"]')
558
- || elements.conversationContent.querySelector('[data-unsent="true"]')) || window.confirm(
559
- i18n.getLocale().startsWith("zh") ? "此任务有未提交或结果尚不确定的输入。仍要离开?"
560
- : "This Task has unsent input or an unresolved submission. Leave anyway?");
561
- }
562
- window.addEventListener("beforeunload", event => {
563
- if (!elements.detail.querySelector('[data-unsent="true"]')
564
- && !elements.conversationContent.querySelector('[data-unsent="true"]')) return;
565
- event.preventDefault();
566
- event.returnValue = "";
567
- });
568
-
569
- async function answerInput(input, answer) {
570
- if (!state.detail) return;
571
- const taskId = state.detail.task.id;
572
- const key = taskId + "/input/" + input.id;
573
- try {
574
- await submitMutation(key,
575
- "/api/tasks/" + encodeURIComponent(taskId)
576
- + "/inputs/" + encodeURIComponent(input.id) + "/answer",
577
- answer
578
- );
579
- showToast(i18n.t("input.answered"));
580
- submittedRequests.delete(key);
581
- await refreshDashboard({ quiet: true });
582
- } catch (error) {
583
- showToast(error.disposition === "not-submitted" ? error.message : i18n.getLocale().startsWith("zh")
584
- ? "回答结果未知;请刷新检查原问题,不要盲目重发。"
585
- : "Answer outcome unknown; refresh the original question before resubmitting.");
586
- }
587
- }
588
-
589
285
  let refreshing = false;
590
286
  let catalogRequest = 0;
591
287
  function resetCatalog() {
@@ -594,14 +290,15 @@ function resetCatalog() {
594
290
  refreshDashboard();
595
291
  }
596
292
  async function refreshDashboard(options) {
597
- if (refreshing && options && options.quiet) return;
293
+ const quiet = options && options.quiet;
294
+ if (refreshing && quiet) return;
598
295
  const request = ++catalogRequest;
599
296
  refreshing = true;
600
- elements.catalogNext.disabled = true;
601
- const quiet = options && options.quiet;
297
+ el.catalogNext.disabled = true;
298
+ el.sync.dataset.state = "syncing";
602
299
  if (!quiet) {
603
- elements.refresh.disabled = true;
604
- if (!state.tasks.length) renderLoading(elements.tasks, i18n.t, "loading.dashboard");
300
+ el.refresh.disabled = true;
301
+ if (!state.tasks.length) el.tasks.replaceChildren(h("div.loading-block", null, h("span.spinner"), h("span", null, t("loading.dashboard"))));
605
302
  }
606
303
  const previousInputs = state.counts ? state.counts.openInputs : null;
607
304
  try {
@@ -611,11 +308,11 @@ async function refreshDashboard(options) {
611
308
  if (state.query.trim()) query.set("search", state.query.trim());
612
309
  if (state.attentionFilter) query.set("attention", state.attentionFilter);
613
310
  if (state.catalogCursor) query.set("cursor", state.catalogCursor);
614
- const dashboard = await requestJson("/api/dashboard?" + query.toString());
311
+ const dashboard = await api.dashboard(query.toString());
615
312
  if (request !== catalogRequest) return;
616
313
  state.tasks = dashboard.tasks;
617
314
  if (state.catalogQuery !== query.toString() || (state.sessionOverview && JSON.stringify(
618
- state.sessionOverview.tasks.map(task => task.taskId)) !== JSON.stringify(dashboard.tasks.map(task => task.id)))) {
315
+ state.sessionOverview.tasks.map(function (task) { return task.taskId; })) !== JSON.stringify(dashboard.tasks.map(function (task) { return task.id; })))) {
619
316
  state.sessionOverview = null;
620
317
  }
621
318
  state.catalogQuery = query.toString();
@@ -629,422 +326,417 @@ async function refreshDashboard(options) {
629
326
  return { taskId: ref.taskId, taskTitle: task ? task.title : ref.taskId };
630
327
  });
631
328
  state.generatedAt = new Date().toISOString();
632
- // A selected Task may live on another page or outside current filters.
633
- // Only its detail endpoint can establish that it no longer exists.
634
- renderDynamicContent();
635
- if (previousInputs !== null && state.counts.openInputs > previousInputs) {
636
- showToast(i18n.t("input.new"));
637
- }
329
+ el.sync.dataset.state = "ok";
330
+ renderSidebar();
331
+ if (!state.selected) showOverview();
332
+ if (previousInputs !== null && state.counts.openInputs > previousInputs) showToast(t("input.new"));
333
+ // A selected Task may live on another page or outside current filters;
334
+ // only its own read can establish that it no longer exists.
638
335
  if (state.selected) {
639
- try { await loadTaskDetail(state.selected, false); } catch {
640
- showToast(i18n.getLocale().startsWith("zh") ? "连接不可用;保留上次读取。" : "Disconnected; showing the last read.");
641
- }
336
+ try { await loadTaskDetail(state.selected, false); }
337
+ catch { showToast(t("errors.disconnected")); }
642
338
  }
643
339
  } catch {
644
340
  if (request !== catalogRequest) return;
341
+ el.sync.dataset.state = "error";
645
342
  if (!quiet) {
646
- renderError(elements.tasks, i18n.t("errors.dashboard"));
647
- showToast(i18n.t("errors.dashboard"));
343
+ el.tasks.replaceChildren(h("div.list-empty.is-error", null, h("p", null, t("errors.dashboard"))));
344
+ showToast(t("errors.dashboard"));
648
345
  }
649
346
  } finally {
650
347
  if (request === catalogRequest) {
651
348
  refreshing = false;
652
- elements.refresh.disabled = false;
653
- elements.catalogNext.disabled = !state.nextCursor;
349
+ el.refresh.disabled = false;
350
+ el.catalogNext.disabled = !state.nextCursor;
654
351
  }
655
352
  }
656
353
  }
657
354
 
658
- function terminalUrl(target, columns, rows) {
659
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
660
- const parameters = new URLSearchParams({
661
- scope: target.scope,
662
- role: target.roleName,
663
- cols: String(columns),
664
- rows: String(rows),
665
- token: token
355
+ function renderSidebar() {
356
+ renderAttentionBar(el.attention, state, t, pickAttention);
357
+ renderFilters(el.filters, state, t, function (filter) {
358
+ state.filter = filter;
359
+ state.attentionFilter = null;
360
+ state.catalogAll = false;
361
+ syncUrl(false);
362
+ resetCatalog();
666
363
  });
667
- if (target.scope === "task") parameters.set("task", target.taskId);
668
- return protocol + "//" + window.location.host + "/api/terminal?" + parameters.toString();
364
+ el.catalogCount.textContent = t("catalog.count").replace("{shown}", String(state.tasks.length)).replace("{total}", String(state.catalogTotal));
365
+ el.catalogReset.hidden = !state.catalogCursor;
366
+ el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
367
+ el.catalogAttentionReset.hidden = !state.attentionFilter;
368
+ const scroll = el.tasks.scrollTop;
369
+ renderTasks(el.tasks, state, t, locale(), selectTask);
370
+ el.tasks.scrollTop = scroll;
371
+ el.lastSync.textContent = state.generatedAt ? formatClock(state.generatedAt, locale()) : "—";
372
+ }
373
+
374
+ async function readPageSessions() {
375
+ if (state.sessionLoading) return;
376
+ const query = state.catalogQuery;
377
+ state.sessionLoading = true;
378
+ showOverview();
379
+ try {
380
+ const result = await api.pageSessions(query);
381
+ if (query !== state.catalogQuery) return;
382
+ if (JSON.stringify(result.tasks.map(function (task) { return task.taskId; })) !== JSON.stringify(state.tasks.map(function (task) { return task.id; }))) {
383
+ throw new Error(t("overview.sessionsPageChanged"));
384
+ }
385
+ state.sessionOverview = result;
386
+ state.sessionError = null;
387
+ } catch (error) { state.sessionError = error.message; }
388
+ finally {
389
+ state.sessionLoading = false;
390
+ if (!state.selected) showOverview();
391
+ }
669
392
  }
670
393
 
671
- function setTerminalState(key) {
672
- terminalStateKey = key;
673
- elements.terminalState.textContent = i18n.t(key);
394
+ function pickAttention(kind) {
395
+ state.attentionFilter = kind;
396
+ state.catalogAll = !!kind && !!state.catalogScope && state.catalogScope.archived === "included";
397
+ state.filter = "all";
398
+ state.query = "";
399
+ el.search.value = "";
400
+ syncUrl(false);
401
+ resetCatalog();
674
402
  }
675
403
 
676
- function terminalPanelOpen() {
677
- return !elements.terminalPanel.hidden;
404
+ // --- Selection --------------------------------------------------------------------
405
+ function hasUnsent() {
406
+ return !!(el.detail.querySelector('[data-unsent="true"]') || discussionHasUnsent(el.discussion));
678
407
  }
679
-
680
- function openTerminalPanel() {
681
- elements.terminalPanel.hidden = false;
682
- elements.terminalPanel.setAttribute("aria-hidden", "false");
683
- document.body.classList.add("terminal-active");
684
- updateConversationLayout();
408
+ function canLeaveDetail() {
409
+ return !hasUnsent() || window.confirm(t("confirm.leave"));
685
410
  }
411
+ window.addEventListener("beforeunload", function (event) {
412
+ if (!hasUnsent()) return;
413
+ event.preventDefault();
414
+ event.returnValue = "";
415
+ });
686
416
 
687
- function closeTerminalPanel() {
688
- disposeTerminal();
689
- document.body.classList.remove("terminal-active");
690
- elements.terminalPanel.setAttribute("aria-hidden", "true");
691
- elements.terminalPanel.hidden = true;
692
- updateConversationLayout();
417
+ async function selectTask(taskId) {
418
+ // Re-selecting the visible Task is not navigation.
419
+ if (state.selected === taskId && state.detail) return;
420
+ if (state.selected !== taskId && !canLeaveDetail()) { syncUrl(true); return; }
421
+ if (urlTaskId() !== taskId) {
422
+ const params = readQuery();
423
+ params.delete("section");
424
+ applyUrl(params, true);
425
+ }
426
+ state.selected = taskId;
427
+ state.detail = null;
428
+ state.activeTab = "overview";
429
+ dock.sheet = false;
430
+ renderedDetailKey = null;
431
+ renderedDiscussionKey = null;
432
+ el.discussion.replaceChildren();
433
+ delete el.discussion.dataset.taskId;
434
+ syncUrl(state.selected === urlTaskId());
435
+ updateLayout();
436
+ const scroll = el.tasks.scrollTop;
437
+ renderTasks(el.tasks, state, t, locale(), selectTask);
438
+ el.tasks.scrollTop = scroll;
439
+ try {
440
+ await loadTaskDetail(taskId, true);
441
+ } catch {
442
+ if (state.selected !== taskId) return;
443
+ el.detail.replaceChildren(h("div.page", null, h("div.list-empty.is-error", null, h("p", null, t("errors.detail")))));
444
+ showToast(t("errors.detail"));
445
+ }
693
446
  }
694
447
 
695
- function disposeTerminal() {
696
- if (!terminalSession) return;
697
- const current = terminalSession;
698
- terminalSession = null;
699
- current.resizeObserver.disconnect();
700
- current.input.dispose();
701
- current.socket.close();
702
- current.terminal.dispose();
703
- elements.terminalHost.replaceChildren();
448
+ function clearSelection() {
449
+ if (!canLeaveDetail()) return;
450
+ state.selected = null;
451
+ state.detail = null;
452
+ el.discussion.replaceChildren();
453
+ delete el.discussion.dataset.taskId;
454
+ if (dock.mode === "discussion" || (terminal.current() && terminal.current().scope === "task")) {
455
+ if (!terminal.connected()) dock.mode = "discussion";
456
+ }
457
+ syncUrl(!urlTaskId());
458
+ updateLayout();
459
+ showOverview();
460
+ const scroll = el.tasks.scrollTop;
461
+ renderTasks(el.tasks, state, t, locale(), selectTask);
462
+ el.tasks.scrollTop = scroll;
463
+ updateSessionTargets();
704
464
  }
705
465
 
706
- function openTerminal(target) {
707
- disposeTerminal();
708
- elements.terminalTitle.textContent = target.scope === "task"
709
- ? target.taskId + " / " + target.roleName
710
- : target.roleName;
711
- setTerminalState("terminal.connecting");
712
- openTerminalPanel();
713
-
714
- const terminal = new Terminal({
715
- cursorBlink: true,
716
- scrollback: 0,
717
- convertEol: false,
718
- fontFamily: '"IBM Plex Mono","JetBrains Mono","SFMono-Regular",Consolas,monospace',
719
- fontSize: 13,
720
- theme: {
721
- background: "#080b11",
722
- foreground: "#e8eef6",
723
- cursor: "#49d6ff",
724
- selectionBackground: "#264b5d"
725
- }
726
- });
727
- const fit = new FitAddon();
728
- terminal.loadAddon(fit);
729
- terminal.open(elements.terminalHost);
730
- fit.fit();
731
-
732
- let writable = false;
733
- const socket = new WebSocket(terminalUrl(target, terminal.cols, terminal.rows));
734
- const input = terminal.onData(function (data) {
735
- if (writable && socket.readyState === WebSocket.OPEN) {
736
- socket.send(JSON.stringify({ type: "input", data: data }));
737
- }
738
- });
739
- const resizeObserver = new ResizeObserver(function () {
740
- if (!terminalSession || terminalSession.terminal !== terminal) return;
741
- fit.fit();
742
- if (socket.readyState === WebSocket.OPEN) {
743
- socket.send(JSON.stringify({
744
- type: "resize",
745
- columns: terminal.cols,
746
- rows: terminal.rows
747
- }));
748
- }
749
- });
750
- resizeObserver.observe(elements.terminalHost);
751
-
752
- socket.addEventListener("message", function (event) {
753
- let message;
754
- try {
755
- message = JSON.parse(event.data);
756
- } catch {
757
- return;
758
- }
759
- if (message.type === "ready") {
760
- writable = !message.readOnly;
761
- setTerminalState(writable ? "terminal.writable" : "terminal.readOnly");
762
- if (message.history && message.history.limit < message.history.target) {
763
- const number = new Intl.NumberFormat(i18n.getLocale());
764
- showToast(
765
- i18n.t("terminal.historyLimited")
766
- .replace("{current}", number.format(message.history.limit))
767
- .replace("{target}", number.format(message.history.target))
768
- );
769
- }
770
- terminal.focus();
771
- } else if (message.type === "data") {
772
- terminal.write(message.data);
773
- } else if (message.type === "exit") {
774
- setTerminalState("terminal.closed");
775
- } else if (message.type === "error") {
776
- setTerminalState("terminal.error");
777
- terminal.writeln("\\r\\n" + message.message);
778
- }
779
- });
780
- socket.addEventListener("close", function () {
781
- writable = false;
782
- setTerminalState("terminal.closed");
783
- });
784
- socket.addEventListener("error", function () {
785
- writable = false;
786
- setTerminalState("terminal.error");
787
- });
788
-
789
- terminalSession = { terminal, socket, input, resizeObserver };
466
+ async function answerInput(input, answer, control) {
467
+ if (!state.detail) return;
468
+ const taskId = state.detail.task.id;
469
+ if (control) control.disabled = true;
470
+ try {
471
+ await api.answerInput(taskId, input.id, answer);
472
+ releaseMutation(taskId + "/input/" + input.id);
473
+ showToast(t("input.answered"));
474
+ const form = control && control.closest("form");
475
+ if (form) form.dataset.unsent = "false";
476
+ await refreshDashboard({ quiet: true });
477
+ } catch (error) {
478
+ if (control) control.disabled = error.disposition !== "not-submitted";
479
+ showToast(error.disposition === "not-submitted" ? error.message : t("input.unknown"));
480
+ }
790
481
  }
791
482
 
792
- if (elements.detailTabs) {
793
- elements.detailTabs.addEventListener("click", function (event) {
794
- const tab = event.target && event.target.closest ? event.target.closest(".tab") : null;
795
- if (!tab) return;
796
- const targetId = tab.dataset.target;
797
- if (!targetId || !elements.detail) return;
798
- const section = elements.detail.querySelector("#" + targetId);
799
- if (!section) return;
800
- const folded = section.querySelector(":scope > details");
801
- if (folded) folded.open = true;
802
- section.scrollIntoView({ behavior: "smooth", block: "start" });
803
- setSectionParam(targetId);
804
- elements.detailTabs.querySelectorAll(".tab").forEach(function (other) {
805
- other.classList.toggle("is-active", other === tab);
806
- });
483
+ // --- Dock layout ------------------------------------------------------------------
484
+ function dockVisible() {
485
+ if (narrow.matches ? !dock.sheet : !dock.open) return false;
486
+ if (state.selected) return true;
487
+ return dock.mode === "session" && terminal.connected();
488
+ }
489
+ function maxDockWidth() {
490
+ const sidebar = window.innerWidth > 900 ? el.sidebar.offsetWidth : 0;
491
+ return Math.max(320, Math.min(760, window.innerWidth - sidebar - 420));
492
+ }
493
+ function setDockWidth(width, persist) {
494
+ const effective = Math.max(320, Math.min(maxDockWidth(), width));
495
+ document.documentElement.style.setProperty("--dock-w", effective + "px");
496
+ el.divider.setAttribute("aria-valuemax", String(maxDockWidth()));
497
+ el.divider.setAttribute("aria-valuenow", String(effective));
498
+ if (persist) { dock.width = effective; writePreference("yui.dock.width", String(effective)); }
499
+ }
500
+ function updateLayout() {
501
+ const visible = dockVisible();
502
+ document.body.classList.toggle("task-open", !!state.selected);
503
+ document.body.classList.toggle("dock-open", visible);
504
+ document.body.classList.toggle("dock-center", dock.center);
505
+ el.dock.hidden = !visible;
506
+ el.divider.hidden = !visible;
507
+ el.discussion.hidden = dock.mode !== "discussion";
508
+ el.session.hidden = dock.mode !== "session";
509
+ el.dockTabDiscussion.setAttribute("aria-selected", String(dock.mode === "discussion"));
510
+ el.dockTabSession.setAttribute("aria-selected", String(dock.mode === "session"));
511
+ el.dockTabDiscussion.disabled = !state.selected;
512
+ el.dockSwap.setAttribute("aria-pressed", String(dock.center));
513
+ setDockWidth(dock.width, false);
514
+ syncDockButtons();
515
+ }
516
+ function syncDockButtons() {
517
+ const visible = dockVisible();
518
+ el.detail.querySelectorAll("[data-dock-toggle]").forEach(function (button) {
519
+ button.setAttribute("aria-pressed", String(visible && dock.mode === button.dataset.dockToggle));
807
520
  });
521
+ el.operator.setAttribute("aria-pressed", String(visible && dock.mode === "session"
522
+ && !!terminal.current() && terminal.current().scope === "global"));
808
523
  }
809
-
810
- let catalogSearchTimer = null;
811
- elements.search.addEventListener("input", function () {
812
- state.query = elements.search.value;
813
- syncUrlFromState({ replace: true });
814
- window.clearTimeout(catalogSearchTimer);
815
- catalogSearchTimer = window.setTimeout(resetCatalog, 200);
524
+ function setDockOpen(open) {
525
+ if (narrow.matches) dock.sheet = open;
526
+ else {
527
+ dock.open = open;
528
+ writePreference("yui.dock.open", String(open));
529
+ }
530
+ if (!open && terminal.connected()) terminal.close();
531
+ updateLayout();
532
+ }
533
+ function setDockMode(mode) {
534
+ dock.mode = mode === "session" || !state.selected ? "session" : "discussion";
535
+ updateLayout();
536
+ }
537
+ function toggleDock(mode) {
538
+ if (dockVisible() && (!mode || dock.mode === mode)) { setDockOpen(false); return; }
539
+ if (mode) dock.mode = mode === "session" || !state.selected ? mode : "discussion";
540
+ if (!state.selected && dock.mode === "discussion") dock.mode = "session";
541
+ setDockOpen(true);
542
+ }
543
+ function openSession(target) {
544
+ dock.mode = "session";
545
+ if (narrow.matches) dock.sheet = true;
546
+ else {
547
+ dock.open = true;
548
+ writePreference("yui.dock.open", "true");
549
+ }
550
+ terminal.open(target);
551
+ updateLayout();
552
+ updateSessionTargets();
553
+ }
554
+ function openOperator() { openSession({ scope: "global", roleName: "operator" }); }
555
+
556
+ el.dockTabDiscussion.addEventListener("click", function () { setDockMode("discussion"); });
557
+ el.dockTabSession.addEventListener("click", function () { setDockMode("session"); });
558
+ el.dockClose.addEventListener("click", function () { setDockOpen(false); });
559
+ el.dockSwap.addEventListener("click", function () {
560
+ dock.center = !dock.center;
561
+ writePreference("yui.dock.side", dock.center ? "center" : "end");
562
+ updateLayout();
816
563
  });
817
- elements.catalogNext.addEventListener("click", function () {
818
- if (!state.nextCursor) return;
819
- state.catalogCursor = state.nextCursor;
820
- refreshDashboard();
564
+ el.operator.addEventListener("click", function () {
565
+ const current = terminal.current();
566
+ if (dockVisible() && dock.mode === "session" && current && current.scope === "global") { setDockOpen(false); return; }
567
+ openOperator();
821
568
  });
822
- elements.catalogReset.addEventListener("click", function () {
823
- resetCatalog();
569
+
570
+ let resizing = false;
571
+ el.divider.addEventListener("pointerdown", function (event) {
572
+ if (event.button !== 0 || window.innerWidth <= 900) return;
573
+ resizing = true;
574
+ el.divider.setPointerCapture(event.pointerId);
575
+ document.body.classList.add("dock-resizing");
576
+ event.preventDefault();
824
577
  });
825
- elements.catalogAttentionReset.addEventListener("click", function () {
826
- state.attentionFilter = null;
827
- state.catalogAll = false;
828
- syncUrlFromState({ replace: false });
829
- resetCatalog();
578
+ el.divider.addEventListener("pointermove", function (event) {
579
+ if (!resizing) return;
580
+ const rect = el.dock.getBoundingClientRect();
581
+ const width = dock.center ? event.clientX - rect.left : rect.right - event.clientX;
582
+ setDockWidth(width, false);
583
+ dock.width = Math.max(320, Math.min(maxDockWidth(), width));
830
584
  });
831
- elements.detail.addEventListener("click", async function (event) {
832
- const observe = event.target.closest("[data-observe-sessions]");
833
- if (observe) {
834
- if (state.sessionLoading) return;
835
- const query = state.catalogQuery;
836
- state.sessionLoading = true;
837
- observe.disabled = true;
838
- try {
839
- const result = await requestJson("/api/dashboard/sessions?" + query, { signal: AbortSignal.timeout(5000) });
840
- if (query !== state.catalogQuery) return;
841
- if (JSON.stringify(result.tasks.map(task => task.taskId)) !== JSON.stringify(state.tasks.map(task => task.id))) {
842
- throw new Error("Catalog page changed; refresh it before reading Sessions.");
843
- }
844
- state.sessionOverview = result;
845
- state.sessionError = null;
846
- } catch (error) { state.sessionError = error.message; }
847
- finally {
848
- state.sessionLoading = false;
849
- if (!state.selected) showOverview();
850
- }
851
- return;
852
- }
853
- const button = event.target.closest("[data-catalog-attention]");
854
- if (!button) return;
855
- state.attentionFilter = button.dataset.catalogAttention;
856
- state.catalogAll = state.catalogScope && state.catalogScope.archived === "included";
857
- state.filter = "all";
858
- state.query = "";
859
- elements.search.value = "";
860
- syncUrlFromState({ replace: false });
861
- resetCatalog();
585
+ function finishResize() {
586
+ if (!resizing) return;
587
+ resizing = false;
588
+ document.body.classList.remove("dock-resizing");
589
+ writePreference("yui.dock.width", String(dock.width));
590
+ }
591
+ el.divider.addEventListener("pointerup", finishResize);
592
+ el.divider.addEventListener("pointercancel", finishResize);
593
+ el.divider.addEventListener("keydown", function (event) {
594
+ const grow = dock.center ? "ArrowRight" : "ArrowLeft";
595
+ const shrink = dock.center ? "ArrowLeft" : "ArrowRight";
596
+ const delta = event.key === grow ? 24 : event.key === shrink ? -24 : 0;
597
+ if (!delta && event.key !== "Home" && event.key !== "End") return;
598
+ event.preventDefault();
599
+ setDockWidth(event.key === "Home" ? 320 : event.key === "End" ? maxDockWidth() : dock.width + delta, true);
862
600
  });
863
- elements.refresh.addEventListener("click", function () { refreshDashboard(); });
864
- elements.operatorTerminal.addEventListener("click", function () {
865
- openTerminal({ scope: "global", roleName: "operator" });
601
+ window.addEventListener("resize", function () { setDockWidth(dock.width, false); });
602
+ narrow.addEventListener("change", updateLayout);
603
+
604
+ // --- Sidebar controls -------------------------------------------------------------
605
+ let searchTimer = null;
606
+ el.search.addEventListener("input", function () {
607
+ state.query = el.search.value;
608
+ syncUrl(true);
609
+ window.clearTimeout(searchTimer);
610
+ searchTimer = window.setTimeout(resetCatalog, 200);
866
611
  });
867
- const globalDialog = document.querySelector("#global-input-dialog");
868
- const globalForm = document.querySelector("#global-input-form");
869
- const globalRole = document.querySelector("#global-input-role");
870
- const globalAction = document.querySelector("#global-input-action");
871
- const globalBody = document.querySelector("#global-input-body");
872
- const globalTarget = document.querySelector("#global-input-target");
873
- const globalThen = document.querySelector("#global-input-then");
874
- const globalSubmit = document.querySelector("#global-input-submit");
875
- const globalReceipt = document.querySelector("#global-input-receipt");
876
- const globalState = document.querySelector("#global-input-state");
877
- document.querySelector("#global-input-open").addEventListener("click", () => globalDialog.showModal());
878
- document.querySelector("#global-input-close").addEventListener("click", () => globalDialog.close());
879
- globalAction.addEventListener("change", () => {
880
- document.querySelector("#global-input-body-label").hidden = globalAction.value === "interrupt";
881
- document.querySelector("#global-input-target-label").hidden = globalAction.value === "queue";
882
- document.querySelector("#global-input-then-label").hidden = globalAction.value !== "interrupt";
612
+ el.catalogNext.addEventListener("click", function () {
613
+ if (!state.nextCursor) return;
614
+ state.catalogCursor = state.nextCursor;
615
+ refreshDashboard();
616
+ });
617
+ el.catalogReset.addEventListener("click", resetCatalog);
618
+ el.catalogAttentionReset.addEventListener("click", function () { pickAttention(null); });
619
+ el.refresh.addEventListener("click", function () { refreshDashboard(); });
620
+ el.settingsOpen.addEventListener("click", function () { el.settings.showModal(); });
621
+
622
+ // --- Global Role input ------------------------------------------------------------
623
+ const globalDialog = $("#global-input-dialog");
624
+ const globalForm = $("#global-input-form");
625
+ const globalRole = $("#global-input-role");
626
+ const globalAction = $("#global-input-action");
627
+ const globalBody = $("#global-input-body");
628
+ const globalTarget = $("#global-input-target");
629
+ const globalThen = $("#global-input-then");
630
+ const globalSubmit = $("#global-input-submit");
631
+ const globalReceipt = $("#global-input-receipt");
632
+ const globalState = $("#global-input-state");
633
+ $("#global-input-open").addEventListener("click", function () { globalDialog.showModal(); });
634
+ $("#global-input-close").addEventListener("click", function () { globalDialog.close(); });
635
+ globalAction.addEventListener("change", function () {
636
+ $("#global-input-body-label").hidden = globalAction.value === "interrupt";
637
+ $("#global-input-target-label").hidden = globalAction.value === "queue";
638
+ $("#global-input-then-label").hidden = globalAction.value !== "interrupt";
883
639
  globalBody.required = globalAction.value !== "interrupt";
884
640
  globalTarget.required = globalAction.value !== "queue";
885
641
  });
886
- document.querySelector("#global-input-inspect").addEventListener("click", async () => {
642
+ $("#global-input-inspect").addEventListener("click", async function () {
643
+ globalState.hidden = false;
887
644
  try {
888
- const facts = await requestJson("/api/roles/" + encodeURIComponent(globalRole.value.trim()) + "/control");
645
+ const facts = await api.globalState(globalRole.value.trim());
889
646
  globalState.textContent = JSON.stringify(facts, null, 2);
890
- globalTarget.value = facts.turn?.nativeTurnId || facts.turn?.attemptId || "";
647
+ globalTarget.value = (facts.turn && (facts.turn.nativeTurnId || facts.turn.attemptId)) || "";
891
648
  } catch (error) { globalState.textContent = error.message; }
892
649
  });
893
- globalForm.addEventListener("submit", async event => {
650
+ globalForm.addEventListener("submit", async function (event) {
894
651
  event.preventDefault();
895
652
  if (globalSubmit.disabled) return;
896
653
  globalSubmit.disabled = true;
897
654
  const role = globalRole.value.trim();
898
655
  const action = globalAction.value;
899
656
  const requestId = crypto.randomUUID();
900
- const input = { action, requestId };
657
+ const input = { action: action, requestId: requestId };
901
658
  if (action !== "interrupt") input.body = globalBody.value;
902
659
  if (action !== "queue") input.expectedTarget = globalTarget.value.trim();
903
660
  if (action === "interrupt" && globalThen.value.trim()) input.thenMessage = globalThen.value.trim();
904
- globalReceipt.textContent = "Waiting for receipt · 等待回执 · " + requestId;
661
+ globalReceipt.dataset.state = "pending";
662
+ globalReceipt.textContent = t("receipt.waiting") + " · " + requestId;
905
663
  try {
906
- const receipt = await submitMutation("global/" + role, "/api/roles/" + encodeURIComponent(role) + "/control", input);
664
+ const receipt = await api.globalControl(role, input);
907
665
  const status = receipt.steer || receipt.interrupt || receipt.delivery || {};
666
+ globalReceipt.dataset.state = "ok";
908
667
  globalReceipt.textContent = JSON.stringify(receipt);
909
668
  const unknown = ["pending", "delivery-unknown", "steer-unknown", "interrupt-unknown"].includes(status.state)
910
669
  || status.code === "DELIVERY_UNKNOWN";
911
670
  globalSubmit.disabled = unknown;
912
671
  if (!unknown && !status.code) globalBody.value = "";
913
672
  } catch (error) {
914
- globalReceipt.textContent = error.disposition === "not-submitted" ? error.message
915
- : "Outcome unknown; read state before acting. 结果未知,请先读取状态,不要重发。 · " + requestId;
673
+ globalReceipt.dataset.state = error.disposition === "not-submitted" ? "bad" : "warn";
674
+ globalReceipt.textContent = error.disposition === "not-submitted" ? error.message : t("receipt.unknownControl") + " · " + requestId;
916
675
  globalSubmit.disabled = error.disposition !== "not-submitted";
917
676
  }
918
677
  });
919
- elements.detailBack.addEventListener("click", clearSelection);
920
- elements.terminalClose.addEventListener("click", closeTerminalPanel);
921
- elements.conversationToggle.addEventListener("click", function () {
922
- conversationVisible = !conversationVisible;
923
- updateConversationLayout();
924
- });
925
- elements.conversationClose.addEventListener("click", function () {
926
- conversationVisible = false;
927
- updateConversationLayout();
928
- elements.conversationToggle.focus();
929
- });
930
- elements.conversationSwap.addEventListener("click", function () {
931
- const left = !document.body.classList.contains("pane-left");
932
- document.body.classList.toggle("pane-left", left);
933
- localStorage.setItem("yui.conversation.side", left ? "left" : "right");
934
- });
935
- let resizingPane = false;
936
- elements.paneDivider.addEventListener("pointerdown", function (event) {
937
- if (event.button !== 0 || elements.paneDivider.hidden || window.innerWidth <= 900) return;
938
- resizingPane = true;
939
- elements.paneDivider.setPointerCapture(event.pointerId);
940
- document.body.classList.add("pane-resizing");
941
- event.preventDefault();
942
- });
943
- elements.paneDivider.addEventListener("pointermove", function (event) {
944
- if (!resizingPane) return;
945
- const left = document.body.classList.contains("pane-left");
946
- const width = left ? event.clientX - elements.tasks.closest(".sidebar").offsetWidth
947
- : window.innerWidth - event.clientX;
948
- setPaneWidth(width - 4, false);
949
- });
950
- function finishPaneResize() {
951
- if (!resizingPane) return;
952
- resizingPane = false;
953
- document.body.classList.remove("pane-resizing");
954
- localStorage.setItem("yui.conversation.width", String(paneWidth));
955
- }
956
- elements.paneDivider.addEventListener("pointerup", finishPaneResize);
957
- elements.paneDivider.addEventListener("pointercancel", finishPaneResize);
958
- elements.paneDivider.addEventListener("keydown", function (event) {
959
- const left = document.body.classList.contains("pane-left");
960
- const delta = left ? (event.key === "ArrowRight" ? 20 : event.key === "ArrowLeft" ? -20 : 0)
961
- : (event.key === "ArrowLeft" ? 20 : event.key === "ArrowRight" ? -20 : 0);
962
- if (!delta && !["Home", "End"].includes(event.key)) return;
963
- event.preventDefault();
964
- setPaneWidth(event.key === "Home" ? 280 : event.key === "End" ? maxPaneWidth() : paneWidth + delta, true);
965
- });
966
- window.addEventListener("resize", function () { setPaneWidth(paneWidth, false, true); });
678
+
679
+ // --- Keyboard ---------------------------------------------------------------------
967
680
  document.addEventListener("keydown", function (event) {
968
- if (globalDialog.open) return;
681
+ if (globalDialog.open || el.settings.open) return;
969
682
  const active = document.activeElement;
970
- const typing = active && ["INPUT", "SELECT", "TEXTAREA"].includes(active.tagName);
971
- if (event.key === "Escape" && terminalPanelOpen()) {
972
- closeTerminalPanel();
973
- return;
974
- }
975
- if (event.key === "Escape" && document.body.classList.contains("conversation-active")) {
976
- conversationVisible = false;
977
- updateConversationLayout();
978
- elements.conversationToggle.focus();
979
- return;
980
- }
981
- if (event.key === "Escape" && state.selected) {
982
- clearSelection();
983
- return;
984
- }
985
- if (typing) return;
986
- if (event.key === "/") {
987
- event.preventDefault();
988
- elements.search.focus();
683
+ const typing = active && (["INPUT", "SELECT", "TEXTAREA"].includes(active.tagName) || active.isContentEditable);
684
+ const inTerminal = el.terminalHost.contains(active);
685
+ if (event.key === "Escape") {
686
+ // A native Session needs its own Escape key.
687
+ if (inTerminal) return;
688
+ if (typing) { active.blur(); return; }
689
+ if (dockVisible()) { setDockOpen(false); return; }
690
+ if (state.selected) clearSelection();
989
691
  return;
990
692
  }
991
- if (event.key.toLowerCase() === "o" && !event.metaKey && !event.ctrlKey && !event.altKey) {
992
- openTerminal({ scope: "global", roleName: "operator" });
993
- return;
994
- }
995
- if (event.key.toLowerCase() === "r" && !event.metaKey && !event.ctrlKey && !event.altKey && !terminalPanelOpen()) {
996
- refreshDashboard();
997
- }
693
+ if (typing || inTerminal || event.metaKey || event.ctrlKey || event.altKey) return;
694
+ const key = event.key.toLowerCase();
695
+ if (key === "/") { event.preventDefault(); el.search.focus(); el.search.select(); }
696
+ else if (key === "r") refreshDashboard();
697
+ else if (key === "o") openOperator();
698
+ else if (key === "d") toggleDock();
699
+ else if (/^[1-5]$/.test(key) && state.detail) switchTab(TABS[Number(key) - 1]);
998
700
  });
999
701
 
1000
- // Restore view state from the URL on load and on browser back/forward.
702
+ // --- Boot -------------------------------------------------------------------------
1001
703
  function applyStateFromUrl() {
1002
704
  const params = readQuery();
1003
705
  const filter = params.get("filter");
1004
- if (filter && VALID_FILTERS.indexOf(filter) !== -1) {
1005
- state.filter = filter;
1006
- } else {
1007
- state.filter = "all";
1008
- }
1009
- const query = params.get("q");
1010
- state.query = query || "";
1011
- state.attentionFilter = VALID_ATTENTION.includes(params.get("attention")) ? params.get("attention") : null;
706
+ state.filter = STATUS_FILTERS.includes(filter) ? filter : "all";
707
+ state.query = params.get("q") || "";
708
+ state.attentionFilter = ATTENTION_KINDS.includes(params.get("attention")) ? params.get("attention") : null;
1012
709
  state.catalogAll = params.get("all") === "true";
1013
- if (elements.search) elements.search.value = state.query;
710
+ el.search.value = state.query;
1014
711
  const taskId = params.get("task");
1015
712
  if (taskId) {
1016
- selectTask(taskId);
1017
- } else {
1018
- if (state.selected && !canLeaveDetail()) {
1019
- syncUrlFromState({ replace: true });
1020
- return;
1021
- }
1022
- state.selected = null;
1023
- state.detail = null;
1024
- setDetailActive(false);
1025
- elements.conversationContent.replaceChildren();
1026
- showOverview();
713
+ if (taskId === state.selected && state.detail) applySection(params.get("section"));
714
+ else selectTask(taskId);
715
+ return;
1027
716
  }
717
+ if (state.selected && !canLeaveDetail()) { syncUrl(true); return; }
718
+ state.selected = null;
719
+ state.detail = null;
720
+ el.discussion.replaceChildren();
721
+ delete el.discussion.dataset.taskId;
722
+ updateLayout();
723
+ showOverview();
1028
724
  }
1029
-
1030
725
  window.addEventListener("popstate", function () {
1031
726
  applyStateFromUrl();
1032
727
  resetCatalog();
1033
728
  });
1034
-
1035
729
  i18n.subscribe(function () {
1036
- renderDynamicContent();
1037
- if (terminalSession) setTerminalState(terminalStateKey);
1038
- updateConversationLayout();
1039
- if (!state.selected && elements.pageTitle) elements.pageTitle.textContent = i18n.t("page.title");
730
+ theme.render();
731
+ terminal.relabel();
732
+ renderSidebar();
733
+ renderedDiscussionKey = null;
734
+ if (state.detail) renderCurrentDetail(true); else showOverview();
735
+ updateLayout();
1040
736
  });
1041
- showOverview();
1042
-
1043
- // Scroll-spy: keep the detail tab bar in sync with the visible section.
1044
- // Bound once on the scroll container; it reads tabs/sections from the live DOM
1045
- // so it survives detail re-renders.
1046
- elements.mainCol.addEventListener("scroll", updateActiveTabFromScroll, { passive: true });
1047
737
 
738
+ updateLayout();
739
+ showOverview();
1048
740
  applyStateFromUrl();
1049
741
  refreshDashboard();
1050
742
  window.setInterval(function () { refreshDashboard({ quiet: true }); }, 5000);