@worca/app 1.0.0 → 1.2.0-rc.1

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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,2129 @@
1
+ // ui/public/ask-panel.mjs — the Ask Worca floating sheet (spec §10). One
2
+ // factory, everything in the closure: the module is evaluated once per test
3
+ // file even though app.js is re-imported with a cache-buster, so module scope
4
+ // holds no state. All markup is built with DOM APIs and textContent — no
5
+ // innerHTML for content anywhere in this file (the markdown renderer owns the
6
+ // only sanitized-HTML path).
7
+ import { createThreadModel } from './ask-model.mjs';
8
+ import { createMarkdownRenderer } from './ask-markdown.mjs';
9
+ import { createThinkingOrb } from './thinking-orb.mjs';
10
+ import { workflowPickerLabel } from './results-view.mjs';
11
+
12
+ /**
13
+ * Cold-start pick, used ONLY until GET /api/ask/models resolves — and afterwards
14
+ * only if that payload carries no `default` (older stubs / a 500). The authoritative
15
+ * default is ASK_LIMITS.defaultModel/defaultEffort, shipped as `catalog.default`
16
+ * and already validated against the live catalog by src/core/ask/models.mjs.
17
+ */
18
+ const FALLBACK_PICK = Object.freeze({ model: 'claude-opus-5', effort: 'high' });
19
+
20
+ const ICONS = {
21
+ threads: 'M4 6h16M4 12h16M4 18h9',
22
+ plus: 'M12 5v14M5 12h14',
23
+ chevronDown: 'M6 9l6 6 6-6',
24
+ send: 'M12 19V5M6 11l6-6 6 6',
25
+ down: 'M12 5v14M6 13l6 6 6-6',
26
+ };
27
+
28
+ export function fmtTokens(n) {
29
+ if (!Number.isFinite(n) || n <= 0) return null;
30
+ return n < 1000 ? `${n} tok` : `${(n / 1000).toFixed(1)}k tok`;
31
+ }
32
+ /** Context fill (usage.ctx / totals.ctx) — a snapshot, never a cumulative sum. */
33
+ export function fmtCtx(n) {
34
+ if (!Number.isFinite(n) || n <= 0) return null;
35
+ return n < 1000 ? `${n} ctx` : `${(n / 1000).toFixed(1)}k ctx`;
36
+ }
37
+ export function fmtUsd(x) {
38
+ return Number.isFinite(x) ? `$${x.toFixed(2)}` : null;
39
+ }
40
+ export function fmtAgents(n) {
41
+ return Number.isFinite(n) && n > 0 ? `${n} agent${n === 1 ? '' : 's'}` : null;
42
+ }
43
+
44
+ /** When a chat was started: relative while it is recent, a short absolute date
45
+ * once it is older than a month. Mirrors plugins-view.mjs relTime's thresholds,
46
+ * but returns null — not the raw input — for a missing or unparsable stamp, so
47
+ * renderThreadRows skips the date element and the row shows nothing rather than
48
+ * "Invalid Date". Pure; callers pass the injected now() to stay jsdom-safe. */
49
+ export function fmtStarted(iso, now = Date.now()) {
50
+ const t = Date.parse(iso);
51
+ if (!Number.isFinite(t)) return null;
52
+ const s = Math.max(0, Math.round((now - t) / 1000));
53
+ if (s < 45) return 'just now';
54
+ const m = Math.round(s / 60); if (m < 60) return `${m}m ago`;
55
+ const h = Math.round(m / 60); if (h < 24) return `${h}h ago`;
56
+ const d = Math.round(h / 24); if (d < 30) return `${d}d ago`;
57
+ return String(iso).slice(0, 10);
58
+ }
59
+
60
+ export function fmtElapsed(ms) {
61
+ if (!Number.isFinite(ms) || ms < 0) return '';
62
+ const s = ms / 1000;
63
+ if (s < 60) return `${s.toFixed(1)}s`;
64
+ return `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, '0')}s`;
65
+ }
66
+ function mmss(ms) {
67
+ const s = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1000));
68
+ return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
69
+ }
70
+ function clipInput(input) {
71
+ if (input && input._truncated === true) return String(input.preview ?? '');
72
+ if (input == null) return '';
73
+ let s = '';
74
+ try { s = JSON.stringify(input); } catch { s = String(input); }
75
+ if (s === '{}') return '';
76
+ return s.length > 60 ? `${s.slice(0, 60)}…` : s;
77
+ }
78
+
79
+ /** The launcher's shortcut hint: the keydown handler accepts BOTH Meta+K and
80
+ * Ctrl+K, but the glyph shown must match the viewer's OS — '⌘K' is meaningless
81
+ * on Windows/Linux, where the working chord is Ctrl+K. */
82
+ export function shortcutLabel(win) {
83
+ const nav = win?.navigator;
84
+ const platform = String(nav?.userAgentData?.platform || nav?.platform || '');
85
+ return /mac|iphone|ipad|ipod/i.test(platform) ? '⌘K' : 'Ctrl K';
86
+ }
87
+
88
+ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, loadMarkdown, hljsLoader, storage, raf, now }) {
89
+ const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
90
+ const st = {
91
+ open: false,
92
+ threadId: null,
93
+ model: null, // createThreadModel for the active thread (Task 4+)
94
+ picker: {
95
+ model: storedPick && storedPick.model ? storedPick.model : FALLBACK_PICK.model,
96
+ effort: storedPick ? storedPick.effort : FALLBACK_PICK.effort,
97
+ },
98
+ // D11 provenance, tracked per slot: only a MODEL the user actually picked
99
+ // outranks the backend default. An effort-only record leaves the model slot
100
+ // unclaimed, so a later change to ASK_LIMITS.defaultModel still reaches here.
101
+ pickerFromStore: !!(storedPick && storedPick.model),
102
+ effortFromStore: storedPick !== null,
103
+ catalog: null,
104
+ // #397: the thread's project/workspace scope. pinned:false = Auto (follow the
105
+ // page — today's behaviour). label caches the display name once resolved.
106
+ scope: { pinned: false, projectKey: null, workspaceId: null, label: null },
107
+ popover: null, // {panel, trigger, onClose, build, refreshOn}
108
+ expandedAgents: new Set(),
109
+ worktrees: [], // P4 §10: the chat's open worktrees (snapshot-fed)
110
+ pinned: true,
111
+ prevFocus: null,
112
+ pendingFiles: [],
113
+ sending: false,
114
+ subscribedFor: null,
115
+ elapsedTimer: null,
116
+ elapsedStart: null,
117
+ flushArmed: false,
118
+ resyncing: false,
119
+ firstOpenDone: false,
120
+ destroyed: false,
121
+ lastAnswerRender: 0,
122
+ rowEls: null,
123
+ cardEls: null,
124
+ cardOptions: null,
125
+ catalogLoading: null,
126
+ mdKicked: false,
127
+ answerPending: null,
128
+ rowPending: null,
129
+ };
130
+ const el = {}; // element refs, filled by the builders
131
+ const renderer = createMarkdownRenderer({ doc, load: loadMarkdown, hljsLoader });
132
+
133
+ // ---- storage --------------------------------------------------------------
134
+ /**
135
+ * The stored pick, or null when nothing usable is stored. `model` is null for an
136
+ * EFFORT-ONLY record — the user moved the effort while the model was still the
137
+ * backend default, so there is no model choice to honour (D11). `effort` is always
138
+ * a string. A legacy record (always `{model,effort}`) reads back unchanged.
139
+ */
140
+ function readStoredModel() {
141
+ try {
142
+ const raw = storage.getItem('worca-cc.ask.model');
143
+ const v = raw ? JSON.parse(raw) : null;
144
+ if (v && typeof v.effort === 'string') {
145
+ return { model: typeof v.model === 'string' && v.model ? v.model : null, effort: v.effort };
146
+ }
147
+ } catch { /* storage unavailable */ }
148
+ return null; // no stored pick — the catalog decides (D5/D6/D11)
149
+ }
150
+ function storeModel() {
151
+ // Provenance travels with the record: writing st.picker.model when the user never
152
+ // chose one would pin the cold-start literal (or a default they merely saw), and
153
+ // the backend would be authoritative exactly once per browser.
154
+ const rec = { model: st.pickerFromStore ? st.picker.model : null, effort: st.picker.effort };
155
+ try { storage.setItem('worca-cc.ask.model', JSON.stringify(rec)); } catch { /* ignore */ }
156
+ }
157
+ function readStoredThread() { try { return storage.getItem('worca-cc.ask.thread') || null; } catch { return null; } }
158
+ function storeThread(id) {
159
+ try {
160
+ if (id) storage.setItem('worca-cc.ask.thread', id);
161
+ else storage.removeItem('worca-cc.ask.thread');
162
+ } catch { /* ignore */ }
163
+ }
164
+
165
+ // ---- tiny DOM helpers -----------------------------------------------------
166
+ function make(tag, className, text) {
167
+ const n = doc.createElement(tag);
168
+ if (className) n.className = className;
169
+ if (text != null) n.textContent = text;
170
+ return n;
171
+ }
172
+ function svgIcon(d, size = 17, sw = 1.9) {
173
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
174
+ svg.setAttribute('width', String(size));
175
+ svg.setAttribute('height', String(size));
176
+ svg.setAttribute('viewBox', '0 0 24 24');
177
+ svg.setAttribute('fill', 'none');
178
+ svg.setAttribute('stroke', 'currentColor');
179
+ svg.setAttribute('stroke-width', String(sw));
180
+ svg.setAttribute('stroke-linecap', 'round');
181
+ svg.setAttribute('stroke-linejoin', 'round');
182
+ svg.setAttribute('aria-hidden', 'true');
183
+ for (const part of Array.isArray(d) ? d : [d]) {
184
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
185
+ path.setAttribute('d', part);
186
+ svg.appendChild(path);
187
+ }
188
+ return svg;
189
+ }
190
+ function iconButton(className, title, icon, onClick) {
191
+ const b = make('button', className);
192
+ b.type = 'button';
193
+ b.title = title;
194
+ b.setAttribute('aria-label', title);
195
+ b.appendChild(svgIcon(icon));
196
+ b.addEventListener('click', onClick);
197
+ return b;
198
+ }
199
+
200
+ // ---- shell ----------------------------------------------------------------
201
+ function buildRoot() {
202
+ const dock = make('div', 'ask-dock');
203
+
204
+ const pill = make('button', 'ask-pill');
205
+ pill.type = 'button';
206
+ const pillLogo = doc.createElement('img');
207
+ pillLogo.className = 'ask-pill-logo';
208
+ pillLogo.src = '/assets/worca-favicon.png';
209
+ pillLogo.alt = '';
210
+ pill.appendChild(pillLogo);
211
+ pill.appendChild(make('span', 'ask-pill-label', 'Ask Worca'));
212
+ pill.appendChild(make('span', 'ask-kbd', shortcutLabel(win)));
213
+ pill.addEventListener('click', openSheet);
214
+
215
+ const sheet = make('section', 'ask-sheet');
216
+ sheet.hidden = true;
217
+ sheet.setAttribute('data-ask-sheet', '');
218
+ sheet.setAttribute('role', 'dialog');
219
+ sheet.setAttribute('aria-label', 'Ask Worca');
220
+
221
+ const header = make('header', 'ask-header');
222
+ const logo = doc.createElement('img');
223
+ logo.className = 'ask-header-logo';
224
+ logo.src = '/assets/worca-favicon.png';
225
+ logo.alt = '';
226
+ header.appendChild(logo);
227
+ el.title = make('div', 'ask-title', 'Ask Worca');
228
+ // Header is logo → title → spacer → icon buttons. The #397 scope selector
229
+ // used to sit here; it now lives in the composer's bottom row next to the
230
+ // "+" attach button (see buildComposer). A long haiku title still ellipsizes.
231
+ header.appendChild(el.title);
232
+ header.appendChild(make('span', 'ask-header-spacer'));
233
+ const threadsBtn = iconButton('ask-icon-btn', 'History', ICONS.threads, () => toggleThreadsPopover(threadsBtn));
234
+ threadsBtn.setAttribute('data-ask-threads-btn', '');
235
+ header.appendChild(threadsBtn);
236
+ const newBtn = iconButton('ask-icon-btn', 'New chat', ICONS.plus, () => newThread());
237
+ newBtn.setAttribute('data-ask-new-btn', '');
238
+ header.appendChild(newBtn);
239
+ header.appendChild(iconButton('ask-icon-btn', 'Close', ICONS.chevronDown, closeSheet));
240
+ sheet.appendChild(header);
241
+
242
+ el.transcript = make('div', 'ask-transcript');
243
+ el.transcript.setAttribute('data-ask-scroll', '');
244
+ el.transcript.addEventListener('scroll', updatePinFromScroll);
245
+ sheet.appendChild(el.transcript);
246
+
247
+ sheet.appendChild(buildComposer());
248
+
249
+ el.live = make('div', 'sr-only');
250
+ el.live.setAttribute('aria-live', 'polite');
251
+ sheet.appendChild(el.live);
252
+
253
+ el.jump = make('button', 'ask-jump');
254
+ el.jump.type = 'button';
255
+ el.jump.appendChild(svgIcon(ICONS.down, 12, 2.2));
256
+ el.jump.appendChild(make('span', null, 'Jump to latest'));
257
+ el.jump.hidden = true;
258
+ el.jump.addEventListener('click', jumpToLatest);
259
+ sheet.appendChild(el.jump);
260
+ dock.appendChild(sheet);
261
+ dock.appendChild(pill);
262
+ el.pill = pill;
263
+ el.sheet = sheet;
264
+ return dock;
265
+ }
266
+
267
+ // Mirrors src/core/ask/attachment-kind.mjs + limits.mjs (#398): text kinds are
268
+ // UTF-8 capped at 512 KB, binary kinds (images + PDF) at 5 MB; the server
269
+ // re-validates everything, these are just early clear messages.
270
+ const ASK_ATTACH_EXT = ['.md', '.markdown', '.txt', '.json', '.csv', '.log'];
271
+ const ASK_ATTACH_BINARY = {
272
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
273
+ '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf',
274
+ };
275
+ const ASK_MAX_TEXT_BYTES = 524_288;
276
+ const ASK_MAX_BINARY_BYTES = 5 * 1024 * 1024;
277
+ const ASK_MAX_THREAD_BYTES = 25 * 1024 * 1024;
278
+
279
+ function bytesToBase64(bytes) {
280
+ let bin = '';
281
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
282
+ return win.btoa(bin);
283
+ }
284
+
285
+ function setComposerMsg(text) {
286
+ el.composerMsg.textContent = text || '';
287
+ el.composerMsg.hidden = !text;
288
+ }
289
+
290
+ function renderChips() {
291
+ el.chips.replaceChildren();
292
+ el.chips.hidden = !st.pendingFiles.length;
293
+ for (const f of st.pendingFiles) {
294
+ const chip = make('span', 'ask-chip');
295
+ if (f.attKind === 'image' && f.dataBase64) {
296
+ // #398: composer thumbnail straight from the bytes just read — no
297
+ // object-URL lifecycle to manage, the chip owns its data URI.
298
+ const img = doc.createElement('img');
299
+ img.className = 'ask-chip-thumb';
300
+ img.alt = f.name;
301
+ img.src = `data:${f.mime};base64,${f.dataBase64}`;
302
+ chip.appendChild(img);
303
+ }
304
+ chip.appendChild(make('span', 'ask-chip-name', f.name));
305
+ const x = make('button', 'ask-chip-x', '×');
306
+ x.type = 'button';
307
+ x.setAttribute('aria-label', `Remove ${f.name}`);
308
+ x.addEventListener('click', () => {
309
+ st.pendingFiles = st.pendingFiles.filter((p) => p !== f);
310
+ renderChips();
311
+ });
312
+ chip.appendChild(x);
313
+ el.chips.appendChild(chip);
314
+ }
315
+ }
316
+
317
+ async function addFiles(fileList) {
318
+ for (const f of [...(fileList || [])]) {
319
+ const name = String(f.name || '');
320
+ const dot = name.lastIndexOf('.');
321
+ const ext = dot >= 0 ? name.slice(dot).toLowerCase() : '';
322
+ const binMime = ASK_ATTACH_BINARY[ext];
323
+ if (!ASK_ATTACH_EXT.includes(ext) && !binMime) { setComposerMsg(`attachment type not allowed: ${name}`); continue; }
324
+ const cap = binMime ? ASK_MAX_BINARY_BYTES : ASK_MAX_TEXT_BYTES;
325
+ if (f.size > cap) { setComposerMsg(`attachment over ${cap} bytes: ${name}`); continue; }
326
+ const others = st.pendingFiles.filter((p) => p.name !== name); // dedupe by name, newest wins
327
+ if (others.length >= 8) { setComposerMsg('at most 8 attachments per message'); continue; }
328
+ const serverBytes = st.model ? st.model.attachmentsBytes() : 0;
329
+ const pendingBytes = others.reduce((n, p) => n + p.bytes, 0);
330
+ if (serverBytes + pendingBytes + f.size > ASK_MAX_THREAD_BYTES) { setComposerMsg('attachment budget for this thread exceeded'); continue; }
331
+ let dataBase64 = '';
332
+ try {
333
+ dataBase64 = bytesToBase64(new Uint8Array(await f.arrayBuffer()));
334
+ } catch { setComposerMsg(`could not read ${name}`); continue; }
335
+ const attKind = binMime ? (binMime.startsWith('image/') ? 'image' : 'binary') : 'text';
336
+ st.pendingFiles = [...others, { name, bytes: f.size, dataBase64, attKind, mime: binMime || null }];
337
+ }
338
+ renderChips();
339
+ }
340
+
341
+ function updateSendStop() {
342
+ if (!el.send) return;
343
+ const streaming = !!(st.model && st.model.live());
344
+ el.send.hidden = streaming;
345
+ el.stop.hidden = !streaming;
346
+ }
347
+
348
+ function updateMeters() {
349
+ if (!el.meterTokens) return;
350
+ const totals = st.model ? st.model.totals() : { live: null };
351
+ // Context fill: the streaming call's figure while live, else the last turn's.
352
+ // A thread with turns but no ctx predates the metric — show nothing, never a fake 0.
353
+ const liveCtx = totals.live && totals.live.usage ? totals.live.usage.ctx : null;
354
+ const ctx = Number.isFinite(liveCtx) ? liveCtx : totals.ctx;
355
+ el.meterTokens.textContent = fmtCtx(ctx) || ((totals.turns || 0) > 0 ? '' : '0 ctx');
356
+ // Cost: the stored thread total; while a turn streams, "≈" + that total plus
357
+ // this turn's live figure — the CLI's once its result landed, else the
358
+ // display-only list-price estimate the ask-usage frame carries. ask-done
359
+ // nulls `live` and replaces the totals in one frame, so the authoritative
360
+ // figure takes over with no special case. No figure at all → empty cell,
361
+ // never a fabricated $0.00 (P3-F5).
362
+ const lv = totals.live;
363
+ const liveCost = lv ? (Number.isFinite(lv.costUsd) ? lv.costUsd : (Number.isFinite(lv.estimatedCostUsd) ? lv.estimatedCostUsd : null)) : null;
364
+ if (liveCost != null) el.meterCost.textContent = `≈${fmtUsd((Number.isFinite(totals.costUsd) ? totals.costUsd : 0) + liveCost)}`;
365
+ else el.meterCost.textContent = totals.costUsd == null ? '' : (fmtUsd(totals.costUsd) || '');
366
+ el.agentsBtnLabel.textContent = fmtAgents(totals.agents) || '0 agents'; // totals().agents already includes the live row's agents
367
+ }
368
+
369
+ function stopTurn() {
370
+ if (!st.threadId) return;
371
+ Promise.resolve()
372
+ .then(() => fetch(`/api/ask/threads/${st.threadId}/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }))
373
+ .catch(() => { /* the turn will end via its own frames */ });
374
+ }
375
+
376
+ async function sendMessage() {
377
+ if (st.sending || st.destroyed) return;
378
+ if (st.model && st.model.live()) return; // a turn is streaming — the stop button is showing
379
+ const text = el.input.value.trim();
380
+ if (!text) return;
381
+ st.sending = true;
382
+ setComposerMsg(null);
383
+ try {
384
+ let id = st.threadId;
385
+ if (!id) {
386
+ let r = null;
387
+ try {
388
+ r = await fetch('/api/ask/threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
389
+ } catch { r = null; }
390
+ if (!r || (r.status !== 201 && !r.ok)) { setComposerMsg('could not create the thread'); return; }
391
+ const body = await r.json();
392
+ id = body.thread.id;
393
+ loadGen += 1; // a pending loadThread() must not replace this fresh model
394
+ st.threadId = id;
395
+ st.model = createThreadModel({ threadId: id });
396
+ st.model.load({ thread: body.thread, messages: [], attachments: [], runLinks: [], inFlight: null });
397
+ renderTranscript();
398
+ storeThread(id);
399
+ }
400
+ const payload = {
401
+ text,
402
+ model: st.picker.model,
403
+ effort: st.picker.effort,
404
+ context: scopedContext(getPageContext() || {}),
405
+ ...(st.pendingFiles.length ? { attachments: st.pendingFiles.map((f) => ({ name: f.name, dataBase64: f.dataBase64 })) } : {}),
406
+ };
407
+ const model = st.model;
408
+ let res = null;
409
+ try {
410
+ res = await fetch(`/api/ask/threads/${id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
411
+ } catch { setComposerMsg('network error — the message was not sent'); return; }
412
+ // The user may have clicked New chat or switched threads during the POST: the
413
+ // message is on the server and arrives with its thread; touching the composer
414
+ // or the (now different or null) model here would be wrong (review of PR #376).
415
+ if (st.destroyed || st.model !== model || st.threadId !== id) return;
416
+ if (!res || res.status !== 202) {
417
+ let msg = `request failed (${res ? res.status : 'network'})`;
418
+ try { const b = await res.json(); if (b && b.error) msg = b.error; } catch { /* keep the fallback */ }
419
+ setComposerMsg(msg);
420
+ return;
421
+ }
422
+ const { userMessageId, attachments: stored } = await res.json();
423
+ // Prefer the server's rows: they carry the store-minted ids that key the
424
+ // image thumbnail (#398) and the thread's attachment ledger. The pending
425
+ // files are the fallback for a server that predates the field.
426
+ const echoAtts = Array.isArray(stored)
427
+ ? stored.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind ?? 'text', mime: a.mime ?? null }))
428
+ : st.pendingFiles.map((f) => ({ name: f.name, bytes: f.bytes, attKind: f.attKind, mime: f.mime }));
429
+ st.model.noteLocalUserMessage({ id: userMessageId, text, attachments: echoAtts });
430
+ // No provisional title from the prompt: the header keeps "Ask Worca" until
431
+ // the ask-title frame lands (ask-model marks title dirty, flushExtra repaints).
432
+ el.input.value = '';
433
+ st.pendingFiles = [];
434
+ renderChips();
435
+ subscribe(id);
436
+ st.pinned = true;
437
+ scheduleFlush();
438
+ } finally {
439
+ st.sending = false;
440
+ updateSendStop();
441
+ }
442
+ }
443
+
444
+ function buildComposer() {
445
+ const wrap = make('div', 'ask-composer');
446
+
447
+ el.chips = make('div', 'ask-chips');
448
+ el.chips.hidden = true;
449
+ wrap.appendChild(el.chips);
450
+
451
+ el.input = doc.createElement('textarea');
452
+ el.input.className = 'ask-input';
453
+ el.input.rows = 1;
454
+ el.input.placeholder = 'Ask about any run, agent, or project…';
455
+ el.input.addEventListener('keydown', (e) => {
456
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
457
+ });
458
+ el.input.addEventListener('input', () => {
459
+ el.input.style.height = 'auto';
460
+ el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
461
+ });
462
+ wrap.appendChild(el.input);
463
+
464
+ el.composerMsg = make('div', 'ask-composer-msg');
465
+ el.composerMsg.hidden = true;
466
+ wrap.appendChild(el.composerMsg);
467
+
468
+ const row = make('div', 'ask-composer-row');
469
+
470
+ el.fileInput = doc.createElement('input');
471
+ el.fileInput.type = 'file';
472
+ el.fileInput.multiple = true;
473
+ el.fileInput.accept = `${ASK_ATTACH_EXT.join(',')},${Object.keys(ASK_ATTACH_BINARY).join(',')},text/*`;
474
+ el.fileInput.hidden = true;
475
+ el.fileInput.addEventListener('change', () => { addFiles(el.fileInput.files); el.fileInput.value = ''; });
476
+ row.appendChild(el.fileInput);
477
+ const attach = iconButton('ask-icon-btn', 'Attach files', ICONS.plus, () => el.fileInput.click());
478
+ attach.setAttribute('data-ask-attach-btn', '');
479
+ row.appendChild(attach);
480
+
481
+ // #397: the scope selector — which project/workspace this chat is about,
482
+ // independent of the page behind the sheet. It sits right after "+"
483
+ // (attach → scope → spacer → meter …): the pill keeps its width (style.css
484
+ // .ask-scope-btn flex:none), the spacer absorbs the slack. Its popover
485
+ // (.ask-pop-scope) opens upward from the sheet's bottom-left.
486
+ const scopeBtn = make('button', 'ask-scope-btn');
487
+ scopeBtn.type = 'button';
488
+ scopeBtn.setAttribute('data-ask-scope-btn', '');
489
+ scopeBtn.title = 'Project scope for this chat';
490
+ el.scopeLabel = make('span', 'ask-scope-label', 'Auto');
491
+ scopeBtn.appendChild(el.scopeLabel);
492
+ scopeBtn.appendChild(svgIcon(ICONS.chevronDown, 11, 2));
493
+ scopeBtn.addEventListener('click', () => openScopePopover(scopeBtn));
494
+ el.scopeBtn = scopeBtn;
495
+ row.appendChild(scopeBtn);
496
+
497
+ row.appendChild(make('span', 'ask-composer-spacer'));
498
+
499
+ const meter = make('span', 'ask-meter');
500
+ meter.setAttribute('data-ask-meter', '');
501
+ el.meterTokens = make('span', 'ask-meter-tokens', '0 ctx');
502
+ meter.appendChild(el.meterTokens);
503
+ meter.appendChild(make('span', 'ask-meter-sep', '|'));
504
+ el.meterCost = make('span', 'ask-meter-cost', '');
505
+ meter.appendChild(el.meterCost);
506
+ meter.appendChild(make('span', 'ask-meter-sep', '|'));
507
+ row.appendChild(meter);
508
+
509
+ const wtBtn = make('button', 'ask-agents-btn ask-wt-btn');
510
+ wtBtn.type = 'button';
511
+ wtBtn.setAttribute('data-ask-wt-btn', '');
512
+ wtBtn.hidden = true;
513
+ el.wtBtn = wtBtn;
514
+ el.wtBtnLabel = make('span', null, '0 worktrees');
515
+ wtBtn.appendChild(el.wtBtnLabel);
516
+ wtBtn.appendChild(svgIcon('M6 15l6-6 6 6', 11, 2));
517
+ wtBtn.addEventListener('click', () => openWorktreesPopover(wtBtn));
518
+ row.appendChild(wtBtn);
519
+
520
+ const agentsBtn = make('button', 'ask-agents-btn');
521
+ agentsBtn.type = 'button';
522
+ agentsBtn.setAttribute('data-ask-agents-btn', '');
523
+ el.agentsBtnLabel = make('span', null, '0 agents');
524
+ agentsBtn.appendChild(el.agentsBtnLabel);
525
+ agentsBtn.appendChild(svgIcon('M6 15l6-6 6 6', 11, 2));
526
+ agentsBtn.addEventListener('click', () => openRunInfoPopover(agentsBtn));
527
+ row.appendChild(agentsBtn);
528
+
529
+ const modelBtn = make('button', 'ask-model-btn');
530
+ modelBtn.type = 'button';
531
+ modelBtn.setAttribute('data-ask-model-btn', '');
532
+ el.modelBtnLabel = make('span', 'ask-model-btn-label', st.picker.model);
533
+ el.modelBtnEffort = make('span', 'ask-model-btn-effort', st.picker.effort);
534
+ modelBtn.appendChild(el.modelBtnLabel);
535
+ modelBtn.appendChild(el.modelBtnEffort);
536
+ modelBtn.appendChild(svgIcon(ICONS.chevronDown, 12, 2));
537
+ modelBtn.addEventListener('click', () => openModelPopover(modelBtn));
538
+ row.appendChild(modelBtn);
539
+
540
+ el.send = make('button', 'ask-send');
541
+ el.send.type = 'button';
542
+ el.send.setAttribute('data-ask-send', '');
543
+ el.send.setAttribute('aria-label', 'Send');
544
+ el.send.appendChild(svgIcon(ICONS.send, 15, 2.2));
545
+ el.send.addEventListener('click', sendMessage);
546
+ row.appendChild(el.send);
547
+
548
+ el.stop = make('button', 'ask-stop');
549
+ el.stop.type = 'button';
550
+ el.stop.setAttribute('data-ask-stop', '');
551
+ el.stop.setAttribute('aria-label', 'Stop');
552
+ el.stop.hidden = true;
553
+ const stopRect = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
554
+ stopRect.setAttribute('width', '10');
555
+ stopRect.setAttribute('height', '10');
556
+ stopRect.setAttribute('viewBox', '0 0 24 24');
557
+ stopRect.setAttribute('fill', 'currentColor');
558
+ stopRect.setAttribute('aria-hidden', 'true');
559
+ const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
560
+ rect.setAttribute('x', '6'); rect.setAttribute('y', '6');
561
+ rect.setAttribute('width', '12'); rect.setAttribute('height', '12');
562
+ rect.setAttribute('rx', '2');
563
+ stopRect.appendChild(rect);
564
+ el.stop.appendChild(stopRect);
565
+ el.stop.addEventListener('click', stopTurn);
566
+ row.appendChild(el.stop);
567
+
568
+ wrap.appendChild(row);
569
+ return wrap;
570
+ }
571
+
572
+ function announce(text) { el.live.textContent = text; }
573
+
574
+ function focusComposer() {
575
+ try { el.input.focus({ preventScroll: true }); } catch { try { el.input.focus(); } catch { /* detached */ } }
576
+ }
577
+
578
+ function openSheet() {
579
+ if (st.open || st.destroyed) return;
580
+ st.open = true;
581
+ st.prevFocus = doc.activeElement;
582
+ el.pill.hidden = true;
583
+ el.sheet.hidden = false;
584
+ st.pinned = true;
585
+ ensureFirstOpen();
586
+ focusComposer();
587
+ scheduleFlush();
588
+ }
589
+
590
+ /**
591
+ * Append a plain-text reference to the composer WITHOUT sending, so several can
592
+ * stack and the user presses send once. Opens the sheet if it is closed and
593
+ * focuses the composer either way. Returns false when there is nothing to add.
594
+ */
595
+ function appendToComposer(text) {
596
+ const add = String(text ?? '').trim();
597
+ if (!add || st.destroyed) return false;
598
+ openSheet(); // no-op when already open…
599
+ const cur = el.input.value;
600
+ el.input.value = cur ? `${cur.replace(/\s*$/, '')}\n${add}` : add;
601
+ // …so the autosize listener and the focus have to be driven here.
602
+ el.input.dispatchEvent(new win.Event('input'));
603
+ focusComposer();
604
+ try { el.input.selectionStart = el.input.selectionEnd = el.input.value.length; } catch { /* jsdom */ }
605
+ return true;
606
+ }
607
+
608
+ function closeSheet() {
609
+ if (!st.open) return;
610
+ closePopover({ focusTrigger: false });
611
+ st.open = false;
612
+ el.sheet.hidden = true;
613
+ el.pill.hidden = false;
614
+ const prev = st.prevFocus;
615
+ st.prevFocus = null;
616
+ if (prev && prev.isConnected && typeof prev.focus === 'function') { try { prev.focus(); return; } catch { /* fall through */ } }
617
+ try { el.pill.focus(); } catch { /* ignore */ }
618
+ }
619
+
620
+ function toggleSheet() { (st.open ? closeSheet : openSheet)(); }
621
+
622
+ // ---- keyboard + pointer routing ------------------------------------------
623
+ function containsNode(rootEl, t) { return !!(t && t.nodeType && rootEl.contains(t)); }
624
+
625
+ function ownsKey(e) {
626
+ return e.key === 'Escape' && st.open
627
+ && (containsNode(root, e.target) || containsNode(root, doc.activeElement));
628
+ }
629
+
630
+ function isToggleCombo(e) {
631
+ return (e.metaKey || e.ctrlKey) && !e.altKey && typeof e.key === 'string' && e.key.toLowerCase() === 'k';
632
+ }
633
+
634
+ function onDocKeydown(e) {
635
+ if (st.destroyed) return;
636
+ if (isToggleCombo(e)) {
637
+ if (e.repeat || e.isComposing) return;
638
+ e.preventDefault();
639
+ toggleSheet();
640
+ return;
641
+ }
642
+ if (e.key === 'Escape' && ownsKey(e) && st.popover) closePopover({ focusTrigger: true });
643
+ // Escape with nothing open is an owned no-op — app.js's handlers already
644
+ // returned via ownsKey(); the sheet itself never closes on Escape (§10.4).
645
+ }
646
+
647
+ function onDocPointerdown(e) {
648
+ if (st.destroyed || !st.open) return;
649
+ const t = e.target;
650
+ if (!t || typeof t.closest !== 'function') return;
651
+ if (t.closest('[data-ask-sheet]')) {
652
+ if (st.popover && !st.popover.panel.contains(t) && !st.popover.trigger.contains(t)) {
653
+ closePopover({ focusTrigger: false });
654
+ }
655
+ return;
656
+ }
657
+ // `.hd-cmt-card` joins the allowlist: its "Ask Worca" button appends to the
658
+ // composer, and pointerdown lands BEFORE the click that would open the sheet.
659
+ if (t.closest('.viewer-modal, #confirm-modal, .info-bubble, .mention-popup, .hd-cmt-card')) return;
660
+ closeSheet();
661
+ }
662
+
663
+ // ---- popover primitive (spec §10.6 .ask-pop) ------------------------------
664
+ function closePopover({ focusTrigger = true } = {}) {
665
+ const p = st.popover;
666
+ if (!p) return;
667
+ st.popover = null;
668
+ p.panel.remove();
669
+ if (p.onClose) { try { p.onClose(); } catch { /* ignore */ } }
670
+ if (focusTrigger) { try { p.trigger.focus(); } catch { /* ignore */ } }
671
+ }
672
+
673
+ function menuItems(panel) { return [...panel.querySelectorAll('[role="menuitem"]:not([disabled])')]; }
674
+
675
+ function onPopKeydown(e) {
676
+ const p = st.popover;
677
+ if (!p) return;
678
+ const items = menuItems(p.panel);
679
+ if (!items.length) return;
680
+ const idx = items.indexOf(doc.activeElement);
681
+ const go = (i) => { const item = items[(i + items.length) % items.length]; item.tabIndex = 0; try { item.focus(); } catch { /* ignore */ } };
682
+ if (e.key === 'ArrowDown') { e.preventDefault(); go(idx + 1); }
683
+ else if (e.key === 'ArrowUp') { e.preventDefault(); go(idx - 1); }
684
+ else if (e.key === 'Home') { e.preventDefault(); go(0); }
685
+ else if (e.key === 'End') { e.preventDefault(); go(items.length - 1); }
686
+ else if ((e.key === 'Enter' || e.key === ' ') && idx >= 0) { e.preventDefault(); items[idx].click(); }
687
+ }
688
+
689
+ function openPopover({ panelClass, trigger, build, onClose, refreshOn }) {
690
+ if (st.popover && st.popover.trigger === trigger) { closePopover({ focusTrigger: false }); return null; }
691
+ closePopover({ focusTrigger: false });
692
+ const panel = make('div', `ask-pop ${panelClass}`);
693
+ panel.setAttribute('role', 'menu');
694
+ panel.addEventListener('keydown', onPopKeydown);
695
+ build(panel);
696
+ el.sheet.appendChild(panel);
697
+ // refreshOn(dirty) → true re-runs build() on that flush (flushExtra), so an
698
+ // OPEN popover follows the live meters / worktrees instead of freezing at open.
699
+ st.popover = { panel, trigger, onClose: onClose || null, build, refreshOn: refreshOn || null };
700
+ const first = menuItems(panel)[0];
701
+ if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
702
+ return panel;
703
+ }
704
+
705
+ function menuItem(className, onPick) {
706
+ const b = make('button', `ask-pop-item ${className}`.trim());
707
+ b.type = 'button';
708
+ b.setAttribute('role', 'menuitem');
709
+ b.tabIndex = -1;
710
+ if (onPick) b.addEventListener('click', onPick);
711
+ return b;
712
+ }
713
+
714
+ // ---- threads popover (list; switching/delete land in Task 7) -------------
715
+ // The start date leads the meter line, bold and on the primary ink, so the eye
716
+ // scans it down the list while the cost/agent figures keep the meter's grey.
717
+ // Hence an element rather than a string: only the date changes weight and
718
+ // colour. An unusable createdAt drops the span and its separator with it.
719
+ function threadMeter(t) {
720
+ const meter = make('span', 'ask-thread-meter');
721
+ const when = fmtStarted(t.createdAt, now());
722
+ const rest = [fmtCtx(t.totals && t.totals.ctx), fmtUsd(t.totals && t.totals.costUsd), fmtAgents(t.totals && t.totals.agents)]
723
+ .filter(Boolean).join(' · ');
724
+ if (when) meter.appendChild(make('span', 'ask-thread-when', when));
725
+ if (rest) meter.appendChild(doc.createTextNode(when ? ` · ${rest}` : rest));
726
+ return meter;
727
+ }
728
+
729
+ /** History meter: "12 chats" / "1 chat" / '' at 0 (same empty-string convention as the agents meter). */
730
+ function fmtChats(n) {
731
+ return Number.isFinite(n) && n > 0 ? `${n} chat${n === 1 ? '' : 's'}` : '';
732
+ }
733
+
734
+ function toggleThreadsPopover(trigger) {
735
+ let meter = null;
736
+ const panel = openPopover({
737
+ panelClass: 'ask-pop-threads',
738
+ trigger,
739
+ build: (p) => {
740
+ // Same caption-row pattern as the agents popover: the row paints at once,
741
+ // the meter fills once the list lands (the popover opens synchronously).
742
+ const head = make('div', 'ask-pop-caption-row');
743
+ head.appendChild(make('span', 'ask-pop-caption', 'History'));
744
+ meter = make('span', 'ask-pop-caption-meter', '');
745
+ head.appendChild(meter);
746
+ p.appendChild(head);
747
+ },
748
+ });
749
+ if (!panel) return;
750
+ Promise.resolve()
751
+ .then(() => fetch('/api/ask/threads?limit=50'))
752
+ .then((r) => (r && r.ok ? r.json() : { threads: [] }))
753
+ .catch(() => ({ threads: [] }))
754
+ .then(({ threads, total }) => {
755
+ if (st.popover === null || st.popover.panel !== panel) return; // closed meanwhile
756
+ const rows = Array.isArray(threads) ? threads : [];
757
+ // `total` is EVERY saved chat (the route caps rows at limit); an older
758
+ // server without it degrades to the page size.
759
+ if (meter) meter.textContent = fmtChats(Number.isInteger(total) && total >= 0 ? total : rows.length);
760
+ renderThreadRows(panel, rows);
761
+ });
762
+ }
763
+
764
+ function renderThreadRows(panel, threads) {
765
+ if (!threads.length) {
766
+ panel.appendChild(make('div', 'ask-pop-empty', 'No saved chats.'));
767
+ return;
768
+ }
769
+ // The rows scroll inside a capped list so 50 threads cannot run past the
770
+ // sheet; the caption stays a direct child of the panel, hence pinned. The
771
+ // list itself stays inside the panel: menuItems() reads the whole panel.
772
+ const list = make('div', 'ask-threads-list');
773
+ for (const t of threads) {
774
+ const row = make('div', 'ask-thread-row');
775
+ const pick = menuItem('ask-thread-pick', () => { closePopover({ focusTrigger: false }); switchThread(t.id); });
776
+ // The dot leads the row, sitting against the title where it reads as "this
777
+ // chat is live" -- .ask-thread-dot collapses it (display:none) unless the
778
+ // live arm joins it, so an idle row leaves no empty gutter and its title
779
+ // starts at the left edge. The date rides the meter line under the title.
780
+ pick.appendChild(make('span', `ask-dot ask-thread-dot${t.inFlight ? ' ask-dot-live' : ''}`));
781
+ const col = make('span', 'ask-thread-col');
782
+ // A null title = the haiku title has not landed yet (the message route
783
+ // stamps nothing); "New chat" is the same label the turn falls back to.
784
+ col.appendChild(make('span', 'ask-thread-title', t.title || 'New chat'));
785
+ col.appendChild(threadMeter(t));
786
+ pick.appendChild(col);
787
+ row.appendChild(pick);
788
+ row.appendChild(buildThreadTrash(t));
789
+ list.appendChild(row);
790
+ }
791
+ panel.appendChild(list);
792
+ const first = menuItems(panel)[0];
793
+ if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
794
+ }
795
+
796
+ // ---- catalog + picker (D8) ------------------------------------------------
797
+ function catalogEntry(id) { return st.catalog ? st.catalog.models.find((m) => m && m.id === id) || null : null; }
798
+
799
+ function updatePickerButton() {
800
+ if (!el.modelBtnLabel) return;
801
+ const entry = catalogEntry(st.picker.model);
802
+ // Same '⚠' marker the run-graph node label uses (ui/public/app.js:998).
803
+ const flagged = !!entry && (entry.costUnreliable === true
804
+ || (Array.isArray(entry.secretsMissing) && entry.secretsMissing.length > 0));
805
+ el.modelBtnLabel.textContent = (entry ? entry.label : st.picker.model) + (flagged ? ' ⚠' : '');
806
+ el.modelBtnEffort.textContent = st.picker.effort;
807
+ }
808
+
809
+ function coerceEffort(entry, effort) {
810
+ if (!entry || !Array.isArray(entry.efforts) || entry.efforts.includes(effort)) return effort;
811
+ return entry.efforts.includes('high') ? 'high' : entry.efforts[0];
812
+ }
813
+
814
+ /** The backend's D8 default, or the cold-start literal for a payload without one. */
815
+ function catalogDefault() {
816
+ const d = st.catalog && st.catalog.default;
817
+ if (d && typeof d.model === 'string' && typeof d.effort === 'string') return { model: d.model, effort: d.effort };
818
+ return { ...FALLBACK_PICK };
819
+ }
820
+
821
+ function applyCatalogToPicker() {
822
+ const fallback = catalogDefault();
823
+ // Each slot is decided by its own provenance: a stored MODEL outranks the backend
824
+ // default, and a stored EFFORT survives even when the model comes from the default.
825
+ // (effortFromStore ⊇ pickerFromStore — a stored model always carries its effort.)
826
+ const wanted = {
827
+ model: st.pickerFromStore ? st.picker.model : fallback.model,
828
+ effort: st.effortFromStore ? st.picker.effort : fallback.effort,
829
+ };
830
+ const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
831
+ const wantedEntry = catalogEntry(wanted.model);
832
+ // Unknown stored/default id -> the backend default -> the first model we do have.
833
+ const entry = wantedEntry || catalogEntry(fallback.model) || list[0] || null;
834
+ if (!entry) { updatePickerButton(); return; } // empty catalog: keep what we have
835
+ const effort = wantedEntry ? wanted.effort : fallback.effort;
836
+ const next = { model: entry.id, effort: coerceEffort(entry, effort) };
837
+ const changed = next.model !== st.picker.model || next.effort !== st.picker.effort;
838
+ st.picker = next;
839
+ // D11: persist ONLY a repair of a pick the user actually made. Writing the
840
+ // backend default here would make it authoritative exactly once, ever.
841
+ if (changed && st.pickerFromStore) storeModel();
842
+ updatePickerButton();
843
+ }
844
+
845
+ function loadCatalog() {
846
+ if (st.catalog) return Promise.resolve(st.catalog);
847
+ if (st.catalogLoading) return st.catalogLoading;
848
+ st.catalogLoading = Promise.resolve()
849
+ .then(() => fetch('/api/ask/models'))
850
+ .then((r) => (r && r.ok ? r.json() : null))
851
+ .catch(() => null)
852
+ .then((body) => {
853
+ st.catalogLoading = null;
854
+ if (body && Array.isArray(body.models)) { st.catalog = body; applyCatalogToPicker(); }
855
+ return st.catalog;
856
+ });
857
+ return st.catalogLoading;
858
+ }
859
+
860
+ function ensureFirstOpen() {
861
+ if (st.firstOpenDone) return;
862
+ st.firstOpenDone = true;
863
+ loadCatalog();
864
+ const stored = readStoredThread();
865
+ if (stored && !st.threadId) switchThread(stored);
866
+ }
867
+
868
+ /**
869
+ * Primary-list grouping key. Claude ids group by family; a PLUGIN model groups by
870
+ * its plugin, so a plugin shipping ten ids contributes one primary row and the rest
871
+ * land under "More models" — instead of each foreign id becoming its own "family"
872
+ * (the old `|| m.id` fallback) and flooding the list. Anything else shares one
873
+ * 'other' bucket.
874
+ */
875
+ function familyKey(m) {
876
+ const fam = (m.id.match(/^claude-(opus|fable|sonnet|haiku)-/) || [])[1];
877
+ if (fam) return `claude:${fam}`;
878
+ if (m.custom === 'plugin' && m.plugin) return `plugin:${m.plugin}`;
879
+ return 'other';
880
+ }
881
+
882
+ function splitCatalog() {
883
+ const primary = [];
884
+ const rest = [];
885
+ const seen = new Set();
886
+ for (const m of st.catalog ? st.catalog.models : []) {
887
+ if (!m || typeof m.id !== 'string') continue;
888
+ if (m.custom === 'global') { primary.push(m); continue; } // user models are never demoted
889
+ const fam = familyKey(m);
890
+ // The picked model always shows up front so its ✓ is visible and it is one click away.
891
+ if (seen.has(fam) && m.id !== st.picker.model) rest.push(m);
892
+ else { seen.add(fam); primary.push(m); }
893
+ }
894
+ return { primary, rest };
895
+ }
896
+
897
+ function setPickerModel(id) {
898
+ st.picker = { model: id, effort: coerceEffort(catalogEntry(id), st.picker.effort) };
899
+ st.pickerFromStore = true; // an explicit model choice claims the slot (D11)
900
+ st.effortFromStore = true;
901
+ storeModel();
902
+ updatePickerButton();
903
+ closePopover({ focusTrigger: false });
904
+ focusComposer();
905
+ }
906
+
907
+ function setPickerEffort(effort) {
908
+ st.picker = { ...st.picker, effort };
909
+ st.effortFromStore = true; // the effort only — the model slot is untouched (D11)
910
+ storeModel();
911
+ updatePickerButton();
912
+ closePopover({ focusTrigger: false });
913
+ focusComposer();
914
+ }
915
+
916
+ function openModelPopover(trigger) {
917
+ const panel = openPopover({ panelClass: 'ask-pop-model', trigger, build: () => {} });
918
+ if (!panel) return;
919
+ const focusFirst = () => { const f = menuItems(panel)[0]; if (f) { f.tabIndex = 0; try { f.focus(); } catch { /* ignore */ } } };
920
+ const tag = (text, variant, title) => {
921
+ const t = make('span', variant ? `ask-model-tag ${variant}` : 'ask-model-tag', text);
922
+ if (title) t.title = title;
923
+ return t;
924
+ };
925
+ const modelItem = (m) => {
926
+ const item = menuItem('ask-model-item', () => setPickerModel(m.id));
927
+ // The row carries NO provenance: a plugin name is arbitrary text and an origin
928
+ // badge starved the label in a 292px panel. Where a model comes from is the
929
+ // Models view's job; here the name is the thing being picked.
930
+ item.appendChild(make('span', 'ask-model-name', m.label || m.id));
931
+ // The two STATUS badges stay (models-view.mjs:116,120-123) — they are warnings, not provenance.
932
+ if (m.costUnreliable) {
933
+ item.appendChild(tag('⚠cost', 'is-warn',
934
+ 'This model reported no cost while consuming tokens — chat spend may not count toward the budget.'));
935
+ }
936
+ if (Array.isArray(m.secretsMissing) && m.secretsMissing.length) {
937
+ item.appendChild(tag('secret not set', 'is-err',
938
+ `${m.secretsMissing.join(', ')} is not set — configure it in the ${m.plugin ? `“${m.plugin}” ` : ''}plugin's Model secrets, or this model will fail.`));
939
+ }
940
+ if (m.id === st.picker.model) item.appendChild(make('span', 'ask-model-check', '✓'));
941
+ return item;
942
+ };
943
+ const renderPane = (pane) => {
944
+ panel.replaceChildren();
945
+ if (pane === 'effort') {
946
+ const back = menuItem('ask-pane-back', () => renderPane('main'));
947
+ back.setAttribute('data-ask-pane-back', '');
948
+ back.appendChild(make('span', null, '‹ Effort'));
949
+ panel.appendChild(back);
950
+ panel.appendChild(make('div', 'ask-pop-divider'));
951
+ const entry = catalogEntry(st.picker.model);
952
+ for (const eff of entry && Array.isArray(entry.efforts) ? entry.efforts : ['medium', 'high', 'xhigh', 'max']) {
953
+ const item = menuItem('ask-effort-item', () => setPickerEffort(eff));
954
+ item.appendChild(make('span', 'ask-model-name', eff));
955
+ if (eff === st.picker.effort) item.appendChild(make('span', 'ask-model-check', '✓'));
956
+ panel.appendChild(item);
957
+ }
958
+ } else if (pane === 'more') {
959
+ const back = menuItem('ask-pane-back', () => renderPane('main'));
960
+ back.setAttribute('data-ask-pane-back', '');
961
+ back.appendChild(make('span', null, '‹ Models'));
962
+ panel.appendChild(back);
963
+ panel.appendChild(make('div', 'ask-pop-divider'));
964
+ for (const m of splitCatalog().rest) panel.appendChild(modelItem(m));
965
+ } else {
966
+ const { primary, rest } = splitCatalog();
967
+ for (const m of primary) panel.appendChild(modelItem(m));
968
+ panel.appendChild(make('div', 'ask-pop-divider'));
969
+ const effortRow = menuItem('ask-effort-row', () => renderPane('effort'));
970
+ effortRow.setAttribute('data-ask-effort-row', '');
971
+ effortRow.appendChild(make('span', null, 'Effort'));
972
+ effortRow.appendChild(make('span', 'ask-pop-row-value', st.picker.effort));
973
+ effortRow.appendChild(make('span', 'ask-pop-row-chev', '›'));
974
+ panel.appendChild(effortRow);
975
+ if (rest.length) {
976
+ const moreRow = menuItem('ask-more-models', () => renderPane('more'));
977
+ moreRow.setAttribute('data-ask-more-models', '');
978
+ moreRow.appendChild(make('span', null, 'More models'));
979
+ moreRow.appendChild(make('span', 'ask-pop-row-chev', '›'));
980
+ panel.appendChild(moreRow);
981
+ }
982
+ }
983
+ focusFirst();
984
+ };
985
+ loadCatalog().then(() => { if (st.popover && st.popover.panel === panel) renderPane('main'); });
986
+ renderPane('main');
987
+ }
988
+
989
+ // ---- scope selector (#397) ------------------------------------------------
990
+ /** Per-field merge: the pinned scope replaces the page context's TARGET keys;
991
+ * view/run/diff-file context still follow the page. Auto sends pinned:false so
992
+ * the server never resurrects a stale thread pin over an explicit choice. */
993
+ function scopedContext(page) {
994
+ const ctx = { ...page };
995
+ if (!st.scope.pinned) return { ...ctx, pinned: false };
996
+ delete ctx.projectDir;
997
+ delete ctx.projectKey;
998
+ delete ctx.workspaceId;
999
+ ctx.pinned = true;
1000
+ if (st.scope.projectKey) ctx.projectKey = st.scope.projectKey;
1001
+ else if (st.scope.workspaceId) ctx.workspaceId = st.scope.workspaceId;
1002
+ return ctx;
1003
+ }
1004
+
1005
+ function updateScopeButton() {
1006
+ if (!el.scopeLabel) return;
1007
+ el.scopeLabel.textContent = st.scope.pinned
1008
+ ? (st.scope.label || st.scope.projectKey || st.scope.workspaceId || 'Pinned')
1009
+ : 'Auto';
1010
+ el.scopeBtn.classList.toggle('is-pinned', st.scope.pinned);
1011
+ }
1012
+
1013
+ function setScope(next) {
1014
+ st.scope = {
1015
+ pinned: !!next.pinned,
1016
+ projectKey: next.projectKey || null,
1017
+ workspaceId: next.workspaceId || null,
1018
+ label: next.label || null,
1019
+ };
1020
+ updateScopeButton();
1021
+ closePopover({ focusTrigger: false });
1022
+ focusComposer();
1023
+ // Persist on the thread so the pin survives reload with no message sent. A
1024
+ // brand-new chat has no row yet — the first message's context (pinned:true)
1025
+ // persists it then instead.
1026
+ if (!st.threadId) return;
1027
+ const scope = st.scope.pinned
1028
+ ? (st.scope.projectKey ? { pinned: true, projectKey: st.scope.projectKey } : { pinned: true, workspaceId: st.scope.workspaceId })
1029
+ : { pinned: false };
1030
+ Promise.resolve()
1031
+ .then(() => fetch(`/api/ask/threads/${st.threadId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }) }))
1032
+ .catch(() => { /* the next message carries the scope in its context anyway */ });
1033
+ }
1034
+
1035
+ /** Restore the selector from a stored thread context (loadThread / reopen). */
1036
+ function applyThreadScope(context) {
1037
+ const c = context && typeof context === 'object' ? context : null;
1038
+ const key = c && c.pinned === true && typeof c.projectKey === 'string' && c.projectKey ? c.projectKey : null;
1039
+ const ws = c && c.pinned === true && typeof c.workspaceId === 'string' && c.workspaceId ? c.workspaceId : null;
1040
+ st.scope = key
1041
+ ? { pinned: true, projectKey: key, workspaceId: null, label: null }
1042
+ : ws
1043
+ ? { pinned: true, projectKey: null, workspaceId: ws, label: null }
1044
+ : { pinned: false, projectKey: null, workspaceId: null, label: null };
1045
+ updateScopeButton(); // the raw key shows until the name resolves
1046
+ if (st.scope.pinned) resolveScopeLabel();
1047
+ }
1048
+
1049
+ function resolveScopeLabel() {
1050
+ const want = { projectKey: st.scope.projectKey, workspaceId: st.scope.workspaceId };
1051
+ loadCardOptions().then((opts) => {
1052
+ if (st.destroyed || !st.scope.pinned) return;
1053
+ if (st.scope.projectKey !== want.projectKey || st.scope.workspaceId !== want.workspaceId) return;
1054
+ const p = want.projectKey ? opts.projects.find((x) => x && x.key === want.projectKey) : null;
1055
+ const w = want.workspaceId ? opts.workspaces.find((x) => x && x.id === want.workspaceId) : null;
1056
+ st.scope.label = (p && p.name) || (w && (w.name || w.id)) || null;
1057
+ updateScopeButton();
1058
+ });
1059
+ }
1060
+
1061
+ function openScopePopover(trigger) {
1062
+ const panel = openPopover({ panelClass: 'ask-pop-scope', trigger, build: (p) => {
1063
+ p.appendChild(make('div', 'ask-pop-caption', 'Chat scope'));
1064
+ } });
1065
+ if (!panel) return;
1066
+ loadCardOptions().then((opts) => {
1067
+ if (!st.popover || st.popover.panel !== panel) return;
1068
+ const item = (label, on, onPick) => {
1069
+ const it = menuItem('ask-scope-item', onPick);
1070
+ it.appendChild(make('span', 'ask-model-name', label));
1071
+ if (on) it.appendChild(make('span', 'ask-model-check', '✓'));
1072
+ return it;
1073
+ };
1074
+ panel.appendChild(item('Auto (follow current page)', !st.scope.pinned, () => setScope({ pinned: false })));
1075
+ const projects = opts.projects.filter((p) => p && p.key);
1076
+ if (projects.length) {
1077
+ panel.appendChild(make('div', 'ask-pop-divider'));
1078
+ panel.appendChild(make('div', 'ask-pop-caption', 'Projects'));
1079
+ for (const p of projects) {
1080
+ panel.appendChild(item(
1081
+ p.exists === false ? `${p.name} (missing)` : p.name,
1082
+ st.scope.pinned && st.scope.projectKey === p.key,
1083
+ () => setScope({ pinned: true, projectKey: p.key, label: p.name }),
1084
+ ));
1085
+ }
1086
+ }
1087
+ const workspaces = opts.workspaces.filter((w) => w && w.id);
1088
+ if (workspaces.length) {
1089
+ panel.appendChild(make('div', 'ask-pop-divider'));
1090
+ panel.appendChild(make('div', 'ask-pop-caption', 'Workspaces'));
1091
+ for (const w of workspaces) {
1092
+ panel.appendChild(item(
1093
+ w.name || w.id,
1094
+ st.scope.pinned && st.scope.workspaceId === w.id,
1095
+ () => setScope({ pinned: true, workspaceId: w.id, label: w.name || w.id }),
1096
+ ));
1097
+ }
1098
+ }
1099
+ const first = menuItems(panel)[0];
1100
+ if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
1101
+ });
1102
+ }
1103
+
1104
+ // ---- run-info popover ("Agents this chat") --------------------------------
1105
+ // ---- worktrees (P4 §10) ---------------------------------------------------
1106
+ function setWorktrees(list) {
1107
+ st.worktrees = Array.isArray(list) ? list : [];
1108
+ if (!el.wtBtn) return;
1109
+ el.wtBtn.hidden = st.worktrees.length === 0;
1110
+ el.wtBtnLabel.textContent = `${st.worktrees.length} worktree${st.worktrees.length === 1 ? '' : 's'}`;
1111
+ }
1112
+
1113
+ function refreshWorktrees() {
1114
+ if (!st.threadId) { setWorktrees([]); return Promise.resolve([]); }
1115
+ const tid = st.threadId;
1116
+ return Promise.resolve()
1117
+ .then(() => fetch(`/api/ask/threads/${tid}`))
1118
+ .then((r) => (r && r.ok ? r.json() : null))
1119
+ .catch(() => null)
1120
+ .then((snap) => {
1121
+ if (st.threadId !== tid) return st.worktrees;
1122
+ const list = snap && Array.isArray(snap.worktrees) ? snap.worktrees : [];
1123
+ // The model owns the list (ask-worktrees frames land there too) and the
1124
+ // flush repaints an open popover; the count is ALSO written synchronously
1125
+ // — deleteWorktree and the tests read it right after the awaited refetch.
1126
+ if (st.model) { st.model.setWorktrees(list); scheduleFlush(); }
1127
+ setWorktrees(list);
1128
+ return st.worktrees;
1129
+ });
1130
+ }
1131
+
1132
+ const wtShortSha = (c) => (typeof c === 'string' ? c.slice(0, 7) : '');
1133
+
1134
+ async function deleteWorktree(w) {
1135
+ const ok = await confirm({
1136
+ title: 'Remove this worktree?',
1137
+ message: `${w.projectKey} @ ${w.ref} is checked out at ${w.path}. The checkout is deleted; branches are untouched.`,
1138
+ confirmLabel: 'Remove',
1139
+ danger: true,
1140
+ });
1141
+ if (!ok) return;
1142
+ try { await fetch(`/api/ask/threads/${st.threadId}/worktrees/${w.worktreeId}`, { method: 'DELETE' }); } catch { /* refetch shows the truth */ }
1143
+ await refreshWorktrees();
1144
+ }
1145
+
1146
+ function openWorktreesPopover(trigger) {
1147
+ const panel = openPopover({ panelClass: 'ask-pop-runinfo ask-pop-worktrees', trigger, refreshOn: (d) => d.worktrees, build: (p) => {
1148
+ p.appendChild(make('div', 'ask-pop-caption', 'Worktrees this chat'));
1149
+ // Synchronous: st.worktrees is the DOM mirror, already fed by the snapshot
1150
+ // or the last frame, and flushExtra refreshes it BEFORE re-running build().
1151
+ renderWorktreeRows(p, st.worktrees);
1152
+ } });
1153
+ if (!panel) return;
1154
+ // Heal on open (one snapshot GET): the list lands in the model and the
1155
+ // dirty.worktrees flush re-runs build() above — one render path.
1156
+ refreshWorktrees();
1157
+ }
1158
+
1159
+ function renderWorktreeRows(panel, list) {
1160
+ if (!list.length) { panel.appendChild(make('div', 'ask-pop-empty', 'No worktrees open.')); return; }
1161
+ for (const w of list) {
1162
+ const row = make('div', 'ask-runinfo-row ask-wt-row');
1163
+ const col = make('span', 'ask-runinfo-col');
1164
+ col.appendChild(make('span', 'ask-runinfo-name', `${w.projectKey} · ${w.ref}@${wtShortSha(w.commit)}`));
1165
+ const path = make('span', 'ask-runinfo-sub ask-wt-path', w.path);
1166
+ path.title = 'Click to copy';
1167
+ path.addEventListener('click', () => { try { win.navigator.clipboard.writeText(w.path); } catch { /* unsupported */ } });
1168
+ col.appendChild(path);
1169
+ row.appendChild(col);
1170
+ // AGE (spec §10 row: project · ref@sha7 · AGE · path · trash). Reuses the
1171
+ // run-info popover's `.ask-runinfo-elapsed` cell — its `margin-left:auto`
1172
+ // also right-aligns the trash that follows.
1173
+ row.appendChild(make('span', 'ask-runinfo-elapsed', w.createdAt ? fmtElapsed(now() - Date.parse(w.createdAt)) : '—'));
1174
+ const trash = make('button', 'ask-thread-trash');
1175
+ trash.type = 'button';
1176
+ trash.setAttribute('aria-label', `Remove worktree ${w.worktreeId}`);
1177
+ trash.appendChild(svgIcon('M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2h9.2L17.5 7', 14, 1.8));
1178
+ trash.addEventListener('click', (e) => { e.stopPropagation(); closePopover({ focusTrigger: false }); deleteWorktree(w); });
1179
+ row.appendChild(trash);
1180
+ panel.appendChild(row);
1181
+ }
1182
+ }
1183
+
1184
+ function openRunInfoPopover(trigger) {
1185
+ // Rebuilt on every meters flush: agent blocks mark meters dirty (ask-model),
1186
+ // so rows, dots, ctx and cost move while agents run.
1187
+ openPopover({ panelClass: 'ask-pop-runinfo', trigger, refreshOn: (d) => d.meters, build: (p) => {
1188
+ const agents = [];
1189
+ if (st.model) {
1190
+ for (const row of st.model.messages()) {
1191
+ for (const b of row.blocks || []) if (b && b.kind === 'agent') agents.push(b);
1192
+ }
1193
+ }
1194
+ const head = make('div', 'ask-pop-caption-row');
1195
+ head.appendChild(make('span', 'ask-pop-caption', 'Agents this chat'));
1196
+ const cost = agents.reduce((n, a) => n + (Number.isFinite(a.costUsd) ? a.costUsd : 0), 0);
1197
+ // Cost only: costs sum across agents; context fills do not.
1198
+ head.appendChild(make('span', 'ask-pop-caption-meter', agents.length ? `≈${fmtUsd(cost)}` : ''));
1199
+ p.appendChild(head);
1200
+ if (!agents.length) { p.appendChild(make('div', 'ask-pop-empty', 'No agents spawned yet.')); return; }
1201
+ for (const a of agents) {
1202
+ const row = make('div', 'ask-runinfo-row');
1203
+ row.appendChild(make('span', `ask-dot${a.status === 'running' ? ' ask-dot-run' : a.status === 'done' ? ' ask-dot-done' : ''}`));
1204
+ const col = make('span', 'ask-runinfo-col');
1205
+ col.appendChild(make('span', 'ask-runinfo-name', a.label || a.type || 'agent'));
1206
+ col.appendChild(make('span', 'ask-runinfo-sub', [a.model, fmtCtx(a.ctx) || fmtTokens(a.tokens), Number.isFinite(a.costUsd) ? `≈${fmtUsd(a.costUsd)}` : null, a.status || null].filter(Boolean).join(' · ')));
1207
+ row.appendChild(col);
1208
+ row.appendChild(make('span', 'ask-runinfo-elapsed', fmtElapsed(a.durationMs) || '—'));
1209
+ p.appendChild(row);
1210
+ }
1211
+ } });
1212
+ }
1213
+
1214
+ // ---- thread actions -------------------------------------------------------
1215
+ function newThread() {
1216
+ loadGen += 1; // a load still in flight must not resurrect the old thread
1217
+ st.threadId = null;
1218
+ st.model = null;
1219
+ st.subscribedFor = null;
1220
+ stopElapsed();
1221
+ storeThread(null);
1222
+ el.title.textContent = 'Ask Worca';
1223
+ applyThreadScope(null); // #397: a brand-new chat starts on Auto
1224
+ renderTranscript();
1225
+ updateMeters();
1226
+ setWorktrees([]);
1227
+ updateSendStop();
1228
+ setComposerMsg(null);
1229
+ focusComposer();
1230
+ }
1231
+
1232
+ async function deleteThread(t) {
1233
+ closePopover({ focusTrigger: false });
1234
+ const ok = await confirm({
1235
+ title: 'Delete this chat?',
1236
+ message: `“${t.title || 'New chat'}” and its transcript are removed${t.worktrees ? ` along with ${t.worktrees} worktree${t.worktrees === 1 ? '' : 's'}` : ''}. This cannot be undone.`,
1237
+ confirmLabel: 'Delete',
1238
+ danger: true,
1239
+ });
1240
+ if (!ok) { focusComposer(); return; }
1241
+ try { await fetch(`/api/ask/threads/${t.id}`, { method: 'DELETE' }); } catch { /* the list will show it either way */ }
1242
+ if (readStoredThread() === t.id) storeThread(null);
1243
+ if (st.threadId === t.id) newThread(); // clears + focuses the textarea (D14)
1244
+ else focusComposer();
1245
+ }
1246
+
1247
+ function buildThreadTrash(t) {
1248
+ const b = make('button', 'ask-thread-trash');
1249
+ b.type = 'button';
1250
+ b.setAttribute('aria-label', `Delete "${t.title || 'New chat'}"`);
1251
+ b.appendChild(svgIcon('M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2h9.2L17.5 7', 14, 1.8));
1252
+ b.addEventListener('click', (e) => { e.stopPropagation(); deleteThread(t); });
1253
+ return b;
1254
+ }
1255
+
1256
+ // ---- stubs the later tasks replace wholesale ------------------------------
1257
+ // ---- transcript (spec §10.5) ---------------------------------------------
1258
+ function buildAttachmentPill(b) {
1259
+ // #398: an image attachment renders as a thumbnail served by the download
1260
+ // route (sniff-verified mime, inline disposition); everything else keeps the
1261
+ // name pill. The id comes from the 202 body or the ask-message broadcast; an
1262
+ // echo without one (older server) pills until the snapshot.
1263
+ if (b.attKind === 'image' && b.id && st.threadId) {
1264
+ const link = make('a', 'ask-attachment-thumb-link');
1265
+ link.href = `/api/ask/threads/${st.threadId}/attachments/${b.id}`;
1266
+ link.target = '_blank';
1267
+ link.rel = 'noopener';
1268
+ const img = doc.createElement('img');
1269
+ img.className = 'ask-attachment-thumb';
1270
+ img.alt = b.name || '(image)';
1271
+ img.loading = 'lazy';
1272
+ img.src = link.href;
1273
+ link.appendChild(img);
1274
+ return link;
1275
+ }
1276
+ const pill = make('span', 'extra-pill ask-attachment-pill');
1277
+ pill.appendChild(make('span', 'extra-pill-name', b.name || '(attachment)'));
1278
+ return pill;
1279
+ }
1280
+
1281
+ function buildNotice(b) {
1282
+ const n = make('div', 'ask-notice');
1283
+ n.appendChild(make('span', null, b.text || ''));
1284
+ if (b.href) {
1285
+ n.appendChild(doc.createTextNode(' '));
1286
+ const a = make('a', 'ask-notice-link', 'open');
1287
+ a.setAttribute('href', b.href);
1288
+ n.appendChild(a);
1289
+ }
1290
+ return n;
1291
+ }
1292
+
1293
+ // ---- Start-run card (spec §9, §10.5; D1-D3) -------------------------------
1294
+ // Field edits live in the DOM only (V7): a proposed card's element is CACHED
1295
+ // by card id and REUSED across message re-renders, so streaming updates and
1296
+ // proposed re-emits never clobber what the user typed. Only a STATE change
1297
+ // (started/dismissed/failed) builds a fresh terminal element.
1298
+ function loadCardOptions() {
1299
+ if (st.cardOptions) return st.cardOptions;
1300
+ const grab = (url, key) => Promise.resolve()
1301
+ .then(() => fetch(url))
1302
+ .then((r) => (r && r.ok ? r.json() : null))
1303
+ .catch(() => null)
1304
+ .then((body) => {
1305
+ if (Array.isArray(body)) return body;
1306
+ if (body && Array.isArray(body[key])) return body[key];
1307
+ return [];
1308
+ });
1309
+ st.cardOptions = Promise.all([
1310
+ grab('/api/projects', 'projects'),
1311
+ grab('/api/workflows', 'workflows'),
1312
+ // Verified against ui/server.mjs:2905-2913 — the envelope key is
1313
+ // `guardrails`, NOT `sets` (app.js listGuardrailsApi:3090-3095). The
1314
+ // wrong key renders an empty select, Start posts guardrailsId:'' and
1315
+ // /api/run silently coerces that to 'permissive'.
1316
+ grab('/api/guardrails', 'guardrails'),
1317
+ grab('/api/workspaces', 'workspaces'),
1318
+ ]).then(([projects, workflows, guardrails, workspaces]) => ({ projects, workflows, guardrails, workspaces }));
1319
+ return st.cardOptions;
1320
+ }
1321
+
1322
+ function fillSelect(select, options, value) {
1323
+ select.replaceChildren();
1324
+ for (const o of options) {
1325
+ const opt = doc.createElement('option');
1326
+ opt.value = o.value;
1327
+ opt.textContent = o.label;
1328
+ select.appendChild(opt);
1329
+ }
1330
+ if (value != null && [...select.options].some((o) => o.value === value)) select.value = value;
1331
+ }
1332
+
1333
+ function loadBranchesInto(select, projectDir, want) {
1334
+ fillSelect(select, [{ value: '', label: 'current branch (auto)' }], '');
1335
+ if (!projectDir) return;
1336
+ Promise.resolve()
1337
+ .then(() => fetch(`/api/branches?projectDir=${encodeURIComponent(projectDir)}`))
1338
+ .then((r) => (r && r.ok ? r.json() : null))
1339
+ .catch(() => null)
1340
+ .then((body) => {
1341
+ const branches = Array.isArray(body) ? body : (body && Array.isArray(body.branches)) ? body.branches : [];
1342
+ const opts = [{ value: '', label: 'current branch (auto)' }, ...branches.map((b) => ({ value: b, label: b }))];
1343
+ if (want && !opts.some((o) => o.value === want)) opts.push({ value: want, label: want });
1344
+ fillSelect(select, opts, want || '');
1345
+ });
1346
+ }
1347
+
1348
+ function wsBasename(p) { return String(p || '').replace(/\/+$/, '').split('/').pop() || String(p || ''); }
1349
+
1350
+ function buildCardTerminal(block) {
1351
+ const card = block.card || {};
1352
+ if (block.state === 'started') {
1353
+ const n = make('div', 'ask-card ask-card-started');
1354
+ n.appendChild(make('span', null, `Run started — ${card.title || card.brief || 'run'} `));
1355
+ const a = make('a', 'ask-card-link', 'open');
1356
+ a.setAttribute('href', `#running/${block.runId || ''}`);
1357
+ n.appendChild(a);
1358
+ return n;
1359
+ }
1360
+ if (block.state === 'failed') {
1361
+ return make('div', 'ask-card-stub ask-card-failed', `Run failed${block.error ? `: ${block.error}` : ''} — ${card.title || card.brief || ''}`);
1362
+ }
1363
+ return make('div', 'ask-card-stub', `Not now — ${card.title || card.brief || 'run proposal'}`);
1364
+ }
1365
+
1366
+ function buildCardForm(block) {
1367
+ const card = block.card || {};
1368
+ const rootEl = make('div', 'ask-card');
1369
+ const local = { target: card.target === 'workspace' ? 'workspace' : 'project', options: null };
1370
+
1371
+ rootEl.appendChild(make('div', 'ask-card-title', card.title || 'Run proposal'));
1372
+
1373
+ // #397 guardrail: the model proposed a different target than the chat's pin.
1374
+ if (block.scopeMismatch) {
1375
+ rootEl.appendChild(make('div', 'ask-card-scope-warn',
1376
+ 'This proposal targets a different project or workspace than the one pinned for this chat — check the target before starting.'));
1377
+ }
1378
+
1379
+ const seg = make('div', 'ask-card-seg');
1380
+ const segBtns = {};
1381
+ for (const [t, label] of [['project', 'Project'], ['workspace', 'Workspace']]) {
1382
+ const b = make('button', 'ask-card-seg-btn', label);
1383
+ b.type = 'button';
1384
+ b.setAttribute('data-ask-card-seg', t);
1385
+ b.addEventListener('click', () => {
1386
+ if (local.target === t) return;
1387
+ local.target = t;
1388
+ for (const k of Object.keys(segBtns)) segBtns[k].classList.toggle('on', k === local.target);
1389
+ renderTarget();
1390
+ });
1391
+ segBtns[t] = b;
1392
+ seg.appendChild(b);
1393
+ }
1394
+ segBtns[local.target].classList.add('on');
1395
+ rootEl.appendChild(seg);
1396
+
1397
+ const targetHost = make('div', 'ask-card-target');
1398
+ rootEl.appendChild(targetHost);
1399
+
1400
+ const field = (label, control) => {
1401
+ const f = make('div', 'ask-card-field');
1402
+ f.appendChild(make('label', 'ask-card-label', label));
1403
+ f.appendChild(control);
1404
+ return f;
1405
+ };
1406
+
1407
+ const workflowSel = doc.createElement('select');
1408
+ workflowSel.className = 'ask-card-workflow';
1409
+ rootEl.appendChild(field('Workflow', workflowSel));
1410
+
1411
+ const guardSel = doc.createElement('select');
1412
+ guardSel.className = 'ask-card-guardrails';
1413
+ rootEl.appendChild(field('Guardrails', guardSel));
1414
+
1415
+ const brief = doc.createElement('textarea');
1416
+ brief.className = 'ask-card-brief';
1417
+ brief.value = card.brief || '';
1418
+ brief.addEventListener('input', () => {
1419
+ brief.style.height = 'auto';
1420
+ brief.style.height = `${Math.min(brief.scrollHeight || 0, 160)}px`;
1421
+ });
1422
+ rootEl.appendChild(field('Task brief', brief));
1423
+
1424
+ const feature = doc.createElement('input');
1425
+ feature.type = 'text';
1426
+ feature.className = 'ask-card-feature';
1427
+ feature.value = card.featureBranch || '';
1428
+ rootEl.appendChild(field('Feature branch', feature));
1429
+
1430
+ const err = make('div', 'ask-card-err');
1431
+ rootEl.appendChild(err);
1432
+
1433
+ const actions = make('div', 'ask-card-actions');
1434
+ const openNp = make('button', 'ask-card-open-np', 'Open in New Pipeline');
1435
+ openNp.type = 'button';
1436
+ openNp.setAttribute('data-ask-card-open-np', '');
1437
+ openNp.addEventListener('click', () => prefillFromCard(block, rootEl, local));
1438
+ actions.appendChild(openNp);
1439
+ actions.appendChild(make('span', 'ask-card-actions-spacer'));
1440
+ const dismissBtn = make('button', 'ask-card-not-now', 'Not now');
1441
+ dismissBtn.type = 'button';
1442
+ dismissBtn.setAttribute('data-ask-card-dismiss', '');
1443
+ dismissBtn.addEventListener('click', () => dismissCard(block, rootEl));
1444
+ actions.appendChild(dismissBtn);
1445
+ const startBtn = make('button', 'ask-card-start', 'Start');
1446
+ startBtn.type = 'button';
1447
+ startBtn.setAttribute('data-ask-card-start', '');
1448
+ startBtn.addEventListener('click', () => startCard(block, rootEl, local));
1449
+ actions.appendChild(startBtn);
1450
+ rootEl.appendChild(actions);
1451
+
1452
+ function renderTarget() {
1453
+ targetHost.replaceChildren();
1454
+ const opts = local.options;
1455
+ if (local.target === 'project') {
1456
+ const projSel = doc.createElement('select');
1457
+ projSel.className = 'ask-card-project-select';
1458
+ const srcSel = doc.createElement('select');
1459
+ srcSel.className = 'ask-card-source';
1460
+ if (opts) {
1461
+ fillSelect(projSel, opts.projects.map((p) => ({ value: p.path, label: p.exists === false ? `${p.name} (missing)` : p.name })), card.projectDir || (opts.projects[0] && opts.projects[0].path) || '');
1462
+ loadBranchesInto(srcSel, projSel.value, card.sourceBranch || '');
1463
+ }
1464
+ projSel.addEventListener('change', () => loadBranchesInto(srcSel, projSel.value, ''));
1465
+ targetHost.appendChild(field('Project', projSel));
1466
+ targetHost.appendChild(field('Source branch', srcSel));
1467
+ } else {
1468
+ const wsSel = doc.createElement('select');
1469
+ wsSel.className = 'ask-card-workspace-select';
1470
+ const members = make('div', 'ask-card-members');
1471
+ const srcInput = doc.createElement('input');
1472
+ srcInput.type = 'text';
1473
+ srcInput.className = 'ask-card-source-input';
1474
+ srcInput.placeholder = 'auto';
1475
+ srcInput.value = card.sourceBranch || '';
1476
+ const details = doc.createElement('details');
1477
+ // .disclosure swaps the OS triangle for the app's own chevron
1478
+ details.className = 'ask-card-members-src disclosure';
1479
+ details.appendChild(make('summary', null, 'Per-member source branches'));
1480
+ const memberHost = make('div', 'ask-card-members-src-list');
1481
+ details.appendChild(memberHost);
1482
+ const renderMembers = () => {
1483
+ members.replaceChildren();
1484
+ memberHost.replaceChildren();
1485
+ const row = opts && opts.workspaces.find((w) => w && w.id === wsSel.value);
1486
+ const list = row && Array.isArray(row.projectKeys)
1487
+ ? row.projectKeys.map((k, i) => ({ projectKey: k, name: wsBasename(row.projectPaths && row.projectPaths[i]) }))
1488
+ : Array.isArray(card.members) ? card.members.map((m) => ({ projectKey: m.projectKey, name: m.projectName })) : [];
1489
+ members.textContent = list.map((m) => m.name).join(', ');
1490
+ for (const m of list) {
1491
+ const inp = doc.createElement('input');
1492
+ inp.type = 'text';
1493
+ inp.className = 'ask-card-member-src';
1494
+ inp.placeholder = 'auto';
1495
+ inp.setAttribute('data-project-key', m.projectKey);
1496
+ if (card.sourceBranchByKey && card.sourceBranchByKey[m.projectKey]) inp.value = card.sourceBranchByKey[m.projectKey];
1497
+ memberHost.appendChild(field(m.name, inp));
1498
+ }
1499
+ };
1500
+ if (opts) {
1501
+ fillSelect(wsSel, opts.workspaces.map((w) => ({ value: w.id, label: w.name || w.id })), card.workspaceId || (opts.workspaces[0] && opts.workspaces[0].id) || '');
1502
+ renderMembers();
1503
+ }
1504
+ wsSel.addEventListener('change', renderMembers);
1505
+ targetHost.appendChild(field('Workspace', wsSel));
1506
+ targetHost.appendChild(members);
1507
+ targetHost.appendChild(field('Source branch (default)', srcInput));
1508
+ targetHost.appendChild(details);
1509
+ }
1510
+ }
1511
+
1512
+ renderTarget();
1513
+ loadCardOptions().then((opts) => {
1514
+ if (st.destroyed) return;
1515
+ local.options = opts;
1516
+ fillSelect(workflowSel, opts.workflows.map((w) => ({ value: w.id, label: workflowPickerLabel(w, null) || w.name || w.id })), card.workflowId || 'wf_default');
1517
+ fillSelect(guardSel, opts.guardrails.map((g) => ({ value: g.id, label: g.id === 'permissive' ? 'Permissive' : (g.name || g.id) })), card.guardrailsId || 'normal');
1518
+ renderTarget();
1519
+ });
1520
+ return rootEl;
1521
+ }
1522
+
1523
+ function collectCardBody(rootEl, local, card) {
1524
+ const body = {
1525
+ prompt: rootEl.querySelector('.ask-card-brief').value,
1526
+ workflowId: rootEl.querySelector('.ask-card-workflow').value,
1527
+ guardrailsId: rootEl.querySelector('.ask-card-guardrails').value, // ALWAYS sent (spec §9.4)
1528
+ title: card.title || undefined,
1529
+ mock: false,
1530
+ };
1531
+ const feature = rootEl.querySelector('.ask-card-feature').value.trim();
1532
+ if (feature) body.featureBranch = feature;
1533
+ if (local.target === 'workspace') {
1534
+ body.workspaceId = rootEl.querySelector('.ask-card-workspace-select').value;
1535
+ const src = rootEl.querySelector('.ask-card-source-input');
1536
+ if (src && src.value.trim()) body.sourceBranch = src.value.trim();
1537
+ const byKey = {};
1538
+ for (const inp of rootEl.querySelectorAll('.ask-card-member-src')) {
1539
+ const k = inp.getAttribute('data-project-key');
1540
+ const v = inp.value.trim();
1541
+ if (k && v) byKey[k] = v;
1542
+ }
1543
+ if (Object.keys(byKey).length) body.sourceBranchByKey = byKey;
1544
+ } else {
1545
+ body.projectDir = rootEl.querySelector('.ask-card-project-select').value;
1546
+ const src = rootEl.querySelector('.ask-card-source');
1547
+ if (src && src.value) body.sourceBranch = src.value;
1548
+ }
1549
+ return body;
1550
+ }
1551
+
1552
+ async function startCard(block, rootEl, local) {
1553
+ const err = rootEl.querySelector('.ask-card-err');
1554
+ const startBtn = rootEl.querySelector('[data-ask-card-start]');
1555
+ err.textContent = '';
1556
+ startBtn.disabled = true;
1557
+ try {
1558
+ const body = { ...collectCardBody(rootEl, local, block.card || {}), askThreadId: st.threadId, askCardId: block.id };
1559
+ let res = null;
1560
+ try {
1561
+ res = await fetch('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1562
+ } catch { err.textContent = 'network error'; return; }
1563
+ if (!res.ok) {
1564
+ let msg = `request failed (${res.status})`;
1565
+ try { const b = await res.json(); if (b && b.error) msg = b.error; } catch { /* keep */ }
1566
+ err.textContent = msg;
1567
+ return;
1568
+ }
1569
+ // Success: the server links, flips the card to started and broadcasts;
1570
+ // the flip frame renders the terminal state. The browser never navigates
1571
+ // (beginRun is NEVER called — spec §10.5).
1572
+ } finally {
1573
+ startBtn.disabled = false;
1574
+ }
1575
+ }
1576
+
1577
+ async function dismissCard(block, rootEl) {
1578
+ const err = rootEl.querySelector('.ask-card-err');
1579
+ err.textContent = '';
1580
+ let res = null;
1581
+ try {
1582
+ res = await fetch(`/api/ask/threads/${st.threadId}/cards/${block.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: 'dismissed' }) });
1583
+ } catch { err.textContent = 'network error'; return; }
1584
+ if (!res.ok) {
1585
+ let msg = `request failed (${res.status})`;
1586
+ try { const b = await res.json(); if (b && b.error) msg = b.error; } catch { /* keep */ }
1587
+ err.textContent = msg;
1588
+ }
1589
+ // the flip frame renders the stub
1590
+ }
1591
+
1592
+ function prefillFromCard(block, rootEl, local) {
1593
+ const card = block.card || {};
1594
+ const p = {
1595
+ target: local.target,
1596
+ workflowId: rootEl.querySelector('.ask-card-workflow').value,
1597
+ guardrailsId: rootEl.querySelector('.ask-card-guardrails').value,
1598
+ prompt: rootEl.querySelector('.ask-card-brief').value,
1599
+ title: card.title || '',
1600
+ featureBranch: rootEl.querySelector('.ask-card-feature').value.trim(),
1601
+ };
1602
+ if (local.target === 'workspace') {
1603
+ p.workspaceId = rootEl.querySelector('.ask-card-workspace-select').value;
1604
+ const src = rootEl.querySelector('.ask-card-source-input');
1605
+ p.sourceBranch = src ? src.value.trim() : '';
1606
+ const byKey = {};
1607
+ for (const inp of rootEl.querySelectorAll('.ask-card-member-src')) {
1608
+ const k = inp.getAttribute('data-project-key');
1609
+ const v = inp.value.trim();
1610
+ if (k && v) byKey[k] = v;
1611
+ }
1612
+ if (Object.keys(byKey).length) p.sourceBranchByKey = byKey;
1613
+ } else {
1614
+ p.projectDir = rootEl.querySelector('.ask-card-project-select').value;
1615
+ const src = rootEl.querySelector('.ask-card-source');
1616
+ p.sourceBranch = src ? src.value : '';
1617
+ }
1618
+ openNewPipeline(p);
1619
+ }
1620
+
1621
+ function buildCard(block) {
1622
+ if (!st.cardEls) st.cardEls = new Map();
1623
+ const cached = st.cardEls.get(block.id);
1624
+ if (cached && cached.state === block.state && block.state === 'proposed') return cached.el;
1625
+ const built = block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block);
1626
+ st.cardEls.set(block.id, { el: built, state: block.state });
1627
+ return built;
1628
+ }
1629
+
1630
+ function toolRow(block) {
1631
+ const rowEl = make('div', 'ask-tool-row');
1632
+ const short = String(block.name || '').replace(/^mcp__worca__/, '');
1633
+ const parts = short.split('_');
1634
+ rowEl.appendChild(make('span', 'ask-tool-op', parts[0] || short));
1635
+ const target = parts.slice(1).join(' ');
1636
+ const preview = clipInput(block.input);
1637
+ rowEl.appendChild(make('span', 'ask-tool-target', preview ? (target ? `${target} · ${preview}` : preview) : target));
1638
+ const note = block.status === 'error' ? 'error' : block.status === 'running' ? '…' : fmtElapsed(block.durationMs);
1639
+ rowEl.appendChild(make('span', 'ask-tool-note', note || ''));
1640
+ return rowEl;
1641
+ }
1642
+
1643
+ function agentRow(block) {
1644
+ const wrap = make('div', 'ask-agent');
1645
+ const rowEl = make('button', 'ask-agent-row');
1646
+ rowEl.type = 'button';
1647
+ rowEl.appendChild(make('span', `ask-dot${block.status === 'running' ? ' ask-dot-run' : block.status === 'done' ? ' ask-dot-done' : ''}`));
1648
+ rowEl.appendChild(make('span', 'ask-agent-name', block.label || block.type || 'agent'));
1649
+ rowEl.appendChild(make('span', 'ask-agent-model', block.model || ''));
1650
+ rowEl.appendChild(make('span', 'ask-agent-tokens', fmtCtx(block.ctx) || fmtTokens(block.tokens) || ''));
1651
+ rowEl.appendChild(make('span', 'ask-agent-cost', Number.isFinite(block.costUsd) ? `≈${fmtUsd(block.costUsd)}` : ''));
1652
+ rowEl.appendChild(make('span', `ask-agent-status${block.status === 'done' ? ' is-done' : ''}`, block.status || ''));
1653
+ rowEl.addEventListener('click', () => {
1654
+ if (st.expandedAgents.has(block.id)) st.expandedAgents.delete(block.id);
1655
+ else st.expandedAgents.add(block.id);
1656
+ const found = st.model && findRowOfBlock(block.id);
1657
+ if (found) refreshRow(found);
1658
+ });
1659
+ wrap.appendChild(rowEl);
1660
+ if (st.expandedAgents.has(block.id)) {
1661
+ const log = make('div', 'ask-agent-log');
1662
+ const head = make('div', 'ask-agent-log-head');
1663
+ head.appendChild(make('span', null, [block.model, fmtCtx(block.ctx) || fmtTokens(block.tokens), Number.isFinite(block.costUsd) ? `≈${fmtUsd(block.costUsd)}` : null].filter(Boolean).join(' · ')));
1664
+ head.appendChild(make('span', 'ask-agent-log-type', block.type || ''));
1665
+ log.appendChild(head);
1666
+ const body = make('div', 'ask-agent-log-body');
1667
+ for (const line of Array.isArray(block.log) ? block.log : []) {
1668
+ const l = make('div', 'ask-agent-log-line');
1669
+ l.appendChild(make('span', 'ask-agent-log-t', mmss(line.t)));
1670
+ l.appendChild(make('span', 'ask-agent-log-text', line.text || ''));
1671
+ body.appendChild(l);
1672
+ }
1673
+ log.appendChild(body);
1674
+ wrap.appendChild(log);
1675
+ }
1676
+ return wrap;
1677
+ }
1678
+
1679
+ function findRowOfBlock(blockId) {
1680
+ for (const row of st.model.messages()) {
1681
+ if ((row.blocks || []).some((b) => b && b.id === blockId)) return row;
1682
+ }
1683
+ return null;
1684
+ }
1685
+
1686
+ function refreshRow(row) {
1687
+ const entry = st.rowEls && st.rowEls.get(row.id);
1688
+ if (entry) entry.update(row);
1689
+ }
1690
+
1691
+ function buildActivity(row) {
1692
+ const isLive = !!(st.model && st.model.live() && st.model.live().messageId === row.id);
1693
+ const activity = make('div', 'ask-activity');
1694
+ const head = make('div', 'ask-activity-head');
1695
+ const stopped = row.status === 'stopped' || row.status === 'error';
1696
+ if (isLive) head.appendChild(make('span', 'ask-activity-label', 'Thinking'));
1697
+ else if (!stopped) head.appendChild(make('span', 'ask-activity-label', 'Done'));
1698
+ head.appendChild(make('span', `ask-dot${isLive ? ' ask-dot-run' : row.status === 'error' ? '' : ' ask-dot-done'}`));
1699
+ // The head names its state in one word ahead of the dot — Thinking, Done, or
1700
+ // Stopped after — and nothing more while the turn is live: the orb row at the
1701
+ // bottom of the message owns the elapsed and the meter, and printing either
1702
+ // set twice is the noise this replaced. A turn that ended badly says so
1703
+ // instead of Done; nothing else marks a stop.
1704
+ if (!isLive) {
1705
+ if (stopped) head.appendChild(make('span', 'ask-activity-label', 'Stopped after'));
1706
+ head.appendChild(make('span', 'ask-activity-elapsed', fmtElapsed(row.durationMs) || ''));
1707
+ head.appendChild(make('span', 'ask-activity-spacer'));
1708
+ const meter = [fmtCtx(row.usage && row.usage.ctx), fmtUsd(row.costUsd)].filter(Boolean).join(' · ');
1709
+ head.appendChild(make('span', 'ask-activity-meter', meter));
1710
+ }
1711
+ activity.appendChild(head);
1712
+ const tools = (row.blocks || []).filter((b) => b && b.kind === 'tool');
1713
+ for (const b of tools) activity.appendChild(toolRow(b));
1714
+ const agents = (row.blocks || []).filter((b) => b && b.kind === 'agent');
1715
+ if (agents.length) {
1716
+ const sect = make('div', 'ask-agents');
1717
+ const cap = make('div', 'ask-agents-cap');
1718
+ cap.appendChild(make('span', null, 'Sub-agents'));
1719
+ cap.appendChild(make('span', 'ask-agents-count', String(agents.length)));
1720
+ sect.appendChild(cap);
1721
+ for (const b of agents) sect.appendChild(agentRow(b));
1722
+ activity.appendChild(sect);
1723
+ }
1724
+ return { el: activity };
1725
+ }
1726
+
1727
+ // The ONE orb: created on first live turn and re-parented into each rebuilt
1728
+ // live row. Rebuilding it per row would restart the canvas — and since a tool
1729
+ // block rebuilds the row, the sphere would visibly snap back mid-turn.
1730
+ function ensureThinking() {
1731
+ if (el.thinking) return el.thinking;
1732
+ el.orb = createThinkingOrb({ doc, win, size: 28.5 });
1733
+ const wrap = make('div', 'ask-thinking');
1734
+ wrap.appendChild(el.orb.el);
1735
+ el.thinkingLabel = make('span', 'ask-thinking-label');
1736
+ wrap.appendChild(el.thinkingLabel);
1737
+ const meter = make('span', 'ask-thinking-meter');
1738
+ el.thinkingElapsed = make('span', 'ask-thinking-elapsed');
1739
+ el.thinkingUsage = make('span', 'ask-thinking-usage');
1740
+ meter.appendChild(el.thinkingElapsed);
1741
+ meter.appendChild(el.thinkingUsage);
1742
+ wrap.appendChild(meter);
1743
+ el.thinking = wrap;
1744
+ return wrap;
1745
+ }
1746
+
1747
+ function updateThinking() {
1748
+ const live = st.model && st.model.live();
1749
+ if (!live || !el.thinking) return;
1750
+ el.thinkingLabel.textContent = `${live.label || 'Thinking'}…`;
1751
+ // ask-usage marks only `meters` dirty, so the row is NOT rebuilt when the
1752
+ // numbers move — this runs every flush instead (updateLiveElapsed).
1753
+ const rest = [fmtCtx(live.usage && live.usage.ctx), fmtUsd(live.costUsd)].filter(Boolean).join(' · ');
1754
+ el.thinkingUsage.textContent = rest ? ` · ${rest}` : '';
1755
+ }
1756
+
1757
+ function renderAnswerInto(div, row) {
1758
+ const isLive = !!(st.model && st.model.live() && st.model.live().messageId === row.id);
1759
+ const text = isLive ? st.model.live().text : row.text || '';
1760
+ // Seed the >32 KB throttle clock here, not only in renderAnswerFor: a
1761
+ // structural flush repaints answers through renderTranscript, which never
1762
+ // passes through renderAnswerFor — left at 0, the 250 ms window would be
1763
+ // permanently expired and the size ladder dead.
1764
+ st.lastAnswerRender = now();
1765
+ if (!renderer.isReady() && !renderer.isFailed() && !st.mdKicked) {
1766
+ st.mdKicked = true;
1767
+ renderer.ensure().then((ok) => { if (ok && !st.destroyed) rerenderAnswers(); });
1768
+ }
1769
+ const out = renderer.render(text);
1770
+ if (out.kind === 'md') {
1771
+ div.classList.add('ask-md');
1772
+ div.classList.remove('ask-answer-plain');
1773
+ div.replaceChildren(out.frag);
1774
+ if (!isLive) renderer.highlight(div); // fire-and-forget; §10.5: highlight on done
1775
+ } else {
1776
+ div.classList.add('ask-answer-plain');
1777
+ div.classList.remove('ask-md');
1778
+ div.textContent = text;
1779
+ }
1780
+ }
1781
+
1782
+ function rerenderAnswers() {
1783
+ if (!st.rowEls) return;
1784
+ for (const entry of st.rowEls.values()) { if (entry.renderAnswer) entry.renderAnswer(); }
1785
+ scheduleFlush();
1786
+ }
1787
+
1788
+ function buildMessage(row) {
1789
+ const wrap = make('div', `ask-msg ask-msg-${row.role}`);
1790
+ let renderAnswer = null;
1791
+ if (row.role === 'user') {
1792
+ const bubble = make('div', 'ask-user-bubble', row.text || '');
1793
+ wrap.appendChild(bubble);
1794
+ const atts = (row.blocks || []).filter((b) => b && b.kind === 'attachment');
1795
+ if (atts.length) {
1796
+ const pills = make('div', 'extras-pills ask-user-pills');
1797
+ for (const b of atts) pills.appendChild(buildAttachmentPill(b));
1798
+ wrap.appendChild(pills);
1799
+ }
1800
+ } else if (row.role === 'system') {
1801
+ const notices = (row.blocks || []).filter((b) => b && b.kind === 'notice');
1802
+ if (notices.length) for (const b of notices) wrap.appendChild(buildNotice(b));
1803
+ else wrap.appendChild(buildNotice({ text: row.text }));
1804
+ } else {
1805
+ wrap.appendChild(buildActivity(row).el);
1806
+ const answer = make('div', 'ask-answer');
1807
+ wrap.appendChild(answer);
1808
+ renderAnswer = () => renderAnswerInto(answer, row);
1809
+ renderAnswer();
1810
+ for (const b of row.blocks || []) {
1811
+ if (!b) continue;
1812
+ if (b.kind === 'notice') wrap.appendChild(buildNotice(b));
1813
+ else if (b.kind === 'card') wrap.appendChild(buildCard(b, row));
1814
+ }
1815
+ if (row.status === 'error') {
1816
+ const explained = (row.blocks || []).some((b) => b && b.kind === 'notice');
1817
+ if (row.errorMessage) wrap.appendChild(make('div', 'ask-error-line', row.errorMessage));
1818
+ else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
1819
+ }
1820
+ if (st.model && st.model.live() && st.model.live().messageId === row.id) {
1821
+ wrap.appendChild(ensureThinking()); // last child: the bottom of the message
1822
+ el.elapsed = el.thinkingElapsed; // the ONE live elapsed node
1823
+ // Idempotent, and the only re-arm on the adoption path: a thread whose
1824
+ // ask-start the ring buffer already evicted goes live without ever
1825
+ // passing through startElapsed(), and would otherwise show a dead orb.
1826
+ el.orb.start();
1827
+ updateThinking();
1828
+ }
1829
+ }
1830
+ const entry = {
1831
+ el: wrap,
1832
+ renderAnswer,
1833
+ update(row2) {
1834
+ const fresh = buildMessage(row2);
1835
+ wrap.replaceWith(fresh.el);
1836
+ st.rowEls.set(row2.id, fresh);
1837
+ },
1838
+ };
1839
+ return entry;
1840
+ }
1841
+
1842
+ function renderTranscript() {
1843
+ st.rowEls = new Map();
1844
+ el.transcript.replaceChildren();
1845
+ if (!st.model) return;
1846
+ for (const row of st.model.messages()) {
1847
+ const entry = buildMessage(row);
1848
+ st.rowEls.set(row.id, entry);
1849
+ el.transcript.appendChild(entry.el);
1850
+ }
1851
+ }
1852
+
1853
+ // Bumped by every loadThread()/newThread()/thread creation: whichever GET resolves
1854
+ // LAST used to win unconditionally, so a slow old thread load overwrote a newer
1855
+ // switch (review of PR #376). A load whose generation is stale returns null.
1856
+ let loadGen = 0;
1857
+ async function loadThread(id) {
1858
+ const gen = ++loadGen;
1859
+ let res = null;
1860
+ try { res = await fetch(`/api/ask/threads/${id}`); } catch { return null; }
1861
+ if (gen !== loadGen || st.destroyed) return null;
1862
+ if (!res || !res.ok) {
1863
+ if (res && res.status === 404 && readStoredThread() === id) storeThread(null);
1864
+ return null;
1865
+ }
1866
+ let snap = null;
1867
+ try { snap = await res.json(); } catch { return null; }
1868
+ if (gen !== loadGen || st.destroyed) return null;
1869
+ st.threadId = id;
1870
+ st.model = createThreadModel({ threadId: id });
1871
+ st.model.load(snap);
1872
+ el.title.textContent = (snap.thread && snap.thread.title) || 'Ask Worca';
1873
+ applyThreadScope(snap.thread && snap.thread.context); // #397: restore the pin
1874
+ renderTranscript();
1875
+ updateMeters();
1876
+ // P4: the count rides the snapshot loadThread ALREADY fetched — no extra GET.
1877
+ // The model owns the list (load() seeded it). It belongs here, not in
1878
+ // switchThread: resync()/onHello() come through loadThread too.
1879
+ setWorktrees(st.model.worktrees());
1880
+ st.pinned = true;
1881
+ scheduleFlush();
1882
+ stopElapsed(); // a mid-stream thread switch must not leave the old
1883
+ updateSendStop(); // turn's timer or stop button behind (V3/D2 reset)
1884
+ if (snap.inFlight) { subscribe(id); startElapsed(); }
1885
+ return snap;
1886
+ }
1887
+
1888
+ function switchThread(id) {
1889
+ if (!id) return Promise.resolve(null);
1890
+ storeThread(id);
1891
+ return loadThread(id);
1892
+ }
1893
+ // ---- live streaming (spec §10.8) -----------------------------------------
1894
+ function rowOf(id) {
1895
+ if (!st.model) return null;
1896
+ for (const r of st.model.messages()) if (r && r.id === id) return r;
1897
+ return null;
1898
+ }
1899
+
1900
+ function hasSelectionInside(entry) {
1901
+ let sel = null;
1902
+ try { sel = win.getSelection ? win.getSelection() : null; } catch { return false; }
1903
+ if (!sel || !sel.rangeCount || sel.isCollapsed) return false;
1904
+ return containsNode(entry.el, sel.anchorNode) || containsNode(entry.el, sel.focusNode);
1905
+ }
1906
+
1907
+ // Streaming answers re-parse the whole accumulated text (spec §10.5); the
1908
+ // ladder bounds the cost: ≤32 KB every flush, above that at most one render
1909
+ // per 250 ms (measured ≈50 ms/64 KB under jsdom), >200 KB the renderer
1910
+ // itself falls back to plain. A live selection inside the answer defers the
1911
+ // render to the next flush (§10.8).
1912
+ function renderAnswerFor(id) {
1913
+ const entry = st.rowEls && st.rowEls.get(id);
1914
+ const row = rowOf(id);
1915
+ if (!row) return;
1916
+ if (!entry || !entry.renderAnswer) { refreshRow(row); return; }
1917
+ const live = st.model.live();
1918
+ const isLive = !!(live && live.messageId === id);
1919
+ if (isLive) {
1920
+ if (live.text.length > 32_000 && now() - st.lastAnswerRender < 250) { st.answerPending = id; scheduleFlush(); return; }
1921
+ if (hasSelectionInside(entry)) { st.answerPending = id; scheduleFlush(); return; }
1922
+ }
1923
+ st.lastAnswerRender = now();
1924
+ entry.renderAnswer();
1925
+ }
1926
+
1927
+ function startElapsed(startedAtMs) {
1928
+ st.elapsedStart = Number.isFinite(startedAtMs) ? startedAtMs : now();
1929
+ if (st.elapsedTimer) clearInterval(st.elapsedTimer);
1930
+ // Bare setInterval on purpose (app.js:14247-14253 precedent): in a browser
1931
+ // it IS window.setInterval; under node:test this module resolves it to
1932
+ // Node's global, whose Timeout can be unref'd — jsdom's window.setInterval
1933
+ // returns a bare number with no unref(), and a leaked 1s tick would hold
1934
+ // the event loop open for every turn a test leaves streaming.
1935
+ st.elapsedTimer = setInterval(() => scheduleFlush(), 1000);
1936
+ if (st.elapsedTimer && typeof st.elapsedTimer.unref === 'function') st.elapsedTimer.unref();
1937
+ if (el.orb) el.orb.start();
1938
+ }
1939
+ function stopElapsed() {
1940
+ if (st.elapsedTimer) { clearInterval(st.elapsedTimer); st.elapsedTimer = null; }
1941
+ st.elapsedStart = null;
1942
+ // The orb row is simply not rebuilt into a finished message, so the node is
1943
+ // left detached — with no custom-element lifecycle to notice, the rAF loop
1944
+ // has to be cut here or it paints an orphan for the rest of the session.
1945
+ if (el.orb) el.orb.stop();
1946
+ }
1947
+ function updateLiveElapsed() {
1948
+ if (st.elapsedStart != null && el.elapsed && st.model && st.model.live()) {
1949
+ el.elapsed.textContent = fmtElapsed(now() - st.elapsedStart);
1950
+ }
1951
+ updateThinking();
1952
+ }
1953
+
1954
+ function afterFrame(frame) {
1955
+ if (frame.type === 'ask-start') { startElapsed(Date.parse(frame.startedAt)); updateSendStop(); }
1956
+ else if (typeof frame.seq === 'number' && frame.type !== 'ask-done' && frame.type !== 'ask-error' && st.model && st.model.live() && el.send && !el.send.hidden) {
1957
+ // A JOB frame ADOPTED mid-turn (no ask-start seen — the ring buffer evicted
1958
+ // it, or a broadcast delta beat the subscribe replay): the turn is live now,
1959
+ // so the composer must show Stop and the timer must run (review of PR #376).
1960
+ // Out-of-turn frames (ask-title — early now — ask-worktrees, ask-message)
1961
+ // never adopt: startElapsed() here would reset the running clock.
1962
+ startElapsed(); updateSendStop();
1963
+ }
1964
+ if (frame.type === 'ask-done' || frame.type === 'ask-error') {
1965
+ stopElapsed(); updateSendStop(); announce('answer finished');
1966
+ // P4: a finished turn may have created/removed/navigated worktrees. This must
1967
+ // NOT live in updateSendStop() — that also runs from loadThread, so a
1968
+ // running→idle latch there fires a SECOND snapshot GET on every resync.
1969
+ refreshWorktrees();
1970
+ }
1971
+ else if (frame.type === 'ask-message' && frame.message && typeof frame.message.text === 'string'
1972
+ && /is waiting for your answer/.test(frame.message.text)) announce('run needs an answer');
1973
+ }
1974
+
1975
+ // Settings → "Delete all chat history" broadcast (seq-less, threadId-less): every
1976
+ // row is gone server-side, so a tab still holding st.threadId would keep a dead
1977
+ // chat in memory until its next fetch 404s. Reset exactly like the "+" button.
1978
+ function onHistoryCleared() {
1979
+ closePopover({ focusTrigger: false });
1980
+ if (st.threadId) newThread();
1981
+ }
1982
+
1983
+ function pushServerFrame(frame) {
1984
+ if (st.destroyed || !frame) return;
1985
+ if (frame.type === 'ask-history-cleared') { onHistoryCleared(); return; }
1986
+ // Defence-in-depth: the model's own threadId filter is the real router — this early return only saves an apply() call and cannot be observed from tests (the model would drop the frame identically).
1987
+ if (!st.model || frame.threadId !== st.threadId) return;
1988
+ const r = st.model.apply(frame);
1989
+ if (r && r.gap) { resync(); return; }
1990
+ if (!r || !r.ok) return;
1991
+ afterFrame(frame);
1992
+ scheduleFlush();
1993
+ }
1994
+
1995
+ function subscribe(threadId, { force = false } = {}) {
1996
+ if (!threadId) return;
1997
+ if (!force && st.subscribedFor === threadId) return;
1998
+ st.subscribedFor = threadId;
1999
+ sendWs({ type: 'subscribe', threadId });
2000
+ }
2001
+
2002
+ // Re-fetch + resubscribe (spec §10.8: a seq gap or a reconnect re-syncs over
2003
+ // REST — the ring buffer replay then re-plays from seq 1 and the model's seq
2004
+ // dedupe/adoption absorb it). Latched: one resync at a time.
2005
+ function resync() {
2006
+ if (st.resyncing || !st.threadId || st.destroyed) return;
2007
+ st.resyncing = true;
2008
+ const id = st.threadId;
2009
+ Promise.resolve()
2010
+ .then(() => loadThread(id))
2011
+ .then((snap) => { if (snap && snap.inFlight) subscribe(id, { force: true }); })
2012
+ .catch(() => { /* the thread may be gone; loadThread handled storage */ })
2013
+ .then(() => { st.resyncing = false; });
2014
+ }
2015
+
2016
+ function onHello(list) {
2017
+ if (st.destroyed || !Array.isArray(list)) return;
2018
+ st.subscribedFor = null; // a fresh socket forgot every prior subscribe
2019
+ // A fresh socket may have dropped out-of-turn frames for ANY thread (spec
2020
+ // §11: reconnect = re-subscribe + REST re-sync); re-sync whenever a thread
2021
+ // is active — the latch bounds it to one GET, and resync() re-subscribes
2022
+ // only when the snapshot still shows a turn in flight.
2023
+ if (st.threadId) resync();
2024
+ }
2025
+
2026
+ function flushExtra() {
2027
+ if (!st.model) return;
2028
+ if (st.answerPending) { const pid = st.answerPending; st.answerPending = null; renderAnswerFor(pid); }
2029
+ if (st.rowPending) {
2030
+ const rid = st.rowPending;
2031
+ st.rowPending = null;
2032
+ const held = st.rowEls && st.rowEls.get(rid);
2033
+ if (held && hasSelectionInside(held)) { st.rowPending = rid; scheduleFlush(); }
2034
+ else { const row = rowOf(rid); if (row) refreshRow(row); }
2035
+ }
2036
+ const d = st.model.takeDirty();
2037
+ if (d.title) el.title.textContent = st.model.thread().title || 'Ask Worca';
2038
+ if (d.structure) {
2039
+ renderTranscript();
2040
+ } else {
2041
+ for (const id of d.messages) { const row = rowOf(id); if (row) refreshRow(row); }
2042
+ if (d.label && st.model.live()) {
2043
+ const liveId = st.model.live().messageId;
2044
+ const entry = st.rowEls && st.rowEls.get(liveId);
2045
+ // §10.8: a whole-row rebuild would destroy a live selection — defer it
2046
+ // exactly like a throttled answer render.
2047
+ if (entry && hasSelectionInside(entry)) { st.rowPending = liveId; scheduleFlush(); }
2048
+ else { const row = rowOf(liveId); if (row) refreshRow(row); }
2049
+ }
2050
+ for (const id of d.blocks.keys()) {
2051
+ if (d.messages.has(id)) continue;
2052
+ if (d.label && st.model.live() && st.model.live().messageId === id) continue; // already rebuilt
2053
+ const entry = st.rowEls && st.rowEls.get(id);
2054
+ if (st.model.live() && st.model.live().messageId === id && entry && hasSelectionInside(entry)) { st.rowPending = id; scheduleFlush(); continue; }
2055
+ const row = rowOf(id);
2056
+ if (row) refreshRow(row);
2057
+ }
2058
+ for (const id of d.answer) renderAnswerFor(id);
2059
+ }
2060
+ if (d.meters) updateMeters();
2061
+ if (d.worktrees) setWorktrees(st.model.worktrees());
2062
+ // An open popover that subscribed to this flush's dirt is rebuilt in place
2063
+ // (same node — never reopened, never refocused). Runs AFTER the mirror and
2064
+ // the meters above: the worktrees build() reads st.worktrees.
2065
+ const pop = st.popover;
2066
+ if (pop && typeof pop.refreshOn === 'function' && pop.refreshOn(d)) { pop.panel.replaceChildren(); pop.build(pop.panel); }
2067
+ updateLiveElapsed();
2068
+ }
2069
+
2070
+ // ---- flush + scroll (minimal now; Task 5 extends via flushExtra) ---------
2071
+ function scheduleFlush() {
2072
+ if (st.flushArmed || st.destroyed) return;
2073
+ st.flushArmed = true;
2074
+ raf(() => { st.flushArmed = false; flush(); });
2075
+ }
2076
+
2077
+ function flush() {
2078
+ if (st.destroyed) return;
2079
+ flushExtra();
2080
+ applyPin();
2081
+ }
2082
+
2083
+ function updatePinFromScroll() {
2084
+ const t = el.transcript;
2085
+ st.pinned = t.scrollHeight - t.scrollTop - t.clientHeight < 24;
2086
+ if (el.jump) el.jump.hidden = st.pinned;
2087
+ }
2088
+
2089
+ function applyPin() {
2090
+ if (!st.open) return;
2091
+ if (st.pinned) el.transcript.scrollTop = el.transcript.scrollHeight;
2092
+ if (el.jump) el.jump.hidden = st.pinned;
2093
+ }
2094
+
2095
+ function jumpToLatest() {
2096
+ st.pinned = true;
2097
+ el.transcript.scrollTop = el.transcript.scrollHeight;
2098
+ if (el.jump) el.jump.hidden = true;
2099
+ }
2100
+
2101
+ // ---- mount ----------------------------------------------------------------
2102
+ const root = buildRoot();
2103
+ doc.addEventListener('keydown', onDocKeydown, true);
2104
+ doc.addEventListener('pointerdown', onDocPointerdown, true);
2105
+
2106
+ function destroy() {
2107
+ if (st.destroyed) return;
2108
+ st.destroyed = true;
2109
+ closePopover({ focusTrigger: false });
2110
+ if (st.elapsedTimer) { clearInterval(st.elapsedTimer); st.elapsedTimer = null; }
2111
+ if (el.orb) el.orb.stop();
2112
+ doc.removeEventListener('keydown', onDocKeydown, true);
2113
+ doc.removeEventListener('pointerdown', onDocPointerdown, true);
2114
+ root.remove();
2115
+ }
2116
+
2117
+ return Object.freeze({
2118
+ root,
2119
+ open: openSheet,
2120
+ close: closeSheet,
2121
+ toggle: toggleSheet,
2122
+ isOpen: () => st.open,
2123
+ appendToComposer,
2124
+ pushServerFrame,
2125
+ onHello,
2126
+ ownsKey,
2127
+ destroy,
2128
+ });
2129
+ }