@zq-silk/yui 2.0.0 → 2.2.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 (138) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commandCatalog.js +318 -289
  3. package/dist/cli/commandDiscovery.js +72 -0
  4. package/dist/cli/completion.js +22 -227
  5. package/dist/cli/dynamicCompletion.js +12 -6
  6. package/dist/cli/invocationAuthority.js +2 -2
  7. package/dist/cli/invocationRouter.js +18 -7
  8. package/dist/cli.js +23 -3030
  9. package/dist/commands/capabilityCommands.js +5 -5
  10. package/dist/commands/globalRoleCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +90 -21
  12. package/dist/commands/taskIntegrationCommands.js +3 -1
  13. package/dist/commands/taskUpstreamCommands.js +3 -1
  14. package/dist/context/runContextPack.js +132 -5
  15. package/dist/context/sourceRunContext.js +4 -2
  16. package/dist/context/taskContext.js +12 -3
  17. package/dist/controlPlaneCli.js +3092 -0
  18. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  19. package/dist/controller/jobControl.js +21 -0
  20. package/dist/executor/agentExecutor.js +1 -1
  21. package/dist/executor/effectiveLaunch.js +18 -5
  22. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  23. package/dist/integration/gitIntegrationService.js +50 -15
  24. package/dist/kernel/accessAssessment.js +1 -0
  25. package/dist/kernel/builtinCapabilities.js +28 -1
  26. package/dist/kernel/capabilityRegistry.js +64 -19
  27. package/dist/message/messageContinuation.js +7 -2
  28. package/dist/nativeAgent/agent.js +275 -0
  29. package/dist/nativeAgent/cliDemo.js +37 -0
  30. package/dist/nativeAgent/codingTools.js +11 -0
  31. package/dist/nativeAgent/commandTool.js +215 -0
  32. package/dist/nativeAgent/compactionDemo.js +158 -0
  33. package/dist/nativeAgent/composition.js +71 -0
  34. package/dist/nativeAgent/context/budget.js +44 -0
  35. package/dist/nativeAgent/context/index.js +339 -0
  36. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  37. package/dist/nativeAgent/contracts.js +1 -0
  38. package/dist/nativeAgent/demo.js +40 -0
  39. package/dist/nativeAgent/evaluation/cases.js +38 -0
  40. package/dist/nativeAgent/evaluation/checks.js +91 -0
  41. package/dist/nativeAgent/evaluation/demo.js +19 -0
  42. package/dist/nativeAgent/evaluation/files.js +54 -0
  43. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  44. package/dist/nativeAgent/evaluation/index.js +239 -0
  45. package/dist/nativeAgent/executionOwner.js +209 -0
  46. package/dist/nativeAgent/filePatterns.js +170 -0
  47. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  48. package/dist/nativeAgent/index.js +16 -0
  49. package/dist/nativeAgent/interaction/cli.js +358 -0
  50. package/dist/nativeAgent/interaction/contracts.js +1 -0
  51. package/dist/nativeAgent/interaction/index.js +3 -0
  52. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  53. package/dist/nativeAgent/interaction/renderer.js +34 -0
  54. package/dist/nativeAgent/localSafety.js +258 -0
  55. package/dist/nativeAgent/mockProvider.js +43 -0
  56. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  57. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  58. package/dist/nativeAgent/model/errors.js +47 -0
  59. package/dist/nativeAgent/model/gateway.js +423 -0
  60. package/dist/nativeAgent/model/index.js +7 -0
  61. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  62. package/dist/nativeAgent/model/protocols.js +19 -0
  63. package/dist/nativeAgent/model/responses.js +263 -0
  64. package/dist/nativeAgent/model/types.js +1 -0
  65. package/dist/nativeAgent/model/wire.js +73 -0
  66. package/dist/nativeAgent/observability/index.js +220 -0
  67. package/dist/nativeAgent/product/catalog.js +82 -0
  68. package/dist/nativeAgent/product/config.js +295 -0
  69. package/dist/nativeAgent/product/facts.js +30 -0
  70. package/dist/nativeAgent/product/index.js +62 -0
  71. package/dist/nativeAgent/product/location.js +44 -0
  72. package/dist/nativeAgent/product/runtime.js +276 -0
  73. package/dist/nativeAgent/product/storage.js +49 -0
  74. package/dist/nativeAgent/product/tools.js +47 -0
  75. package/dist/nativeAgent/product/transport.js +54 -0
  76. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  77. package/dist/nativeAgent/searchTools.js +305 -0
  78. package/dist/nativeAgent/session/backends.js +293 -0
  79. package/dist/nativeAgent/session/catalog.js +97 -0
  80. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  81. package/dist/nativeAgent/session/contracts.js +1 -0
  82. package/dist/nativeAgent/session/format.js +269 -0
  83. package/dist/nativeAgent/session/index.js +5 -0
  84. package/dist/nativeAgent/session/location.js +36 -0
  85. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  86. package/dist/nativeAgent/session/store.js +248 -0
  87. package/dist/nativeAgent/textTools.js +293 -0
  88. package/dist/nativeAgent/toolManager/executor.js +290 -0
  89. package/dist/nativeAgent/toolManager/index.js +4 -0
  90. package/dist/nativeAgent/validation.js +95 -0
  91. package/dist/plugins/pluginService.js +98 -8
  92. package/dist/resources/projectResourceService.js +23 -1
  93. package/dist/runtime/managedIdentity.js +6 -0
  94. package/dist/surface/surfaceContributions.js +3 -0
  95. package/dist/task/taskAuthority.js +61 -10
  96. package/dist/web/assets/assetManifest.js +38 -22
  97. package/dist/web/assets/client/api.js +115 -0
  98. package/dist/web/assets/client/app.js +615 -862
  99. package/dist/web/assets/client/components.js +323 -897
  100. package/dist/web/assets/client/detail.js +333 -0
  101. package/dist/web/assets/client/dock.js +225 -0
  102. package/dist/web/assets/client/dom.js +55 -3
  103. package/dist/web/assets/client/evidence.js +172 -0
  104. package/dist/web/assets/client/format.js +25 -14
  105. package/dist/web/assets/client/forms.js +230 -0
  106. package/dist/web/assets/client/i18n.js +1024 -791
  107. package/dist/web/assets/client/overview.js +118 -0
  108. package/dist/web/assets/client/records.js +63 -0
  109. package/dist/web/assets/client/sections.js +313 -0
  110. package/dist/web/assets/client/sidebar.js +146 -0
  111. package/dist/web/assets/client/theme.js +61 -22
  112. package/dist/web/assets/icons.js +39 -0
  113. package/dist/web/assets/shell.js +125 -118
  114. package/dist/web/assets/styles/base.js +41 -0
  115. package/dist/web/assets/styles/components.js +169 -0
  116. package/dist/web/assets/styles/layout.js +56 -77
  117. package/dist/web/assets/styles/markdown.js +16 -24
  118. package/dist/web/assets/styles/responsive.js +41 -44
  119. package/dist/web/assets/styles/tokens.js +65 -89
  120. package/dist/web/assets/styles/views.js +292 -0
  121. package/dist/web/webServer.js +2 -1
  122. package/docs/agent-result-consumption.md +16 -0
  123. package/docs/agent-result-consumption.zh-CN.md +13 -0
  124. package/docs/examples/agent-offline.mjs +194 -0
  125. package/docs/native-agent.md +283 -0
  126. package/docs/release-workflow.md +61 -0
  127. package/docs/release-workflow.zh-CN.md +46 -0
  128. package/docs/roles-and-configuration.md +32 -0
  129. package/docs/roles-and-configuration.zh-CN.md +26 -0
  130. package/package.json +1 -1
  131. package/skills/yui-leader/SKILL.md +9 -0
  132. package/skills/yui-reviewer/SKILL.md +5 -0
  133. package/skills/yui-runtime/SKILL.md +33 -0
  134. package/dist/web/assets/client/taskSummary.js +0 -350
  135. package/dist/web/assets/client/taskSurface.js +0 -616
  136. package/dist/web/assets/client/view.js +0 -608
  137. package/dist/web/assets/styles/cards.js +0 -247
  138. package/dist/web/assets/styles/widgets.js +0 -168
