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