@@ -1,591 +1,294 @@
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"), sidebarDivider: $("#sidebar-divider"), 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
-
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);
91
- }
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("&");
101
- }
102
39
 
103
- function applyUrl(params, options) {
104
- const replace = options && options.replace;
105
- const search = buildSearch(params);
106
- 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
- }
40
+ function readPreference(key, fallback) {
41
+ try { return localStorage.getItem(key) || fallback; } catch { return fallback; }
112
42
  }
113
-
114
- function urlTaskId() {
115
- return readQuery().get("task") || null;
43
+ function writePreference(key, value) {
44
+ try { localStorage.setItem(key, value); } catch {}
116
45
  }
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 });
46
+ function clearPreference(key) {
47
+ try { localStorage.removeItem(key); } catch {}
128
48
  }
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);
49
+ const dock = {
50
+ open: readPreference("yui.dock.open", "true") !== "false",
51
+ mode: "discussion",
52
+ center: readPreference("yui.dock.side", "end") === "center",
53
+ width: Number(readPreference("yui.dock.width", "440")) || 440,
54
+ // On narrow screens the dock is a full-screen sheet opened explicitly per
55
+ // Task, never restored from the desktop preference.
56
+ sheet: false
57
+ };
58
+ // Task list width: null keeps the CSS default (--sidebar-w); otherwise the width
59
+ // the user dragged to. It is a preference; the width actually applied is clamped
60
+ // to the current window.
61
+ const sidebar = { width: Number(readPreference("yui.sidebar.width", "")) || null, resizing: false };
62
+ const narrow = window.matchMedia("(max-width: 900px)");
63
+
64
+ const i18n = createI18n(el.locale);
65
+ const t = i18n.t;
66
+ const locale = i18n.getLocale;
67
+ const theme = createThemeController(el.themeOptions, t);
68
+ const terminal = createTerminalController({
69
+ host: el.terminalHost, empty: el.terminalEmpty, state: el.terminalState, targets: el.sessionTargets, cli: el.terminalCli
70
+ }, t, locale, showToast);
71
+ theme.subscribe(function () { terminal.retheme(); });
72
+
73
+ let toastTimer = null;
74
+ function showToast(message) {
75
+ el.toast.textContent = message;
76
+ el.toast.classList.add("show");
77
+ window.clearTimeout(toastTimer);
78
+ toastTimer = window.setTimeout(function () { el.toast.classList.remove("show"); }, 3200);
79
+ }
80
+
81
+ // --- URL / history ---------------------------------------------------------------
82
+ // The selected Task, section, status filter, search and attention filter live
83
+ // in the query string so back/forward, refresh and deep links keep context.
84
+ function readQuery() { return new URLSearchParams(window.location.search); }
85
+ function applyUrl(params, replace) {
86
+ const entries = Array.from(params.entries()).filter(function (entry) { return entry[1] !== ""; });
87
+ const search = entries.length ? "?" + entries.map(function (entry) {
88
+ return encodeURIComponent(entry[0]) + "=" + encodeURIComponent(entry[1]);
89
+ }).join("&") : "";
90
+ const url = window.location.pathname + search + window.location.hash;
91
+ history[replace ? "replaceState" : "pushState"]({ task: params.get("task") || null }, "", url);
137
92
  }
138
-
139
- function syncUrlFromState(options) {
93
+ function urlTaskId() { return readQuery().get("task") || null; }
94
+ function syncUrl(replace) {
140
95
  const params = readQuery();
141
96
  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);
97
+ else { params.delete("task"); params.delete("section"); }
98
+ if (state.filter !== "all") params.set("filter", state.filter); else params.delete("filter");
99
+ if (state.query) params.set("q", state.query); else params.delete("q");
100
+ if (state.attentionFilter) params.set("attention", state.attentionFilter); else params.delete("attention");
101
+ if (state.catalogAll) params.set("all", "true"); else params.delete("all");
102
+ applyUrl(params, replace);
103
+ }
104
+ function setSectionParam(section) {
105
+ const params = readQuery();
106
+ if (!state.selected || params.get("section") === section) return;
107
+ if (section === "overview") params.delete("section"); else params.set("section", section);
108
+ applyUrl(params, true);
155
109
  }
156
110
 
157
- function detailActions() {
111
+ // --- Detail rendering -------------------------------------------------------------
112
+ function detailContext() {
158
113
  return {
159
- conversationHost: elements.conversationContent,
114
+ activeTab: state.activeTab,
115
+ onTab: switchTab,
116
+ onBack: clearSelection,
117
+ showDock: toggleDock,
118
+ openSession: function (roleName) { openSession({ scope: "task", taskId: state.selected, roleName: roleName }); },
160
119
  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
- })
120
+ inspect: api.inspect,
121
+ artifacts: api.artifacts,
122
+ readArtifact: api.artifact,
123
+ evidence: api.evidence,
124
+ panels: api.panels,
125
+ readPanel: api.readPanel,
126
+ control: api.control,
127
+ updateTask: api.updateTask,
128
+ afterWrite: function () { refreshDashboard({ quiet: true }); }
180
129
  };
181
130
  }
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;
131
+ function dockActions() {
132
+ return {
133
+ sendMessage: api.sendMessage,
134
+ inspect: api.inspect,
135
+ afterWrite: function () { refreshDashboard({ quiet: true }); }
136
+ };
236
137
  }
237
138
 
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);
139
+ // Fingerprint of the rendered payload. Quiet polling skips identical reads so
140
+ // local view state (open blocks, list pages, drafts) survives.
141
+ function fingerprint(value) {
142
+ const text = JSON.stringify(value);
245
143
  let hash = 0;
246
- for (let i = 0; i < text.length; i += 1) {
247
- hash = (hash * 31 + text.charCodeAt(i)) | 0;
248
- }
144
+ for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) | 0;
249
145
  return text.length + ":" + hash;
250
146
  }
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
- : "-");
147
+ // Only the observed identity participates: observation timestamps advance on
148
+ // every poll and would otherwise re-render continuously.
149
+ function runtimeSignature(detail) {
150
+ const runtime = detail.runtime;
151
+ return detail.runtimeStatus + "|" + JSON.stringify(runtime ? [
152
+ runtime.sessions && runtime.sessions.sessions, runtime.remoteDelivery,
153
+ runtime.execution && runtime.execution.attention, runtime.execution && runtime.execution.blockers,
154
+ runtime.execution && runtime.execution.next, runtime.execution && runtime.execution.status
155
+ ] : null) + "|" + ((runtime && runtime.roles) || []).map(function (role) {
156
+ return role.name + ":" + (role.runtimeSession ? role.runtimeSession.nativeSessionId + "/" + role.runtimeSession.status : "-") + ":" + role.status;
268
157
  }).join(",");
269
158
  }
270
159
 
160
+ let renderedDetailKey = null;
161
+ let renderedDiscussionKey = null;
162
+ function centerIsBusy() {
163
+ const active = document.activeElement;
164
+ const editing = active && (["INPUT", "SELECT", "TEXTAREA"].includes(active.tagName) || active.isContentEditable);
165
+ return el.detail.dataset.taskId === (state.detail && state.detail.task.id)
166
+ && (el.detail.querySelector('[data-unsent="true"]') || el.detail.querySelector('[data-reading="true"]')
167
+ || editing && el.center.contains(active));
168
+ }
271
169
  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();
170
+ if (!state.detail) {
171
+ if (!state.selected) { renderedDetailKey = null; showOverview(); }
172
+ return;
305
173
  }
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");
174
+ renderDock();
175
+ // Polling must not replace an unsent draft or an in-flight form, including
176
+ // after focus moved. This is view state, never Task state.
177
+ if (centerIsBusy()) return;
178
+ const key = locale() + "|" + state.detailKey + "|" + runtimeSignature(state.detail);
179
+ if (!force && key === renderedDetailKey) return;
180
+ const openSections = Array.from(el.detail.querySelectorAll("details[data-view-key][open]")).map(function (d) { return d.dataset.viewKey; });
181
+ const focusedTab = el.detail.querySelector(".tab:focus");
182
+ const focusedTabId = focusedTab && focusedTab.id;
183
+ const scroll = el.detail.dataset.taskId === state.detail.task.id ? el.center.scrollTop : 0;
184
+ renderTaskDetail(el.detail, state.detail, t, locale(), detailContext());
185
+ el.detail.querySelectorAll("details[data-view-key]").forEach(function (d) { d.open = openSections.includes(d.dataset.viewKey); });
186
+ if (focusedTabId) el.detail.querySelector("#" + CSS.escape(focusedTabId)).focus({ preventScroll: true });
187
+ el.center.scrollTop = scroll;
188
+ renderedDetailKey = key;
189
+ syncDockButtons();
190
+ }
191
+ function renderDock() {
192
+ if (!state.detail) return;
193
+ const key = locale() + "|" + state.detail.task.id + "|" + state.detailKey + "|" + state.detail.task.status;
194
+ if (key !== renderedDiscussionKey || el.discussion.dataset.taskId !== state.detail.task.id) {
195
+ renderDiscussion(el.discussion, state.detail, t, locale(), dockActions());
196
+ renderedDiscussionKey = key;
337
197
  }
338
- if (elements.detailTabs && !elements.detailTabs.hidden) {
339
- root.style.setProperty("--tabs-h", elements.detailTabs.offsetHeight + "px");
198
+ updateSessionTargets();
199
+ }
200
+ function updateSessionTargets() {
201
+ const targets = [];
202
+ if (state.detail) {
203
+ const runtimeRoles = (state.detail.runtime && state.detail.runtime.roles) || [];
204
+ entriesOf(state.detail.core, "role").forEach(function (entry) {
205
+ const runtimeRole = runtimeRoles.find(function (role) { return role.name === entry.ref.refId; });
206
+ targets.push({ scope: "task", taskId: state.detail.task.id, roleName: entry.ref.refId,
207
+ live: !!(runtimeRole && runtimeRole.runtimeSession && runtimeRole.runtimeSession.nativeSessionId) });
208
+ });
340
209
  }
210
+ targets.push({ scope: "global", roleName: "operator" });
211
+ const current = terminal.current();
212
+ if (current && !targets.some(function (target) { return sameTarget(target, current); })) targets.unshift(current);
213
+ terminal.setTargets(targets);
341
214
  }
342
215
 
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);
216
+ function showOverview() {
217
+ delete el.detail.dataset.taskId;
218
+ renderOverview(el.detail, state, t, locale(), {
219
+ select: selectTask,
220
+ filterAttention: pickAttention,
221
+ readSessions: readPageSessions,
222
+ openOperator: openOperator
369
223
  });
370
- if (state.detail) queueSectionParamSync(activeId);
371
224
  }
372
225
 
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();
226
+ function switchTab(tab, options) {
227
+ if (!TABS.includes(tab)) return;
228
+ state.activeTab = tab;
229
+ selectTab(el.detail, tab);
230
+ if (!(options && options.keepScroll)) el.center.scrollTop = 0;
231
+ setSectionParam(tab);
377
232
  }
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
- }
397
-
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();
418
- }
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
- }
233
+ // A section may name a tab or any anchored block inside one (for example
234
+ // "reviews" → the Work tab, scrolled to #detail-reviews).
235
+ function applySection(section) {
236
+ if (!section || TABS.includes(section)) { switchTab(section || "overview"); return; }
237
+ const anchor = el.detail.querySelector("#detail-" + CSS.escape(section));
238
+ const panel = anchor && anchor.closest(".tab-panel");
239
+ if (!panel) { switchTab("overview"); return; }
240
+ state.activeTab = panel.id.replace(/^panel-/, "");
241
+ selectTab(el.detail, state.activeTab);
242
+ anchor.scrollIntoView({ block: "start" });
433
243
  }
434
244
 
245
+ // --- Loading ----------------------------------------------------------------------
435
246
  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
247
  if (navigate) {
441
- renderLoading(elements.detail, i18n.t, "loading.detail");
442
- elements.mainCol.scrollTop = 0;
248
+ el.detail.replaceChildren(h("div.page", null, h("div.loading-block", null, h("span.spinner"), h("span", null, t("loading.detail")))));
249
+ delete el.detail.dataset.taskId;
250
+ el.center.scrollTop = 0;
443
251
  }
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");
252
+ // Rendering consumes the current Context snapshot, not event pages.
253
+ const core = await api.context(taskId);
449
254
  const taskEntry = core.records.find(function (entry) { return entry.ref.store === "task"; });
450
255
  if (!taskEntry) throw new Error("Task reference unavailable.");
451
- const task = taskEntry.omitted ? (await inspectRecord(taskId, taskEntry.ref)).value : taskEntry.value;
256
+ const task = taskEntry.omitted ? (await api.inspect(taskId, taskEntry.ref)).value : taskEntry.value;
257
+ if (state.selected !== taskId) return;
452
258
  const previous = state.detail && state.detail.task.id === taskId ? state.detail : null;
453
259
  const detail = {
454
- task, core,
260
+ task: task, core: core,
455
261
  viewState: previous ? previous.viewState : {},
456
- runtime: previous && previous.runtime,
262
+ runtime: previous ? previous.runtime : null,
457
263
  runtimeStatus: previous ? previous.runtimeStatus : "waiting",
458
264
  runtimeObservedAt: previous ? previous.runtimeObservedAt : new Date().toISOString()
459
265
  };
460
- if (state.selected !== taskId) return;
461
266
  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) {
267
+ state.detailKey = fingerprint(core);
268
+ renderCurrentDetail(navigate);
269
+ if (navigate) applySection(readQuery().get("section"));
270
+ // The optional observation never participates in core readiness.
271
+ api.observation(taskId).then(function (runtime) {
466
272
  if (state.detail !== detail) return;
467
273
  detail.runtime = runtime;
468
274
  detail.runtimeStatus = "available";
469
275
  detail.runtimeObservedAt = new Date().toISOString();
470
- updateRuntimePanel(detail);
276
+ observationArrived(detail);
471
277
  }).catch(function () {
472
278
  if (state.detail !== detail) return;
473
279
  detail.runtime = null;
474
280
  detail.runtimeStatus = "unavailable";
475
281
  detail.runtimeObservedAt = new Date().toISOString();
476
- updateRuntimePanel(detail);
282
+ observationArrived(detail);
477
283
  });
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
284
  }
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.
285
+ function observationArrived(detail) {
286
+ if (el.detail.dataset.taskId === detail.task.id) updateObservation(el.detail, detail, t, locale(), detailContext());
287
+ // Patching slots is not enough when the observed identity changed facts the
288
+ // detail drew earlier; the render key decides whether a re-render is due.
510
289
  if (state.detail === detail) renderCurrentDetail();
511
290
  }
512
291
 
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
292
  let refreshing = false;
590
293
  let catalogRequest = 0;
591
294
  function resetCatalog() {
@@ -594,14 +297,15 @@ function resetCatalog() {
594
297
  refreshDashboard();
595
298
  }
596
299
  async function refreshDashboard(options) {
597
- if (refreshing && options && options.quiet) return;
300
+ const quiet = options && options.quiet;
301
+ if (refreshing && quiet) return;
598
302
  const request = ++catalogRequest;
599
303
  refreshing = true;
600
- elements.catalogNext.disabled = true;
601
- const quiet = options && options.quiet;
304
+ el.catalogNext.disabled = true;
305
+ el.sync.dataset.state = "syncing";
602
306
  if (!quiet) {
603
- elements.refresh.disabled = true;
604
- if (!state.tasks.length) renderLoading(elements.tasks, i18n.t, "loading.dashboard");
307
+ el.refresh.disabled = true;
308
+ if (!state.tasks.length) el.tasks.replaceChildren(h("div.loading-block", null, h("span.spinner"), h("span", null, t("loading.dashboard"))));
605
309
  }
606
310
  const previousInputs = state.counts ? state.counts.openInputs : null;
607
311
  try {
@@ -611,11 +315,11 @@ async function refreshDashboard(options) {
611
315
  if (state.query.trim()) query.set("search", state.query.trim());
612
316
  if (state.attentionFilter) query.set("attention", state.attentionFilter);
613
317
  if (state.catalogCursor) query.set("cursor", state.catalogCursor);
614
- const dashboard = await requestJson("/api/dashboard?" + query.toString());
318
+ const dashboard = await api.dashboard(query.toString());
615
319
  if (request !== catalogRequest) return;
616
320
  state.tasks = dashboard.tasks;
617
321
  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)))) {
322
+ state.sessionOverview.tasks.map(function (task) { return task.taskId; })) !== JSON.stringify(dashboard.tasks.map(function (task) { return task.id; })))) {
619
323
  state.sessionOverview = null;
620
324
  }
621
325
  state.catalogQuery = query.toString();
@@ -629,422 +333,471 @@ async function refreshDashboard(options) {
629
333
  return { taskId: ref.taskId, taskTitle: task ? task.title : ref.taskId };
630
334
  });
631
335
  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
- }
336
+ el.sync.dataset.state = "ok";
337
+ renderSidebar();
338
+ if (!state.selected) showOverview();
339
+ if (previousInputs !== null && state.counts.openInputs > previousInputs) showToast(t("input.new"));
340
+ // A selected Task may live on another page or outside current filters;
341
+ // only its own read can establish that it no longer exists.
638
342
  if (state.selected) {
639
- try { await loadTaskDetail(state.selected, false); } catch {
640
- showToast(i18n.getLocale().startsWith("zh") ? "连接不可用;保留上次读取。" : "Disconnected; showing the last read.");
641
- }
343
+ try { await loadTaskDetail(state.selected, false); }
344
+ catch { showToast(t("errors.disconnected")); }
642
345
  }
643
346
  } catch {
644
347
  if (request !== catalogRequest) return;
348
+ el.sync.dataset.state = "error";
645
349
  if (!quiet) {
646
- renderError(elements.tasks, i18n.t("errors.dashboard"));
647
- showToast(i18n.t("errors.dashboard"));
350
+ el.tasks.replaceChildren(h("div.list-empty.is-error", null, h("p", null, t("errors.dashboard"))));
351
+ showToast(t("errors.dashboard"));
648
352
  }
649
353
  } finally {
650
354
  if (request === catalogRequest) {
651
355
  refreshing = false;
652
- elements.refresh.disabled = false;
653
- elements.catalogNext.disabled = !state.nextCursor;
356
+ el.refresh.disabled = false;
357
+ el.catalogNext.disabled = !state.nextCursor;
654
358
  }
655
359
  }
656
360
  }
657
361
 
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
362
+ function renderSidebar() {
363
+ renderAttentionBar(el.attention, state, t, pickAttention);
364
+ renderFilters(el.filters, state, t, function (filter) {
365
+ state.filter = filter;
366
+ state.attentionFilter = null;
367
+ state.catalogAll = false;
368
+ syncUrl(false);
369
+ resetCatalog();
666
370
  });
667
- if (target.scope === "task") parameters.set("task", target.taskId);
668
- return protocol + "//" + window.location.host + "/api/terminal?" + parameters.toString();
371
+ el.catalogCount.textContent = t("catalog.count").replace("{shown}", String(state.tasks.length)).replace("{total}", String(state.catalogTotal));
372
+ el.catalogReset.hidden = !state.catalogCursor;
373
+ el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
374
+ el.catalogAttentionReset.hidden = !state.attentionFilter;
375
+ const scroll = el.tasks.scrollTop;
376
+ renderTasks(el.tasks, state, t, locale(), selectTask);
377
+ el.tasks.scrollTop = scroll;
378
+ el.lastSync.textContent = state.generatedAt ? formatClock(state.generatedAt, locale()) : "—";
379
+ }
380
+
381
+ async function readPageSessions() {
382
+ if (state.sessionLoading) return;
383
+ const query = state.catalogQuery;
384
+ state.sessionLoading = true;
385
+ showOverview();
386
+ try {
387
+ const result = await api.pageSessions(query);
388
+ if (query !== state.catalogQuery) return;
389
+ if (JSON.stringify(result.tasks.map(function (task) { return task.taskId; })) !== JSON.stringify(state.tasks.map(function (task) { return task.id; }))) {
390
+ throw new Error(t("overview.sessionsPageChanged"));
391
+ }
392
+ state.sessionOverview = result;
393
+ state.sessionError = null;
394
+ } catch (error) { state.sessionError = error.message; }
395
+ finally {
396
+ state.sessionLoading = false;
397
+ if (!state.selected) showOverview();
398
+ }
669
399
  }
670
400
 
671
- function setTerminalState(key) {
672
- terminalStateKey = key;
673
- elements.terminalState.textContent = i18n.t(key);
401
+ function pickAttention(kind) {
402
+ state.attentionFilter = kind;
403
+ state.catalogAll = !!kind && !!state.catalogScope && state.catalogScope.archived === "included";
404
+ state.filter = "all";
405
+ state.query = "";
406
+ el.search.value = "";
407
+ syncUrl(false);
408
+ resetCatalog();
674
409
  }
675
410
 
676
- function terminalPanelOpen() {
677
- return !elements.terminalPanel.hidden;
411
+ // --- Selection --------------------------------------------------------------------
412
+ function hasUnsent() {
413
+ return !!(el.detail.querySelector('[data-unsent="true"]') || discussionHasUnsent(el.discussion));
678
414
  }
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();
415
+ function canLeaveDetail() {
416
+ return !hasUnsent() || window.confirm(t("confirm.leave"));
685
417
  }
418
+ window.addEventListener("beforeunload", function (event) {
419
+ if (!hasUnsent()) return;
420
+ event.preventDefault();
421
+ event.returnValue = "";
422
+ });
686
423
 
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();
424
+ async function selectTask(taskId) {
425
+ // Re-selecting the visible Task is not navigation.
426
+ if (state.selected === taskId && state.detail) return;
427
+ if (state.selected !== taskId && !canLeaveDetail()) { syncUrl(true); return; }
428
+ if (urlTaskId() !== taskId) {
429
+ const params = readQuery();
430
+ params.delete("section");
431
+ applyUrl(params, true);
432
+ }
433
+ state.selected = taskId;
434
+ state.detail = null;
435
+ state.activeTab = "overview";
436
+ dock.sheet = false;
437
+ renderedDetailKey = null;
438
+ renderedDiscussionKey = null;
439
+ el.discussion.replaceChildren();
440
+ delete el.discussion.dataset.taskId;
441
+ syncUrl(state.selected === urlTaskId());
442
+ updateLayout();
443
+ const scroll = el.tasks.scrollTop;
444
+ renderTasks(el.tasks, state, t, locale(), selectTask);
445
+ el.tasks.scrollTop = scroll;
446
+ try {
447
+ await loadTaskDetail(taskId, true);
448
+ } catch {
449
+ if (state.selected !== taskId) return;
450
+ el.detail.replaceChildren(h("div.page", null, h("div.list-empty.is-error", null, h("p", null, t("errors.detail")))));
451
+ showToast(t("errors.detail"));
452
+ }
693
453
  }
694
454
 
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();
455
+ function clearSelection() {
456
+ if (!canLeaveDetail()) return;
457
+ state.selected = null;
458
+ state.detail = null;
459
+ el.discussion.replaceChildren();
460
+ delete el.discussion.dataset.taskId;
461
+ if (dock.mode === "discussion" || (terminal.current() && terminal.current().scope === "task")) {
462
+ if (!terminal.connected()) dock.mode = "discussion";
463
+ }
464
+ syncUrl(!urlTaskId());
465
+ updateLayout();
466
+ showOverview();
467
+ const scroll = el.tasks.scrollTop;
468
+ renderTasks(el.tasks, state, t, locale(), selectTask);
469
+ el.tasks.scrollTop = scroll;
470
+ updateSessionTargets();
704
471
  }
705
472
 
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 };
473
+ async function answerInput(input, answer, control) {
474
+ if (!state.detail) return;
475
+ const taskId = state.detail.task.id;
476
+ if (control) control.disabled = true;
477
+ try {
478
+ await api.answerInput(taskId, input.id, answer);
479
+ releaseMutation(taskId + "/input/" + input.id);
480
+ showToast(t("input.answered"));
481
+ const form = control && control.closest("form");
482
+ if (form) form.dataset.unsent = "false";
483
+ await refreshDashboard({ quiet: true });
484
+ } catch (error) {
485
+ if (control) control.disabled = error.disposition !== "not-submitted";
486
+ showToast(error.disposition === "not-submitted" ? error.message : t("input.unknown"));
487
+ }
790
488
  }
791
489
 
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
- });
490
+ // --- Dock layout ------------------------------------------------------------------
491
+ function dockVisible() {
492
+ if (narrow.matches ? !dock.sheet : !dock.open) return false;
493
+ if (state.selected) return true;
494
+ return dock.mode === "session" && terminal.connected();
495
+ }
496
+ function maxDockWidth() {
497
+ const sidebar = window.innerWidth > 900 ? el.sidebar.offsetWidth : 0;
498
+ return Math.max(320, Math.min(760, window.innerWidth - sidebar - 420));
499
+ }
500
+ function setDockWidth(width, persist) {
501
+ const effective = Math.max(320, Math.min(maxDockWidth(), width));
502
+ document.documentElement.style.setProperty("--dock-w", effective + "px");
503
+ el.divider.setAttribute("aria-valuemax", String(maxDockWidth()));
504
+ el.divider.setAttribute("aria-valuenow", String(effective));
505
+ if (persist) { dock.width = effective; writePreference("yui.dock.width", String(effective)); }
506
+ }
507
+ function updateLayout() {
508
+ const visible = dockVisible();
509
+ document.body.classList.toggle("task-open", !!state.selected);
510
+ document.body.classList.toggle("dock-open", visible);
511
+ document.body.classList.toggle("dock-center", dock.center);
512
+ el.dock.hidden = !visible;
513
+ el.divider.hidden = !visible;
514
+ el.discussion.hidden = dock.mode !== "discussion";
515
+ el.session.hidden = dock.mode !== "session";
516
+ el.dockTabDiscussion.setAttribute("aria-selected", String(dock.mode === "discussion"));
517
+ el.dockTabSession.setAttribute("aria-selected", String(dock.mode === "session"));
518
+ el.dockTabDiscussion.disabled = !state.selected;
519
+ el.dockSwap.setAttribute("aria-pressed", String(dock.center));
520
+ applySidebarWidth();
521
+ syncDockButtons();
522
+ }
523
+ function syncDockButtons() {
524
+ const visible = dockVisible();
525
+ el.detail.querySelectorAll("[data-dock-toggle]").forEach(function (button) {
526
+ button.setAttribute("aria-pressed", String(visible && dock.mode === button.dataset.dockToggle));
807
527
  });
528
+ el.operator.setAttribute("aria-pressed", String(visible && dock.mode === "session"
529
+ && !!terminal.current() && terminal.current().scope === "global"));
808
530
  }
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);
531
+ function setDockOpen(open) {
532
+ if (narrow.matches) dock.sheet = open;
533
+ else {
534
+ dock.open = open;
535
+ writePreference("yui.dock.open", String(open));
536
+ }
537
+ if (!open && terminal.connected()) terminal.close();
538
+ updateLayout();
539
+ }
540
+ function setDockMode(mode) {
541
+ dock.mode = mode === "session" || !state.selected ? "session" : "discussion";
542
+ updateLayout();
543
+ }
544
+ function toggleDock(mode) {
545
+ if (dockVisible() && (!mode || dock.mode === mode)) { setDockOpen(false); return; }
546
+ if (mode) dock.mode = mode === "session" || !state.selected ? mode : "discussion";
547
+ if (!state.selected && dock.mode === "discussion") dock.mode = "session";
548
+ setDockOpen(true);
549
+ }
550
+ function openSession(target) {
551
+ dock.mode = "session";
552
+ if (narrow.matches) dock.sheet = true;
553
+ else {
554
+ dock.open = true;
555
+ writePreference("yui.dock.open", "true");
556
+ }
557
+ terminal.open(target);
558
+ updateLayout();
559
+ updateSessionTargets();
560
+ }
561
+ function openOperator() { openSession({ scope: "global", roleName: "operator" }); }
562
+
563
+ el.dockTabDiscussion.addEventListener("click", function () { setDockMode("discussion"); });
564
+ el.dockTabSession.addEventListener("click", function () { setDockMode("session"); });
565
+ el.dockClose.addEventListener("click", function () { setDockOpen(false); });
566
+ el.dockSwap.addEventListener("click", function () {
567
+ dock.center = !dock.center;
568
+ writePreference("yui.dock.side", dock.center ? "center" : "end");
569
+ updateLayout();
816
570
  });
817
- elements.catalogNext.addEventListener("click", function () {
818
- if (!state.nextCursor) return;
819
- state.catalogCursor = state.nextCursor;
820
- refreshDashboard();
571
+ el.operator.addEventListener("click", function () {
572
+ const current = terminal.current();
573
+ if (dockVisible() && dock.mode === "session" && current && current.scope === "global") { setDockOpen(false); return; }
574
+ openOperator();
821
575
  });
822
- elements.catalogReset.addEventListener("click", function () {
823
- resetCatalog();
576
+
577
+ let resizing = false;
578
+ el.divider.addEventListener("pointerdown", function (event) {
579
+ if (event.button !== 0 || window.innerWidth <= 900) return;
580
+ resizing = true;
581
+ el.divider.setPointerCapture(event.pointerId);
582
+ document.body.classList.add("dock-resizing");
583
+ event.preventDefault();
824
584
  });
825
- elements.catalogAttentionReset.addEventListener("click", function () {
826
- state.attentionFilter = null;
827
- state.catalogAll = false;
828
- syncUrlFromState({ replace: false });
829
- resetCatalog();
585
+ el.divider.addEventListener("pointermove", function (event) {
586
+ if (!resizing) return;
587
+ const rect = el.dock.getBoundingClientRect();
588
+ const width = dock.center ? event.clientX - rect.left : rect.right - event.clientX;
589
+ setDockWidth(width, false);
590
+ dock.width = Math.max(320, Math.min(maxDockWidth(), width));
830
591
  });
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;
592
+ function finishResize() {
593
+ if (!resizing) return;
594
+ resizing = false;
595
+ document.body.classList.remove("dock-resizing");
596
+ writePreference("yui.dock.width", String(dock.width));
597
+ }
598
+ el.divider.addEventListener("pointerup", finishResize);
599
+ el.divider.addEventListener("pointercancel", finishResize);
600
+ el.divider.addEventListener("keydown", function (event) {
601
+ const grow = dock.center ? "ArrowRight" : "ArrowLeft";
602
+ const shrink = dock.center ? "ArrowLeft" : "ArrowRight";
603
+ const delta = event.key === grow ? 24 : event.key === shrink ? -24 : 0;
604
+ if (!delta && event.key !== "Home" && event.key !== "End") return;
605
+ event.preventDefault();
606
+ setDockWidth(event.key === "Home" ? 320 : event.key === "End" ? maxDockWidth() : dock.width + delta, true);
607
+ });
608
+ narrow.addEventListener("change", updateLayout);
609
+
610
+ // --- Sidebar width ----------------------------------------------------------------
611
+ const SIDEBAR_MIN = 240;
612
+ const SIDEBAR_MAX = 560;
613
+ function maxSidebarWidth() {
614
+ // An open dock can shrink to its minimum, so it reserves only that much.
615
+ const reserve = dockVisible() ? 320 + 9 : 0;
616
+ return Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth - reserve - 420));
617
+ }
618
+ function applySidebarWidth() {
619
+ const root = document.documentElement.style;
620
+ if (sidebar.width === null) root.removeProperty("--sidebar-w");
621
+ else root.setProperty("--sidebar-w", Math.max(SIDEBAR_MIN, Math.min(maxSidebarWidth(), sidebar.width)) + "px");
622
+ // A wider task list leaves the dock less room; the dock keeps its stored width.
623
+ setDockWidth(dock.width, false);
624
+ el.sidebarDivider.setAttribute("aria-valuemax", String(maxSidebarWidth()));
625
+ el.sidebarDivider.setAttribute("aria-valuenow", String(el.sidebar.offsetWidth));
626
+ }
627
+ function setSidebarWidth(width, persist) {
628
+ sidebar.width = width === null ? null : Math.max(SIDEBAR_MIN, Math.min(maxSidebarWidth(), Math.round(width)));
629
+ if (persist) {
630
+ if (sidebar.width === null) clearPreference("yui.sidebar.width");
631
+ else writePreference("yui.sidebar.width", String(sidebar.width));
852
632
  }
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();
633
+ applySidebarWidth();
634
+ }
635
+ el.sidebarDivider.addEventListener("pointerdown", function (event) {
636
+ if (event.button !== 0 || window.innerWidth <= 900) return;
637
+ sidebar.resizing = true;
638
+ el.sidebarDivider.setPointerCapture(event.pointerId);
639
+ document.body.classList.add("sidebar-resizing");
640
+ event.preventDefault();
862
641
  });
863
- elements.refresh.addEventListener("click", function () { refreshDashboard(); });
864
- elements.operatorTerminal.addEventListener("click", function () {
865
- openTerminal({ scope: "global", roleName: "operator" });
642
+ el.sidebarDivider.addEventListener("pointermove", function (event) {
643
+ if (!sidebar.resizing) return;
644
+ setSidebarWidth(event.clientX - el.sidebar.getBoundingClientRect().left, false);
866
645
  });
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";
646
+ function finishSidebarResize() {
647
+ if (!sidebar.resizing) return;
648
+ sidebar.resizing = false;
649
+ document.body.classList.remove("sidebar-resizing");
650
+ if (sidebar.width !== null) writePreference("yui.sidebar.width", String(sidebar.width));
651
+ }
652
+ el.sidebarDivider.addEventListener("pointerup", finishSidebarResize);
653
+ el.sidebarDivider.addEventListener("pointercancel", finishSidebarResize);
654
+ el.sidebarDivider.addEventListener("dblclick", function () { setSidebarWidth(null, true); });
655
+ el.sidebarDivider.addEventListener("keydown", function (event) {
656
+ const current = el.sidebar.offsetWidth;
657
+ const next = event.key === "ArrowRight" ? current + 16 : event.key === "ArrowLeft" ? current - 16
658
+ : event.key === "Home" ? SIDEBAR_MIN : event.key === "End" ? maxSidebarWidth() : null;
659
+ if (next === null) return;
660
+ event.preventDefault();
661
+ setSidebarWidth(next, true);
662
+ });
663
+ window.addEventListener("resize", applySidebarWidth);
664
+
665
+ // --- Sidebar controls -------------------------------------------------------------
666
+ let searchTimer = null;
667
+ el.search.addEventListener("input", function () {
668
+ state.query = el.search.value;
669
+ syncUrl(true);
670
+ window.clearTimeout(searchTimer);
671
+ searchTimer = window.setTimeout(resetCatalog, 200);
672
+ });
673
+ el.catalogNext.addEventListener("click", function () {
674
+ if (!state.nextCursor) return;
675
+ state.catalogCursor = state.nextCursor;
676
+ refreshDashboard();
677
+ });
678
+ el.catalogReset.addEventListener("click", resetCatalog);
679
+ el.catalogAttentionReset.addEventListener("click", function () { pickAttention(null); });
680
+ el.refresh.addEventListener("click", function () { refreshDashboard(); });
681
+ el.settingsOpen.addEventListener("click", function () { el.settings.showModal(); });
682
+
683
+ // --- Global Role input ------------------------------------------------------------
684
+ const globalDialog = $("#global-input-dialog");
685
+ const globalForm = $("#global-input-form");
686
+ const globalRole = $("#global-input-role");
687
+ const globalAction = $("#global-input-action");
688
+ const globalBody = $("#global-input-body");
689
+ const globalTarget = $("#global-input-target");
690
+ const globalThen = $("#global-input-then");
691
+ const globalSubmit = $("#global-input-submit");
692
+ const globalReceipt = $("#global-input-receipt");
693
+ const globalState = $("#global-input-state");
694
+ $("#global-input-open").addEventListener("click", function () { globalDialog.showModal(); });
695
+ $("#global-input-close").addEventListener("click", function () { globalDialog.close(); });
696
+ globalAction.addEventListener("change", function () {
697
+ $("#global-input-body-label").hidden = globalAction.value === "interrupt";
698
+ $("#global-input-target-label").hidden = globalAction.value === "queue";
699
+ $("#global-input-then-label").hidden = globalAction.value !== "interrupt";
883
700
  globalBody.required = globalAction.value !== "interrupt";
884
701
  globalTarget.required = globalAction.value !== "queue";
885
702
  });
886
- document.querySelector("#global-input-inspect").addEventListener("click", async () => {
703
+ $("#global-input-inspect").addEventListener("click", async function () {
704
+ globalState.hidden = false;
887
705
  try {
888
- const facts = await requestJson("/api/roles/" + encodeURIComponent(globalRole.value.trim()) + "/control");
706
+ const facts = await api.globalState(globalRole.value.trim());
889
707
  globalState.textContent = JSON.stringify(facts, null, 2);
890
- globalTarget.value = facts.turn?.nativeTurnId || facts.turn?.attemptId || "";
708
+ globalTarget.value = (facts.turn && (facts.turn.nativeTurnId || facts.turn.attemptId)) || "";
891
709
  } catch (error) { globalState.textContent = error.message; }
892
710
  });
893
- globalForm.addEventListener("submit", async event => {
711
+ globalForm.addEventListener("submit", async function (event) {
894
712
  event.preventDefault();
895
713
  if (globalSubmit.disabled) return;
896
714
  globalSubmit.disabled = true;
897
715
  const role = globalRole.value.trim();
898
716
  const action = globalAction.value;
899
717
  const requestId = crypto.randomUUID();
900
- const input = { action, requestId };
718
+ const input = { action: action, requestId: requestId };
901
719
  if (action !== "interrupt") input.body = globalBody.value;
902
720
  if (action !== "queue") input.expectedTarget = globalTarget.value.trim();
903
721
  if (action === "interrupt" && globalThen.value.trim()) input.thenMessage = globalThen.value.trim();
904
- globalReceipt.textContent = "Waiting for receipt · 等待回执 · " + requestId;
722
+ globalReceipt.dataset.state = "pending";
723
+ globalReceipt.textContent = t("receipt.waiting") + " · " + requestId;
905
724
  try {
906
- const receipt = await submitMutation("global/" + role, "/api/roles/" + encodeURIComponent(role) + "/control", input);
725
+ const receipt = await api.globalControl(role, input);
907
726
  const status = receipt.steer || receipt.interrupt || receipt.delivery || {};
727
+ globalReceipt.dataset.state = "ok";
908
728
  globalReceipt.textContent = JSON.stringify(receipt);
909
729
  const unknown = ["pending", "delivery-unknown", "steer-unknown", "interrupt-unknown"].includes(status.state)
910
730
  || status.code === "DELIVERY_UNKNOWN";
911
731
  globalSubmit.disabled = unknown;
912
732
  if (!unknown && !status.code) globalBody.value = "";
913
733
  } catch (error) {
914
- globalReceipt.textContent = error.disposition === "not-submitted" ? error.message
915
- : "Outcome unknown; read state before acting. 结果未知,请先读取状态,不要重发。 · " + requestId;
734
+ globalReceipt.dataset.state = error.disposition === "not-submitted" ? "bad" : "warn";
735
+ globalReceipt.textContent = error.disposition === "not-submitted" ? error.message : t("receipt.unknownControl") + " · " + requestId;
916
736
  globalSubmit.disabled = error.disposition !== "not-submitted";
917
737
  }
918
738
  });
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); });
739
+
740
+ // --- Keyboard ---------------------------------------------------------------------
967
741
  document.addEventListener("keydown", function (event) {
968
- if (globalDialog.open) return;
742
+ if (globalDialog.open || el.settings.open) return;
969
743
  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();
744
+ const typing = active && (["INPUT", "SELECT", "TEXTAREA"].includes(active.tagName) || active.isContentEditable);
745
+ const inTerminal = el.terminalHost.contains(active);
746
+ if (event.key === "Escape") {
747
+ // A native Session needs its own Escape key.
748
+ if (inTerminal) return;
749
+ if (typing) { active.blur(); return; }
750
+ if (dockVisible()) { setDockOpen(false); return; }
751
+ if (state.selected) clearSelection();
989
752
  return;
990
753
  }
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
- }
754
+ if (typing || inTerminal || event.metaKey || event.ctrlKey || event.altKey) return;
755
+ const key = event.key.toLowerCase();
756
+ if (key === "/") { event.preventDefault(); el.search.focus(); el.search.select(); }
757
+ else if (key === "r") refreshDashboard();
758
+ else if (key === "o") openOperator();
759
+ else if (key === "d") toggleDock();
760
+ else if (/^[1-5]$/.test(key) && state.detail) switchTab(TABS[Number(key) - 1]);
998
761
  });
999
762
 
1000
- // Restore view state from the URL on load and on browser back/forward.
763
+ // --- Boot -------------------------------------------------------------------------
1001
764
  function applyStateFromUrl() {
1002
765
  const params = readQuery();
1003
766
  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;
767
+ state.filter = STATUS_FILTERS.includes(filter) ? filter : "all";
768
+ state.query = params.get("q") || "";
769
+ state.attentionFilter = ATTENTION_KINDS.includes(params.get("attention")) ? params.get("attention") : null;
1012
770
  state.catalogAll = params.get("all") === "true";
1013
- if (elements.search) elements.search.value = state.query;
771
+ el.search.value = state.query;
1014
772
  const taskId = params.get("task");
1015
773
  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();
774
+ if (taskId === state.selected && state.detail) applySection(params.get("section"));
775
+ else selectTask(taskId);
776
+ return;
1027
777
  }
778
+ if (state.selected && !canLeaveDetail()) { syncUrl(true); return; }
779
+ state.selected = null;
780
+ state.detail = null;
781
+ el.discussion.replaceChildren();
782
+ delete el.discussion.dataset.taskId;
783
+ updateLayout();
784
+ showOverview();
1028
785
  }
1029
-
1030
786
  window.addEventListener("popstate", function () {
1031
787
  applyStateFromUrl();
1032
788
  resetCatalog();
1033
789
  });
1034
-
1035
790
  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");
791
+ theme.render();
792
+ terminal.relabel();
793
+ renderSidebar();
794
+ renderedDiscussionKey = null;
795
+ if (state.detail) renderCurrentDetail(true); else showOverview();
796
+ updateLayout();
1040
797
  });
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
798
 
799
+ updateLayout();
800
+ showOverview();
1048
801
  applyStateFromUrl();
1049
802
  refreshDashboard();
1050
803
  window.setInterval(function () { refreshDashboard({ quiet: true }); }, 5000);