@worca/app 1.2.0-rc.3 → 1.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3736 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1589 -212
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +447 -29
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1499 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1304 -194
@@ -8,6 +8,12 @@ import { createThreadModel } from './ask-model.mjs';
8
8
  import { createMarkdownRenderer } from './ask-markdown.mjs';
9
9
  import { createThinkingOrb } from './thinking-orb.mjs';
10
10
  import { workflowPickerLabel } from './results-view.mjs';
11
+ import { renderAutoProposal, AUTO_PROPOSAL_ORDER_CARD } from './auto-proposal.mjs';
12
+ import { createRunProgressCard, snapshotFromState, PROGRESS_CARD_TYPE } from './ask-run-card.mjs';
13
+ import { buildTrace, scheduleTrace, playAssembly } from './auto-build.mjs';
14
+ import { buildNodeConfigRows, pruneNodeSelection, modifiedFieldsOf } from './node-tunables.mjs';
15
+ import { classifyLoops } from '../../src/shared/graph/loops.mjs';
16
+ import { portsFnFor } from '../../src/shared/graph/ports.mjs';
11
17
 
12
18
  /**
13
19
  * Cold-start pick, used ONLY until GET /api/ask/models resolves — and afterwards
@@ -85,7 +91,53 @@ export function shortcutLabel(win) {
85
91
  return /mac|iphone|ipad|ipod/i.test(platform) ? '⌘K' : 'Ctrl K';
86
92
  }
87
93
 
88
- export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, loadMarkdown, hljsLoader, storage, raf, now }) {
94
+ /**
95
+ * Sheet geometry shared with style.css: .ask-dock{padding:0 28px 26px} and
96
+ * .ask-sheet{width:min(821px,100%);height:min(669px,calc(100% - 20px))}. The
97
+ * user's size is clamped to [minW×minH, dock inner box]. The floor IS the
98
+ * stylesheet default: the sheet grows from what it always was and never shrinks
99
+ * below it, so every layout the fixed-size sheet was designed around (the
100
+ * non-wrapping composer row, the popovers written in 100vh terms) still holds.
101
+ * Border-box px throughout — the sheet has no padding.
102
+ */
103
+ export const ASK_SHEET_SIZE = Object.freeze({
104
+ defaultW: 821, defaultH: 669,
105
+ minW: 821, minH: 669,
106
+ dockPadX: 28, dockPadBottom: 26, topGap: 20,
107
+ });
108
+ /**
109
+ * Where the chip picker's panel sits inside the sheet (sheet-relative px, from
110
+ * sheet-relative chip edges). Every other .ask-pop is CSS-anchored (the header
111
+ * corner, or the composer box's inset — style.css --ask-col-inset); a band chip
112
+ * sits wherever the transcript scrolled it, and .ask-sheet
113
+ * clips (overflow:hidden), so a downward-only anchor chops the menu's Effort row
114
+ * off with no way to reach it. Prefer the space under the chip, flip above it
115
+ * when the menu would not fit, and clamp into the sheet when neither side has
116
+ * room — .ask-pop-chip's own max-height/overflow-y makes the rest reachable.
117
+ * A sheetH of 0 (jsdom measures nothing) skips the clamp: no fake geometry.
118
+ */
119
+ export function chipPickerTop({ top, bottom, panelH, sheetH, gap = 6 }) {
120
+ const below = bottom + gap;
121
+ const above = top - gap - panelH;
122
+ let t = (below + panelH <= sheetH - gap) || above < gap ? below : above;
123
+ if (sheetH > 0 && t + panelH > sheetH - gap) t = sheetH - panelH - gap;
124
+ return Math.max(0, t);
125
+ }
126
+
127
+ const SIZE_KEY = 'worca-cc.ask.size';
128
+ /** Out-of-turn / other-thread frames that can move a History row's dots: a run a
129
+ * chat follows changed (tracking) or a turn started/ended there (thinking). */
130
+ const THREADS_REFRESH_FRAMES = new Set(['ask-run-status', 'ask-start', 'ask-done', 'ask-error']);
131
+ const THREADS_REFRESH_MS = 250;
132
+ /** The pill's mark ↔ orb morph: the canvas tween (thinking-orb morphTo) runs on
133
+ * the same clocks as the CSS transitions on the two layers — .52s in, .8s out
134
+ * (style.css .ask-pill-mark rules). The settle fallback outlives the fade-out,
135
+ * for the case the transitionend never arrives. */
136
+ const PILL_MORPH_IN_MS = 520;
137
+ const PILL_MORPH_OUT_MS = 800;
138
+ const PILL_SETTLE_FALLBACK_MS = PILL_MORPH_OUT_MS + 150;
139
+
140
+ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
89
141
  const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
90
142
  const st = {
91
143
  open: false,
@@ -104,28 +156,39 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
104
156
  // #397: the thread's project/workspace scope. pinned:false = Auto (follow the
105
157
  // page — today's behaviour). label caches the display name once resolved.
106
158
  scope: { pinned: false, projectKey: null, workspaceId: null, label: null },
107
- popover: null, // {panel, trigger, onClose, build, refreshOn}
159
+ popover: null, // {panel, trigger, onClose, build, refreshOn, refresh}
160
+ threadsRefresh: null, // the debounce timer behind the History popover's ask-run-status refetch
108
161
  expandedAgents: new Set(),
109
162
  worktrees: [], // P4 §10: the chat's open worktrees (snapshot-fed)
110
163
  pinned: true,
111
164
  prevFocus: null,
165
+ size: readStoredSize(), // {w,h} the user's persisted sheet size (hoisted reader); null = stylesheet default
166
+ applied: null, // {w,h} the inline size currently on the sheet (clamped); null = default
167
+ drag: null, // the active resize gesture — see startResize()
112
168
  pendingFiles: [],
113
169
  sending: false,
114
170
  subscribedFor: null,
115
171
  elapsedTimer: null,
116
172
  elapsedStart: null,
173
+ pillOrbLive: false, // what the pill orb was last told — see syncPillOrb()
174
+ pillOrbSettle: null, // the morph-back's fallback timer, while one runs
117
175
  flushArmed: false,
118
176
  resyncing: false,
119
177
  firstOpenDone: false,
120
178
  destroyed: false,
121
179
  lastAnswerRender: 0,
122
180
  rowEls: null,
181
+ seenRows: new Set(), // message ids the transcript has already shown — see renderTranscript
123
182
  cardEls: null,
124
183
  cardOptions: null,
125
184
  catalogLoading: null,
126
185
  mdKicked: false,
127
186
  answerPending: null,
128
187
  rowPending: null,
188
+ progress: null, // Map<cardId, {ident, handle, rest, hydrating, nextHydrateAt}> — the live run cards
189
+ runTick: null,
190
+ runUnsub: null,
191
+ runPoked: false,
129
192
  };
130
193
  const el = {}; // element refs, filled by the builders
131
194
  const renderer = createMarkdownRenderer({ doc, load: loadMarkdown, hljsLoader });
@@ -161,6 +224,32 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
161
224
  else storage.removeItem('worca-cc.ask.thread');
162
225
  } catch { /* ignore */ }
163
226
  }
227
+ /** The persisted sheet size, or null when nothing usable is stored. Clamped later, against a live dock. */
228
+ function readStoredSize() {
229
+ try {
230
+ const raw = storage.getItem(SIZE_KEY);
231
+ const v = raw ? JSON.parse(raw) : null;
232
+ if (v && Number.isFinite(v.w) && Number.isFinite(v.h)) return { w: Math.round(v.w), h: Math.round(v.h) };
233
+ } catch { /* storage unavailable */ }
234
+ return null;
235
+ }
236
+ function storeSize(size) {
237
+ try {
238
+ if (size) storage.setItem(SIZE_KEY, JSON.stringify({ w: size.w, h: size.h }));
239
+ else storage.removeItem(SIZE_KEY);
240
+ } catch { /* ignore */ }
241
+ }
242
+
243
+ /**
244
+ * Size the composer textarea to its content, one line minimum, 120px maximum.
245
+ * Bound to the input event; every programmatic write to el.input.value (the
246
+ * post-send clear, appendToComposer) must call it too, since assignment
247
+ * fires no input event and the box would keep the previous draft's height.
248
+ */
249
+ function fitInput() {
250
+ el.input.style.height = 'auto';
251
+ el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
252
+ }
164
253
 
165
254
  // ---- tiny DOM helpers -----------------------------------------------------
166
255
  function make(tag, className, text) {
@@ -203,11 +292,28 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
203
292
 
204
293
  const pill = make('button', 'ask-pill');
205
294
  pill.type = 'button';
206
- const pillLogo = doc.createElement('img');
207
- pillLogo.className = 'ask-pill-logo';
208
- pillLogo.src = '/assets/worca-favicon.png';
209
- pillLogo.alt = '';
210
- pill.appendChild(pillLogo);
295
+ // The mark slot: the masked logo and the pill's OWN thinking orb stacked in
296
+ // one 22px host (a CSS mask clips children, so the orb cannot live under
297
+ // the masked span). Both exist from birth — .is-live morphs one into the
298
+ // other in CSS and syncPillOrb() runs the canvas only while there is
299
+ // something to paint. Its own instance on purpose: the transcript's orb
300
+ // (ensureThinking) is re-parented into each live row and cannot be shared.
301
+ const mark = make('span', 'ask-pill-mark');
302
+ mark.setAttribute('aria-hidden', 'true');
303
+ const pillLogo = make('span', 'ask-pill-logo');
304
+ pillLogo.setAttribute('aria-hidden', 'true');
305
+ mark.appendChild(pillLogo);
306
+ el.pillOrb = createThinkingOrb({ doc, win, size: 22 });
307
+ el.pillOrb.stop(); // the factory arms its loop; nothing is lit yet
308
+ el.pillOrb.morphTo(0, 0); // the dots wait on the centre for the first morph-in
309
+ // The morph-back ends when the orb layer's opacity fade does (the transform
310
+ // fade shares the clock, so one of the two is enough); a late event from a
311
+ // fade that a new turn aborted must not cut a live loop — hence the guard.
312
+ el.pillOrb.el.addEventListener('transitionend', (e) => {
313
+ if (e.target === el.pillOrb.el && e.propertyName === 'opacity' && !st.pillOrbLive) settlePillOrb();
314
+ });
315
+ mark.appendChild(el.pillOrb.el);
316
+ pill.appendChild(mark);
211
317
  pill.appendChild(make('span', 'ask-pill-label', 'Ask Worca'));
212
318
  pill.appendChild(make('span', 'ask-kbd', shortcutLabel(win)));
213
319
  pill.addEventListener('click', openSheet);
@@ -219,10 +325,8 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
219
325
  sheet.setAttribute('aria-label', 'Ask Worca');
220
326
 
221
327
  const header = make('header', 'ask-header');
222
- const logo = doc.createElement('img');
223
- logo.className = 'ask-header-logo';
224
- logo.src = '/assets/worca-favicon.png';
225
- logo.alt = '';
328
+ const logo = make('span', 'ask-header-logo');
329
+ logo.setAttribute('aria-hidden', 'true');
226
330
  header.appendChild(logo);
227
331
  el.title = make('div', 'ask-title', 'Ask Worca');
228
332
  // Header is logo → title → spacer → icon buttons. The #397 scope selector
@@ -242,6 +346,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
242
346
  el.transcript = make('div', 'ask-transcript');
243
347
  el.transcript.setAttribute('data-ask-scroll', '');
244
348
  el.transcript.addEventListener('scroll', updatePinFromScroll);
349
+ // The transcript is only the scrollport; every row lands in this column,
350
+ // which style.css caps (--ask-col-max) and centres once the sheet is
351
+ // dragged wider than the cap. Scroll/pin logic keeps reading el.transcript.
352
+ el.transcriptCol = make('div', 'ask-transcript-col');
353
+ el.transcript.appendChild(el.transcriptCol);
245
354
  sheet.appendChild(el.transcript);
246
355
 
247
356
  sheet.appendChild(buildComposer());
@@ -257,10 +366,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
257
366
  el.jump.hidden = true;
258
367
  el.jump.addEventListener('click', jumpToLatest);
259
368
  sheet.appendChild(el.jump);
369
+ for (const edge of ['n', 'e', 'w', 'ne', 'nw']) sheet.appendChild(buildResizeHandle(edge));
260
370
  dock.appendChild(sheet);
261
371
  dock.appendChild(pill);
262
372
  el.pill = pill;
263
373
  el.sheet = sheet;
374
+ el.dock = dock; // measured by dockInner(); `root` is TDZ here
264
375
  return dock;
265
376
  }
266
377
 
@@ -343,6 +454,50 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
343
454
  const streaming = !!(st.model && st.model.live());
344
455
  el.send.hidden = streaming;
345
456
  el.stop.hidden = !streaming;
457
+ // The collapsed launcher pill mirrors "Ask Worca is working": a live turn, a
458
+ // snapshot that reports one in flight (load() nulls live until a frame is
459
+ // adopted, so Stop alone would stay dark on a collapsed reload), or the
460
+ // POST→ask-start window (st.sending). The label shimmer and the mark↔orb
461
+ // morph are pure CSS on this class (.ask-pill.is-live .ask-pill-label and
462
+ // .ask-pill.is-live .ask-pill-mark), so a boundary costs one classList
463
+ // write plus syncPillOrb() — local rAF bookkeeping for the pill's canvas,
464
+ // idle when nothing changed. Keep every OTHER side effect out of here, see
465
+ // afterFrame()'s refreshWorktrees() note.
466
+ const inFlight = !!(st.model && st.model.inFlight && st.model.inFlight());
467
+ if (el.pill) { el.pill.classList.toggle('is-live', streaming || inFlight || !!st.sending); syncPillOrb(); }
468
+ }
469
+
470
+ /**
471
+ * The pill's mark ↔ orb morph, canvas half. CSS cross-fades and scales the
472
+ * two layers off .is-live; this runs the orb's rAF loop only while there is
473
+ * something to paint — live AND the pill visible — and drives the canvas
474
+ * tween (dots grow out of the centre on lighting, sink back on rest) on the
475
+ * same clocks. Hidden behind the open sheet the layers snap (display:none
476
+ * skips transitions), so the factor snaps with them: nothing replays when
477
+ * closeSheet() shows the pill again. The morph-back keeps painting until the
478
+ * opacity transitionend (fallback: a timer), then the loop is cut.
479
+ */
480
+ function syncPillOrb() {
481
+ if (!el.pillOrb) return;
482
+ const live = el.pill.classList.contains('is-live');
483
+ if (live !== st.pillOrbLive) {
484
+ st.pillOrbLive = live;
485
+ clearPillOrbSettle();
486
+ el.pillOrb.morphTo(live ? 1 : 0, el.pill.hidden ? 0 : (live ? PILL_MORPH_IN_MS : PILL_MORPH_OUT_MS));
487
+ if (!live && !el.pill.hidden) {
488
+ st.pillOrbSettle = setTimeout(settlePillOrb, PILL_SETTLE_FALLBACK_MS);
489
+ if (st.pillOrbSettle && typeof st.pillOrbSettle.unref === 'function') st.pillOrbSettle.unref();
490
+ }
491
+ }
492
+ if (!el.pill.hidden && (live || st.pillOrbSettle)) el.pillOrb.start();
493
+ else settlePillOrb();
494
+ }
495
+ function clearPillOrbSettle() {
496
+ if (st.pillOrbSettle) { clearTimeout(st.pillOrbSettle); st.pillOrbSettle = null; }
497
+ }
498
+ function settlePillOrb() {
499
+ clearPillOrbSettle();
500
+ if (el.pillOrb) el.pillOrb.stop();
346
501
  }
347
502
 
348
503
  function updateMeters() {
@@ -380,6 +535,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
380
535
  if (!text) return;
381
536
  st.sending = true;
382
537
  setComposerMsg(null);
538
+ updateSendStop(); // the pill lights the moment the user sends; Send/Stop do not move (nothing streams yet)
383
539
  try {
384
540
  let id = st.threadId;
385
541
  if (!id) {
@@ -430,6 +586,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
430
586
  // No provisional title from the prompt: the header keeps "Ask Worca" until
431
587
  // the ask-title frame lands (ask-model marks title dirty, flushExtra repaints).
432
588
  el.input.value = '';
589
+ fitInput(); // a programmatic clear fires no input event
433
590
  st.pendingFiles = [];
434
591
  renderChips();
435
592
  subscribe(id);
@@ -443,10 +600,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
443
600
 
444
601
  function buildComposer() {
445
602
  const wrap = make('div', 'ask-composer');
603
+ // The band is the padded outer strip; the rounded, bordered box inside it
604
+ // holds chips → textarea → msg → row and shares the transcript column's cap
605
+ // (style.css .ask-composer-box), so both centre together in a wide sheet.
606
+ const box = make('div', 'ask-composer-box');
607
+ el.composerBox = box;
608
+ wrap.appendChild(box);
446
609
 
447
610
  el.chips = make('div', 'ask-chips');
448
611
  el.chips.hidden = true;
449
- wrap.appendChild(el.chips);
612
+ box.appendChild(el.chips);
450
613
 
451
614
  el.input = doc.createElement('textarea');
452
615
  el.input.className = 'ask-input';
@@ -455,15 +618,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
455
618
  el.input.addEventListener('keydown', (e) => {
456
619
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
457
620
  });
458
- el.input.addEventListener('input', () => {
459
- el.input.style.height = 'auto';
460
- el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
461
- });
462
- wrap.appendChild(el.input);
621
+ el.input.addEventListener('input', fitInput);
622
+ box.appendChild(el.input);
463
623
 
464
624
  el.composerMsg = make('div', 'ask-composer-msg');
465
625
  el.composerMsg.hidden = true;
466
- wrap.appendChild(el.composerMsg);
626
+ box.appendChild(el.composerMsg);
467
627
 
468
628
  const row = make('div', 'ask-composer-row');
469
629
 
@@ -482,7 +642,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
482
642
  // independent of the page behind the sheet. It sits right after "+"
483
643
  // (attach → scope → spacer → meter …): the pill keeps its width (style.css
484
644
  // .ask-scope-btn flex:none), the spacer absorbs the slack. Its popover
485
- // (.ask-pop-scope) opens upward from the sheet's bottom-left.
645
+ // (.ask-pop-scope) opens above the composer box, flush with its left edge.
486
646
  const scopeBtn = make('button', 'ask-scope-btn');
487
647
  scopeBtn.type = 'button';
488
648
  scopeBtn.setAttribute('data-ask-scope-btn', '');
@@ -500,10 +660,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
500
660
  meter.setAttribute('data-ask-meter', '');
501
661
  el.meterTokens = make('span', 'ask-meter-tokens', '0 ctx');
502
662
  meter.appendChild(el.meterTokens);
503
- meter.appendChild(make('span', 'ask-meter-sep', '|'));
663
+ { const sep = make('span', 'ask-meter-sep', '|'); sep.setAttribute('aria-hidden', 'true'); meter.appendChild(sep); }
504
664
  el.meterCost = make('span', 'ask-meter-cost', '');
505
665
  meter.appendChild(el.meterCost);
506
- meter.appendChild(make('span', 'ask-meter-sep', '|'));
666
+ { const sep = make('span', 'ask-meter-sep', '|'); sep.setAttribute('aria-hidden', 'true'); meter.appendChild(sep); }
507
667
  row.appendChild(meter);
508
668
 
509
669
  const wtBtn = make('button', 'ask-agents-btn ask-wt-btn');
@@ -565,7 +725,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
565
725
  el.stop.addEventListener('click', stopTurn);
566
726
  row.appendChild(el.stop);
567
727
 
568
- wrap.appendChild(row);
728
+ box.appendChild(row);
569
729
  return wrap;
570
730
  }
571
731
 
@@ -580,11 +740,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
580
740
  st.open = true;
581
741
  st.prevFocus = doc.activeElement;
582
742
  el.pill.hidden = true;
743
+ syncPillOrb(); // nothing to paint behind the sheet
583
744
  el.sheet.hidden = false;
745
+ restoreSize(); // the sheet has a box now — clamp the stored size to the dock
584
746
  st.pinned = true;
585
747
  ensureFirstOpen();
586
748
  focusComposer();
587
749
  scheduleFlush();
750
+ repaintProgressCards({ hydrate: true });
588
751
  }
589
752
 
590
753
  /**
@@ -598,8 +761,8 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
598
761
  openSheet(); // no-op when already open…
599
762
  const cur = el.input.value;
600
763
  el.input.value = cur ? `${cur.replace(/\s*$/, '')}\n${add}` : add;
601
- // …so the autosize listener and the focus have to be driven here.
602
- el.input.dispatchEvent(new win.Event('input'));
764
+ // …so the autosize and the focus have to be driven here.
765
+ fitInput();
603
766
  focusComposer();
604
767
  try { el.input.selectionStart = el.input.selectionEnd = el.input.value.length; } catch { /* jsdom */ }
605
768
  return true;
@@ -611,6 +774,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
611
774
  st.open = false;
612
775
  el.sheet.hidden = true;
613
776
  el.pill.hidden = false;
777
+ syncPillOrb(); // still live? the orb loop comes back, whole
614
778
  const prev = st.prevFocus;
615
779
  st.prevFocus = null;
616
780
  if (prev && prev.isConnected && typeof prev.focus === 'function') { try { prev.focus(); return; } catch { /* fall through */ } }
@@ -619,6 +783,182 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
619
783
 
620
784
  function toggleSheet() { (st.open ? closeSheet : openSheet)(); }
621
785
 
786
+ // ---- resize ---------------------------------------------------------------
787
+ // The sheet is a bottom-anchored, horizontally centred flex child of the dock,
788
+ // so a top-edge drag is a pure height change and a side drag grows the width
789
+ // symmetrically about the centre (each edge moves half the delta, which keeps
790
+ // the grabbed edge under the cursor). Sizes are border-box px applied as
791
+ // inline width/height, so the stylesheet's default rule stays byte-identical
792
+ // and its max-width/max-height backstop still bounds a stale inline size.
793
+ //
794
+ // A gesture ends on pointerup/pointercancel, and — like graph/composer.mjs —
795
+ // on window blur, lostpointercapture (the sheet was hidden, the capture was
796
+ // taken) and a pointermove that reports no button held (the release landed
797
+ // outside the window and never reached us). All of those COMMIT: a half-done
798
+ // resize is still a size the user chose, unlike a half-drawn wire. Escape is
799
+ // the one CANCEL: the pre-drag size comes back and nothing is stored. Only a
800
+ // gesture that actually moved persists, and only the axis it dragged — the
801
+ // other axis keeps the stored preference, so a click on a dock-clamped sheet
802
+ // can never write the clamp back over a larger preference.
803
+ function buildResizeHandle(edge) {
804
+ const h = make('div', `ask-resize ask-resize-${edge}`);
805
+ h.setAttribute('data-ask-resize', edge);
806
+ h.setAttribute('aria-hidden', 'true');
807
+ h.addEventListener('pointerdown', (e) => startResize(e, edge, h));
808
+ h.addEventListener('lostpointercapture', () => { if (st.drag && st.drag.handle === h) finishResize(); });
809
+ h.addEventListener('dblclick', (e) => { e.preventDefault(); resetSize(); });
810
+ return h;
811
+ }
812
+
813
+ /** The dock's content box in px; zeros when there is no layout (hidden, detached, jsdom). */
814
+ function dockInner() {
815
+ const d = el.dock;
816
+ const w = d ? d.clientWidth : 0;
817
+ const h = d ? d.clientHeight : 0;
818
+ return {
819
+ w: w > 0 ? w - 2 * ASK_SHEET_SIZE.dockPadX : 0,
820
+ h: h > 0 ? h - ASK_SHEET_SIZE.dockPadBottom - ASK_SHEET_SIZE.topGap : 0,
821
+ };
822
+ }
823
+
824
+ /** Clamp to [min, dock inner]. The dock bound wins when the two conflict (a narrow viewport). */
825
+ function clampSize(size) {
826
+ const inner = dockInner();
827
+ const maxW = inner.w > 0 ? inner.w : Infinity;
828
+ const maxH = inner.h > 0 ? inner.h : Infinity;
829
+ return {
830
+ w: Math.round(Math.min(Math.max(size.w, ASK_SHEET_SIZE.minW), maxW)),
831
+ h: Math.round(Math.min(Math.max(size.h, ASK_SHEET_SIZE.minH), maxH)),
832
+ };
833
+ }
834
+
835
+ /** Write the inline size, or clear it (null) so min(821px,100%) rules again. */
836
+ function applySize(size) {
837
+ if (size) {
838
+ el.sheet.style.width = `${size.w}px`;
839
+ el.sheet.style.height = `${size.h}px`;
840
+ } else {
841
+ el.sheet.style.removeProperty('width');
842
+ el.sheet.style.removeProperty('height');
843
+ }
844
+ st.applied = size;
845
+ }
846
+
847
+ /** Re-apply the persisted preference against the current dock — on open and on window resize. */
848
+ function restoreSize() {
849
+ applySize(st.size ? clampSize(st.size) : null);
850
+ }
851
+
852
+ /** The sheet's live border-box size; the applied/default size when there is no layout. */
853
+ function currentSize() {
854
+ const w = el.sheet.offsetWidth;
855
+ const h = el.sheet.offsetHeight;
856
+ if (w > 0 && h > 0) return { w, h };
857
+ return st.applied ? { w: st.applied.w, h: st.applied.h } : { w: ASK_SHEET_SIZE.defaultW, h: ASK_SHEET_SIZE.defaultH };
858
+ }
859
+
860
+ function startResize(e, edge, handle) {
861
+ if (st.drag || st.destroyed || (e.button != null && e.button !== 0)) return;
862
+ const start = currentSize();
863
+ st.drag = {
864
+ edge, handle, pointerId: e.pointerId, x: e.clientX, y: e.clientY, w: start.w, h: start.h,
865
+ moved: false, // set by the first pointermove that applies a size
866
+ before: st.applied ? { w: st.applied.w, h: st.applied.h } : null, // what Escape restores
867
+ };
868
+ handle.classList.add('is-active');
869
+ el.sheet.classList.add('is-resizing');
870
+ // Capture is a bonus, never a precondition (Chrome throws for a synthetic
871
+ // pointerId; jsdom has no such method) — the document listeners carry the
872
+ // gesture either way, exactly like graph/composer.mjs.
873
+ try { handle.setPointerCapture?.(e.pointerId); } catch { /* synthetic pointer */ }
874
+ doc.addEventListener('pointermove', onResizeMove);
875
+ doc.addEventListener('pointerup', onResizeEnd);
876
+ doc.addEventListener('pointercancel', onResizeEnd);
877
+ win.addEventListener('blur', onResizeBlur);
878
+ e.preventDefault(); // no text selection / focus steal mid-drag
879
+ }
880
+
881
+ function samePointer(g, e) {
882
+ return g.pointerId == null || e.pointerId == null || e.pointerId === g.pointerId;
883
+ }
884
+
885
+ function onResizeMove(e) {
886
+ const g = st.drag;
887
+ if (!g || !samePointer(g, e)) return;
888
+ if (e.buttons === 0) { finishResize(); return; } // the release never reached us
889
+ g.moved = true;
890
+ const dx = e.clientX - g.x;
891
+ const dy = e.clientY - g.y;
892
+ let w = g.w;
893
+ let h = g.h;
894
+ if (g.edge === 'w' || g.edge === 'nw') w = g.w - 2 * dx; // left edge: leftwards grows
895
+ if (g.edge === 'e' || g.edge === 'ne') w = g.w + 2 * dx; // right edge: rightwards grows
896
+ if (g.edge === 'n' || g.edge === 'ne' || g.edge === 'nw') h = g.h - dy; // top edge: upwards grows
897
+ applySize(clampSize({ w, h }));
898
+ }
899
+
900
+ function onResizeEnd(e) {
901
+ const g = st.drag;
902
+ if (!g || !samePointer(g, e)) return;
903
+ finishResize();
904
+ }
905
+
906
+ function onResizeBlur() { finishResize(); }
907
+
908
+ /** Idempotent; also run from destroy() so a mid-drag unmount leaves no document listeners. */
909
+ function finishResize() {
910
+ const g = endResize();
911
+ if (!g || !g.moved || !st.applied) return;
912
+ // Persist the dragged axis only; the other keeps the stored preference (or,
913
+ // with none stored, the size it had) — the clamp is never written back.
914
+ const movesW = g.edge !== 'n';
915
+ const movesH = g.edge !== 'e' && g.edge !== 'w';
916
+ const prev = st.size;
917
+ st.size = {
918
+ w: movesW || !prev ? st.applied.w : prev.w,
919
+ h: movesH || !prev ? st.applied.h : prev.h,
920
+ };
921
+ storeSize(st.size);
922
+ }
923
+
924
+ /** Escape: the pre-drag size comes back and nothing is stored. */
925
+ function cancelResize() {
926
+ const g = endResize();
927
+ if (!g) return;
928
+ applySize(g.before);
929
+ }
930
+
931
+ /** Tear the gesture down (listeners, classes, capture) and hand it back; null when none. */
932
+ function endResize() {
933
+ const g = st.drag;
934
+ if (!g) return null;
935
+ st.drag = null;
936
+ doc.removeEventListener('pointermove', onResizeMove);
937
+ doc.removeEventListener('pointerup', onResizeEnd);
938
+ doc.removeEventListener('pointercancel', onResizeEnd);
939
+ win.removeEventListener('blur', onResizeBlur);
940
+ g.handle.classList.remove('is-active');
941
+ el.sheet.classList.remove('is-resizing');
942
+ try { if (g.handle.hasPointerCapture?.(g.pointerId)) g.handle.releasePointerCapture(g.pointerId); } catch { /* already gone */ }
943
+ return g;
944
+ }
945
+
946
+ /** Double-click on any grip: back to the stylesheet default and forget the stored size. */
947
+ function resetSize() {
948
+ finishResize();
949
+ st.size = null;
950
+ applySize(null);
951
+ storeSize(null);
952
+ }
953
+
954
+ /** Window resize or dock resize (the rail toggling, the dock's slide): re-clamp — never under a held pointer.
955
+ * A card graph re-measures on EVERY such change, not only when a stored size is being re-clamped. */
956
+ function onWinResize() {
957
+ if (st.destroyed || !st.open || st.drag) return;
958
+ if (st.size) restoreSize();
959
+ relayoutCards();
960
+ }
961
+
622
962
  // ---- keyboard + pointer routing ------------------------------------------
623
963
  function containsNode(rootEl, t) { return !!(t && t.nodeType && rootEl.contains(t)); }
624
964
 
@@ -633,6 +973,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
633
973
 
634
974
  function onDocKeydown(e) {
635
975
  if (st.destroyed) return;
976
+ if (st.drag && e.key === 'Escape') { e.preventDefault(); cancelResize(); return; }
636
977
  if (isToggleCombo(e)) {
637
978
  if (e.repeat || e.isComposing) return;
638
979
  e.preventDefault();
@@ -665,6 +1006,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
665
1006
  const p = st.popover;
666
1007
  if (!p) return;
667
1008
  st.popover = null;
1009
+ if (st.threadsRefresh) { clearTimeout(st.threadsRefresh); st.threadsRefresh = null; }
668
1010
  p.panel.remove();
669
1011
  if (p.onClose) { try { p.onClose(); } catch { /* ignore */ } }
670
1012
  if (focusTrigger) { try { p.trigger.focus(); } catch { /* ignore */ } }
@@ -686,7 +1028,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
686
1028
  else if ((e.key === 'Enter' || e.key === ' ') && idx >= 0) { e.preventDefault(); items[idx].click(); }
687
1029
  }
688
1030
 
689
- function openPopover({ panelClass, trigger, build, onClose, refreshOn }) {
1031
+ function openPopover({ panelClass, trigger, build, onClose, refreshOn, refresh }) {
690
1032
  if (st.popover && st.popover.trigger === trigger) { closePopover({ focusTrigger: false }); return null; }
691
1033
  closePopover({ focusTrigger: false });
692
1034
  const panel = make('div', `ask-pop ${panelClass}`);
@@ -696,7 +1038,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
696
1038
  el.sheet.appendChild(panel);
697
1039
  // refreshOn(dirty) → true re-runs build() on that flush (flushExtra), so an
698
1040
  // OPEN popover follows the live meters / worktrees instead of freezing at open.
699
- st.popover = { panel, trigger, onClose: onClose || null, build, refreshOn: refreshOn || null };
1041
+ // refresh(panel) is the server-fed twin: scheduleThreadsRefresh calls it
1042
+ // (debounced) on out-of-turn frames the model never sees.
1043
+ st.popover = { panel, trigger, onClose: onClose || null, build, refreshOn: refreshOn || null, refresh: refresh || null };
700
1044
  const first = menuItems(panel)[0];
701
1045
  if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
702
1046
  return panel;
@@ -745,8 +1089,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
745
1089
  head.appendChild(meter);
746
1090
  p.appendChild(head);
747
1091
  },
1092
+ refresh: (p) => loadThreadRows(p, meter),
748
1093
  });
749
1094
  if (!panel) return;
1095
+ loadThreadRows(panel, meter);
1096
+ }
1097
+
1098
+ /** Fetch the list and (re)render the rows under the pinned caption. On a
1099
+ * refresh the old rows are replaced in place — same panel node, the focused
1100
+ * row keeps focus by index — so the dots follow the server while it is open. */
1101
+ function loadThreadRows(panel, meter) {
750
1102
  Promise.resolve()
751
1103
  .then(() => fetch('/api/ask/threads?limit=50'))
752
1104
  .then((r) => (r && r.ok ? r.json() : { threads: [] }))
@@ -757,11 +1109,34 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
757
1109
  // `total` is EVERY saved chat (the route caps rows at limit); an older
758
1110
  // server without it degrades to the page size.
759
1111
  if (meter) meter.textContent = fmtChats(Number.isInteger(total) && total >= 0 ? total : rows.length);
760
- renderThreadRows(panel, rows);
1112
+ const stale = panel.querySelectorAll(':scope > .ask-threads-list, :scope > .ask-pop-empty');
1113
+ const refreshing = stale.length > 0;
1114
+ // First load focuses the first row (menu semantics). A refresh keeps the
1115
+ // focused row by index, and leaves focus alone when it sits elsewhere.
1116
+ let focusIndex = 0;
1117
+ if (refreshing) focusIndex = panel.contains(doc.activeElement) ? Math.max(0, menuItems(panel).indexOf(doc.activeElement)) : null;
1118
+ for (const n of stale) n.remove();
1119
+ renderThreadRows(panel, rows, focusIndex);
761
1120
  });
762
1121
  }
763
1122
 
764
- function renderThreadRows(panel, threads) {
1123
+ /** The History popover is open and a run some chat follows just moved: refetch
1124
+ * the list (debounced — a run transition fans out one frame per linked thread,
1125
+ * and a chat may follow several runs, so a single terminal status never flips a
1126
+ * dot directly). Sits BEFORE pushServerFrame's threadId filter: the frame is
1127
+ * usually for ANOTHER chat. A turn starting/ending elsewhere arms the thinking
1128
+ * dot the same way. */
1129
+ function scheduleThreadsRefresh() {
1130
+ const pop = st.popover;
1131
+ if (!pop || typeof pop.refresh !== 'function' || st.threadsRefresh) return;
1132
+ st.threadsRefresh = setTimeout(() => {
1133
+ st.threadsRefresh = null;
1134
+ if (st.popover === pop) pop.refresh(pop.panel);
1135
+ }, THREADS_REFRESH_MS);
1136
+ }
1137
+
1138
+ /** @param {number|null} focusIndex row to focus after the render; null leaves focus alone */
1139
+ function renderThreadRows(panel, threads, focusIndex = 0) {
765
1140
  if (!threads.length) {
766
1141
  panel.appendChild(make('div', 'ask-pop-empty', 'No saved chats.'));
767
1142
  return;
@@ -773,11 +1148,13 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
773
1148
  for (const t of threads) {
774
1149
  const row = make('div', 'ask-thread-row');
775
1150
  const pick = menuItem('ask-thread-pick', () => { closePopover({ focusTrigger: false }); switchThread(t.id); });
776
- // The dot leads the row, sitting against the title where it reads as "this
777
- // chat is live" -- .ask-thread-dot collapses it (display:none) unless the
778
- // live arm joins it, so an idle row leaves no empty gutter and its title
1151
+ // Two dots lead the row, sitting against the title: green = the chat's own
1152
+ // turn is thinking, violet = the chat follows a live run. Each span is
1153
+ // always emitted and .ask-thread-dot collapses it (display:none) unless
1154
+ // its arm joins it, so an idle row leaves no empty gutter and its title
779
1155
  // starts at the left edge. The date rides the meter line under the title.
780
1156
  pick.appendChild(make('span', `ask-dot ask-thread-dot${t.inFlight ? ' ask-dot-live' : ''}`));
1157
+ pick.appendChild(make('span', `ask-dot ask-thread-dot${t.tracking ? ' ask-dot-track' : ''}`));
781
1158
  const col = make('span', 'ask-thread-col');
782
1159
  // A null title = the haiku title has not landed yet (the message route
783
1160
  // stamps nothing); "New chat" is the same label the turn falls back to.
@@ -789,8 +1166,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
789
1166
  list.appendChild(row);
790
1167
  }
791
1168
  panel.appendChild(list);
792
- const first = menuItems(panel)[0];
793
- if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
1169
+ if (focusIndex === null) return;
1170
+ const items = menuItems(panel);
1171
+ const target = items[Math.min(focusIndex, items.length - 1)];
1172
+ if (target) { target.tabIndex = 0; try { target.focus(); } catch { /* ignore */ } }
794
1173
  }
795
1174
 
796
1175
  // ---- catalog + picker (D8) ------------------------------------------------
@@ -1218,11 +1597,13 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1218
1597
  loadGen += 1; // a load still in flight must not resurrect the old thread
1219
1598
  st.threadId = null;
1220
1599
  st.model = null;
1600
+ st.seenRows = new Set(); // a different chat: its rows have never been shown
1221
1601
  st.subscribedFor = null;
1222
1602
  stopElapsed();
1223
1603
  storeThread(null);
1224
1604
  el.title.textContent = 'Ask Worca';
1225
1605
  applyThreadScope(null); // #397: a brand-new chat starts on Auto
1606
+ pruneCardEls(); // st.model is already null — renderTranscript's keep set cannot see the old ids
1226
1607
  renderTranscript();
1227
1608
  updateMeters();
1228
1609
  setWorktrees([]);
@@ -1297,8 +1678,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1297
1678
  // by card id and REUSED across message re-renders, so streaming updates and
1298
1679
  // proposed re-emits never clobber what the user typed. Only a STATE change
1299
1680
  // (started/dismissed/failed) builds a fresh terminal element.
1300
- function loadCardOptions() {
1301
- if (st.cardOptions) return st.cardOptions;
1681
+ // The four lists are cached for the panel's lifetime (the scope label / popover read
1682
+ // them on every open). `fresh: true` refetches and REPLACES the cache — the run card
1683
+ // builds with it, because a workflow saved seconds earlier in this chat is not in the
1684
+ // cached list and fillSelect would silently leave the select on another row.
1685
+ function loadCardOptions({ fresh = false } = {}) {
1686
+ if (st.cardOptions && !fresh) return st.cardOptions;
1302
1687
  const grab = (url, key) => Promise.resolve()
1303
1688
  .then(() => fetch(url))
1304
1689
  .then((r) => (r && r.ok ? r.json() : null))
@@ -1332,10 +1717,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1332
1717
  if (value != null && [...select.options].some((o) => o.value === value)) select.value = value;
1333
1718
  }
1334
1719
 
1720
+ /** Returns the fill promise so a caller can re-read the select once the list lands. */
1335
1721
  function loadBranchesInto(select, projectDir, want) {
1336
1722
  fillSelect(select, [{ value: '', label: 'current branch (auto)' }], '');
1337
- if (!projectDir) return;
1338
- Promise.resolve()
1723
+ if (!projectDir) return Promise.resolve();
1724
+ return Promise.resolve()
1339
1725
  .then(() => fetch(`/api/branches?projectDir=${encodeURIComponent(projectDir)}`))
1340
1726
  .then((r) => (r && r.ok ? r.json() : null))
1341
1727
  .catch(() => null)
@@ -1349,28 +1735,529 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1349
1735
 
1350
1736
  function wsBasename(p) { return String(p || '').replace(/\/+$/, '').split('/').pop() || String(p || ''); }
1351
1737
 
1738
+ // ---- Run proposal card v2 (spec 2026-09-06-ask-run-card-v2 §7.3) --------------------------------------------------
1739
+ const RP_ACC = new Set(['violet', 'blue', 'green', 'peach', 'red', 'amber']);
1740
+ function rpField(labelText, control, hint) {
1741
+ const f = make('div', 'ask-rp-field');
1742
+ const l = make('span', 'ask-rp-label', labelText);
1743
+ if (hint) l.appendChild(make('span', 'ask-rp-hint', ` · ${hint}`));
1744
+ f.append(l, control);
1745
+ return f;
1746
+ }
1747
+ function rpSelect(className, ariaLabel) {
1748
+ const s = doc.createElement('select');
1749
+ s.className = className;
1750
+ s.setAttribute('aria-label', ariaLabel);
1751
+ return s;
1752
+ }
1753
+ /** "3 agents · 1 loop · Review → Implement, max 3 cycles" from a v2 template + registry (v1: agents only).
1754
+ * Loop wires and the cycle budget follow New Pipeline's buildGraphWireRows: classifyLoops decides what is a
1755
+ * loop (needs the registry's ported metas), runConfig.wires[id].maxCycles beats the template's config. */
1756
+ function workflowDesc(wf, registry, runConfig) {
1757
+ if (!wf) return '';
1758
+ if (Array.isArray(wf.nodes)) {
1759
+ const agents = wf.nodes.filter((n) => n && n.kind === 'agent');
1760
+ const label = (id) => { const n = agents.find((a) => a.id === id); const m = n && registry && registry[n.key]; return (m && m.displayName) || (n && n.key) || id; };
1761
+ const { loopWireIds } = classifyLoops(wf, portsFnFor(registry || {}));
1762
+ const savedWires = (runConfig && runConfig.wires) || {};
1763
+ const loops = (wf.wires || []).filter((w) => w && loopWireIds.has(w.id));
1764
+ const parts = [`${agents.length} agent${agents.length === 1 ? '' : 's'}`, `${loops.length} loop${loops.length === 1 ? '' : 's'}`];
1765
+ for (const w of loops) {
1766
+ const n = Number((savedWires[w.id] || {}).maxCycles);
1767
+ const cfg = Number(w.config && w.config.maxCycles);
1768
+ const max = Number.isFinite(n) && n >= 1 ? n : (Number.isFinite(cfg) && cfg >= 1 ? cfg : 3);
1769
+ parts.push(`${label(w.from.node)} → ${label(w.to.node)}, max ${max} cycles`);
1770
+ }
1771
+ return parts.join(' · ');
1772
+ }
1773
+ const n = (wf.steps || []).flat().length;
1774
+ return `${n} agent${n === 1 ? '' : 's'}`;
1775
+ }
1776
+
1777
+ async function fetchJsonOk(url) {
1778
+ try { const r = await fetch(url); return r && r.ok ? await r.json() : null; } catch { return null; }
1779
+ }
1780
+ /** The lane's three sources (spec D5): workflow template, registry, per-project config. null = unusable. */
1781
+ async function loadLane(workflowId, projectDir) {
1782
+ const qs = projectDir ? `?projectDir=${encodeURIComponent(projectDir)}` : '';
1783
+ const [wf, agents, cfg] = await Promise.all([
1784
+ fetchJsonOk(`/api/workflows/${encodeURIComponent(workflowId)}`), fetchJsonOk('/api/agents'), fetchJsonOk(`/api/config${qs}`),
1785
+ ]);
1786
+ const registry = agents && Array.isArray(agents.agents) ? Object.fromEntries(agents.agents.map((a) => [a.key, a])) : {};
1787
+ if (!wf || !(Array.isArray(wf.nodes) || Array.isArray(wf.steps)) || !Object.keys(registry).length || !cfg) return null;
1788
+ const config = (cfg.config && typeof cfg.config === 'object') ? cfg.config : { steps: {}, customModels: [] };
1789
+ const runConfig = (config.workflows && config.workflows[workflowId]) || { nodes: {}, feedbacks: {} };
1790
+ const rows = buildNodeConfigRows(wf, registry, runConfig, workflowId === 'wf_default' ? { legacySteps: config.steps || {} } : {});
1791
+ return { wf, registry, runConfig, rows, edits: {}, editable: !!projectDir,
1792
+ models: Array.isArray(cfg.models) ? cfg.models : [], efforts: Array.isArray(cfg.efforts) ? cfg.efforts : [],
1793
+ subagentModels: Array.isArray(cfg.subagentModels) ? cfg.subagentModels : [] };
1794
+ }
1795
+ const laneEffective = (lane, row) => ({ ...row, ...(lane.edits[row.nodeId] || {}) });
1796
+ const laneCaps = (row) => ({ asksQuestions: row.askQuestions !== null, questionsLocked: row.questionsLocked });
1797
+ const laneEditedRows = (lane) => lane.rows.filter((r) => lane.edits[r.nodeId]);
1798
+ const laneOverrideCount = (lane) => lane.rows.filter((r) => modifiedFieldsOf(laneEffective(lane, r), r.def, laneCaps(r)).length).length;
1799
+ const modelLabel = (lane, id) => { const m = lane.models.find((x) => x.id === id); return m ? (m.label || m.id).replace(' (1M)', '') : id; };
1800
+ function laneSummary(lane) {
1801
+ const counts = new Map();
1802
+ let fan = 0;
1803
+ for (const r of lane.rows) {
1804
+ const c = laneEffective(lane, r);
1805
+ const k = c.model ? modelLabel(lane, c.model) : 'inherit';
1806
+ counts.set(k, (counts.get(k) || 0) + 1);
1807
+ if (c.fanOut) fan++;
1808
+ }
1809
+ return `${lane.rows.length} agents · ${[...counts].map(([k, v]) => `${k} ×${v}`).join(' · ')}${fan ? ` · ${fan} fan-out` : ''}`;
1810
+ }
1811
+ function laneSet(lane, row, patch) {
1812
+ const next = { ...(lane.edits[row.nodeId] || {}), ...patch };
1813
+ for (const k of Object.keys(next)) if (next[k] === row[k]) delete next[k]; // back to the proposal = no edit
1814
+ if (Object.keys(next).length) lane.edits[row.nodeId] = next; else delete lane.edits[row.nodeId];
1815
+ }
1816
+ /** D1: persist every edited row through the New Pipeline writers' bodies (app.js saveStep / saveNode), pruned to
1817
+ * inherit. Returns an error line or null. Nothing is written for a workspace target or an unloaded lane. */
1818
+ async function saveLaneEdits(local) {
1819
+ const lane = local.lane;
1820
+ const projectDir = local.projectDir();
1821
+ if (!lane || !lane.editable || !projectDir) return null;
1822
+ const workflowId = local.workflowId();
1823
+ for (const row of laneEditedRows(lane)) {
1824
+ const patch = pruneNodeSelection(row, lane.edits[row.nodeId]);
1825
+ const body = row.role
1826
+ ? { projectDir, step: row.role, ...patch }
1827
+ : { projectDir, workflowId, nodes: { [row.nodeId]: patch } };
1828
+ let res = null;
1829
+ try {
1830
+ res = await fetch('/api/config', { method: row.role ? 'POST' : 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1831
+ } catch { return `network error saving ${row.label}`; }
1832
+ if (!res || !res.ok) {
1833
+ let msg = `request failed (${res ? res.status : '?'})`;
1834
+ try { const b = await res.json(); if (b && b.error) msg = b.error; } catch { /* keep */ }
1835
+ return `could not save ${row.label}: ${msg}`;
1836
+ }
1837
+ }
1838
+ return null;
1839
+ }
1840
+ const lanePending = (local) => !!(local.lane && local.lane.editable && laneEditedRows(local.lane).length);
1841
+ const RP_EXTRAS_MAX_BYTES = 5 * 1024 * 1024; // D3: the JSON body cap is 8 MB and base64 grows by a third
1842
+ /** The pills as /api/run extras. {extras} or {error}. */
1843
+ async function collectCardExtras(local) {
1844
+ const pills = local.pills || [];
1845
+ if (!pills.length) return { extras: [] };
1846
+ if (pills.reduce((n, p) => n + (p.bytes || 0), 0) > RP_EXTRAS_MAX_BYTES) return { error: 'attachments exceed 5 MB — remove one' };
1847
+ const extras = [];
1848
+ for (const p of pills) {
1849
+ let res = null;
1850
+ try { res = await fetch(`/api/ask/threads/${st.threadId}/attachments/${p.id}`); } catch { res = null; }
1851
+ if (!res || !res.ok || typeof res.arrayBuffer !== 'function') return { error: `could not read attachment ${p.name}` };
1852
+ let buf = null;
1853
+ try { buf = await res.arrayBuffer(); } catch { return { error: `could not read attachment ${p.name}` }; }
1854
+ extras.push({ name: p.name, dataBase64: bytesToBase64(new Uint8Array(buf)) }); // the composer's helper (:345) takes a view
1855
+ }
1856
+ return { extras };
1857
+ }
1858
+ const cardPending = (local) => lanePending(local) || !!(local.pills && local.pills.length);
1859
+ function rpSwitch(ctl, label, on, locked, onChange, editable) {
1860
+ const wrap = make('label', 'ask-rp-ctl');
1861
+ wrap.appendChild(make('span', null, label));
1862
+ const sw = make('span', 'ask-rp-sw');
1863
+ const cb = doc.createElement('input');
1864
+ cb.type = 'checkbox';
1865
+ cb.checked = !!on;
1866
+ cb.disabled = !!locked || !editable;
1867
+ cb.setAttribute('data-ctl', ctl);
1868
+ cb.setAttribute('aria-label', label);
1869
+ cb.addEventListener('change', () => onChange(cb.checked));
1870
+ sw.append(cb, make('span', 'ask-rp-knob'));
1871
+ if (locked) wrap.title = 'Fixed for this agent';
1872
+ wrap.appendChild(sw);
1873
+ return wrap;
1874
+ }
1875
+ /** Paint the lane section: header (sub-line + Reset), tiles, footer. Re-run on every edit.
1876
+ * `lc` = { summary: <span>, workflowId(): string }. lane === null → the unusable state. */
1877
+ function renderLane(laneSec, lane, lc, loadingText = null) {
1878
+ laneSec.replaceChildren();
1879
+ const head = make('div', 'ask-rp-sec-head');
1880
+ const sub = make('span', 'ask-rp-sec-sub');
1881
+ const reset = make('button', 'ask-rp-mini', 'Reset to proposal');
1882
+ reset.type = 'button';
1883
+ head.append(make('span', 'ask-rp-sec-title', 'Agents'), sub, reset);
1884
+ laneSec.appendChild(head);
1885
+ if (!lane) {
1886
+ sub.textContent = '';
1887
+ reset.hidden = true;
1888
+ laneSec.appendChild(make('div', 'ask-rp-lane-msg', loadingText || 'Could not load agent settings.'));
1889
+ lc.summary.textContent = '';
1890
+ return;
1891
+ }
1892
+ const edited = laneEditedRows(lane).length;
1893
+ const over = laneOverrideCount(lane);
1894
+ sub.textContent = edited
1895
+ ? `you changed ${edited} agent${edited > 1 ? 's' : ''} · ${over} override${over === 1 ? '' : 's'} of the workflow defaults`
1896
+ : `proposed by Worca · ${over} override${over === 1 ? '' : 's'} of the workflow defaults`;
1897
+ reset.hidden = !edited;
1898
+ reset.addEventListener('click', () => { lane.edits = {}; renderLane(laneSec, lane, lc); });
1899
+ const box = make('div', 'ask-rp-agents');
1900
+ const opt = (v, t) => { const o = doc.createElement('option'); o.value = v; o.textContent = t; return o; };
1901
+ lane.rows.forEach((row, i) => {
1902
+ const c = laneEffective(lane, row);
1903
+ const changed = !!lane.edits[row.nodeId];
1904
+ const tile = make('div', `ask-rp-tile${changed ? ' mod' : ''}`);
1905
+ tile.dataset.nodeId = row.nodeId;
1906
+ const l1 = make('div', 'ask-rp-tile-l1');
1907
+ l1.appendChild(make('span', `ask-rp-acc${RP_ACC.has(row.color) ? ` ${row.color}` : ''}`));
1908
+ const name = make('div', 'ask-rp-name');
1909
+ name.appendChild(make('b', null, row.label));
1910
+ const small = make('small');
1911
+ // "step N" = position in the lane (launch order); row.stepIndex ranks the task card as 0.
1912
+ if (changed) { small.appendChild(make('span', 'ask-rp-m', 'edited')); small.appendChild(doc.createTextNode(` · step ${i + 1}`)); }
1913
+ else small.textContent = `step ${i + 1} · ${row.modified ? 'project override' : 'workflow default'}`;
1914
+ name.appendChild(small);
1915
+ l1.appendChild(name);
1916
+ // model
1917
+ const sel = rpSelect('ask-rp-model', `Model for ${row.label}`);
1918
+ sel.appendChild(opt('', 'inherit (workflow default)'));
1919
+ for (const m of lane.models) if (!m.hidden || m.id === c.model) sel.appendChild(opt(m.id, m.label || m.id));
1920
+ sel.value = c.model || '';
1921
+ sel.disabled = !lane.editable;
1922
+ sel.addEventListener('change', () => {
1923
+ const mid = sel.value;
1924
+ const list = (lane.models.find((m) => m.id === mid) || {}).efforts || [];
1925
+ const keep = list.includes(c.effort) ? c.effort : (list[1] || list[0] || ''); // the qpanel's rule (app.js buildTunablesTable)
1926
+ laneSet(lane, row, { model: mid, effort: mid ? keep : '' });
1927
+ renderLane(laneSec, lane, lc);
1928
+ });
1929
+ l1.appendChild(sel);
1930
+ // effort pills
1931
+ const eff = make('div', `ask-rp-eff${c.model ? '' : ' unset'}`);
1932
+ eff.setAttribute('role', 'radiogroup');
1933
+ eff.setAttribute('aria-label', `Effort for ${row.label}`);
1934
+ const offered = (lane.models.find((m) => m.id === c.model) || {}).efforts || [];
1935
+ for (const e of lane.efforts) {
1936
+ const b = make('button', `ask-rp-effbtn${e === c.effort ? ' on' : ''}`, e);
1937
+ b.type = 'button';
1938
+ b.setAttribute('role', 'radio');
1939
+ b.setAttribute('aria-checked', String(e === c.effort));
1940
+ b.disabled = !lane.editable || !offered.includes(e);
1941
+ if (!offered.includes(e) && c.model) b.title = `Not offered by ${modelLabel(lane, c.model)}`;
1942
+ b.addEventListener('click', () => { laneSet(lane, row, { effort: e }); renderLane(laneSec, lane, lc); });
1943
+ eff.appendChild(b);
1944
+ }
1945
+ l1.appendChild(eff);
1946
+ tile.appendChild(l1);
1947
+ // line 2
1948
+ const l2 = make('div', 'ask-rp-tile-l2');
1949
+ l2.appendChild(rpSwitch('fanOut', 'fan-out', c.fanOut, false, (v) => { laneSet(lane, row, { fanOut: v }); renderLane(laneSec, lane, lc); }, lane.editable));
1950
+ const subWrap = make('label', 'ask-rp-ctl');
1951
+ subWrap.appendChild(make('span', null, 'sub-agents'));
1952
+ const subSel = rpSelect('ask-rp-subagent', `Sub-agent model for ${row.label}`);
1953
+ subSel.appendChild(opt('', 'subs: default (agent picks)'));
1954
+ for (const v of lane.subagentModels) subSel.appendChild(opt(v, v === 'auto' ? 'subs: agent picks' : `subs: ${v}`));
1955
+ subSel.value = lane.subagentModels.includes(c.subagentModel) ? c.subagentModel : '';
1956
+ subSel.disabled = !lane.editable;
1957
+ subSel.title = 'Model for the sub-agents this node spawns (needs fan-out)';
1958
+ subSel.addEventListener('change', () => { laneSet(lane, row, { subagentModel: subSel.value }); renderLane(laneSec, lane, lc); });
1959
+ subWrap.appendChild(subSel);
1960
+ l2.appendChild(subWrap);
1961
+ if (row.askQuestions !== null) {
1962
+ l2.appendChild(rpSwitch('questions', 'questions', c.askQuestions, row.questionsLocked, (v) => { laneSet(lane, row, { askQuestions: v }); renderLane(laneSec, lane, lc); }, lane.editable));
1963
+ }
1964
+ tile.appendChild(l2);
1965
+ box.appendChild(tile);
1966
+ });
1967
+ const foot = make('div', 'ask-rp-agents-foot');
1968
+ foot.textContent = !lane.editable
1969
+ ? 'Agent settings are per project — pick a project target to edit them.'
1970
+ : edited ? `Edits become this project's defaults for ${lane.wf.name || lc.workflowId()}` : 'Everything at the workflow default';
1971
+ box.appendChild(foot);
1972
+ laneSec.appendChild(box);
1973
+ lc.summary.textContent = laneSummary(lane);
1974
+ }
1975
+
1976
+ /** The stub states. A `started` run — and a `failed` one that HAS a runId — renders the live progress
1977
+ * card instead (buildProgressCard), so neither reaches here. */
1352
1978
  function buildCardTerminal(block) {
1353
1979
  const card = block.card || {};
1354
- if (block.state === 'started') {
1355
- const n = make('div', 'ask-card ask-card-started');
1356
- n.appendChild(make('span', null, `Run started — ${card.title || card.brief || 'run'} `));
1357
- const a = make('a', 'ask-card-link', 'open');
1358
- a.setAttribute('href', `#running/${block.runId || ''}`);
1359
- n.appendChild(a);
1360
- return n;
1361
- }
1362
1980
  if (block.state === 'failed') {
1363
1981
  return make('div', 'ask-card-stub ask-card-failed', `Run failed${block.error ? `: ${block.error}` : ''} — ${card.title || card.brief || ''}`);
1364
1982
  }
1365
1983
  return make('div', 'ask-card-stub', `Not now — ${card.title || card.brief || 'run proposal'}`);
1366
1984
  }
1367
1985
 
1368
- function buildCardForm(block) {
1986
+ // ---- Workflow card (spec §8.3, mockup 2026-09-05 §A-§C, plan PD4/PD7/PD12-15) ---------------------------------
1987
+ const WF_ICO = { check: 'M5 13l4 4L19 7', save: 'M5 12l5 5L20 7' };
1988
+ const TUNABLE_KEYS = ['model', 'effort', 'fanOut', 'askQuestions'];
1989
+ /** The answer's `nodes`: a DIFF against the proposal (the qpanel's rule, app.js renderWorkflowBody). */
1990
+ function diffNodes(base, edits) {
1991
+ const nodes = {};
1992
+ for (const [id, sel] of Object.entries(edits || {})) {
1993
+ const diff = {};
1994
+ for (const k of TUNABLE_KEYS) if (sel[k] !== undefined && sel[k] !== ((base || {})[id] || {})[k]) diff[k] = sel[k];
1995
+ if (Object.keys(diff).length) nodes[id] = diff;
1996
+ }
1997
+ return nodes;
1998
+ }
1999
+
2000
+ function buildWorkflowCard(block, prev) {
1369
2001
  const card = block.card || {};
1370
- const rootEl = make('div', 'ask-card');
1371
- const local = { target: card.target === 'workspace' ? 'workspace' : 'project', options: null };
2002
+ const name = card.name || '';
2003
+ if (block.state === 'declined') return { el: make('div', 'ask-card-stub', `Declined — ${name || 'workflow proposal'}`) };
2004
+ if (block.state === 'failed') return { el: make('div', 'ask-card-stub ask-card-failed', `Proposal failed: ${block.error || 'unknown error'}`) };
2005
+ // State modifier = `is-<state>` (v6): `ask-wfcard-${state}` would make the SAVED root carry the same class as the check line below.
2006
+ const rootEl = make('div', `ask-card ask-wfcard is-${block.state}`);
2007
+ rootEl.setAttribute('data-ask-wfcard', block.state);
2008
+ const head = make('div', 'ask-wfcard-head');
2009
+ head.appendChild(make('span', 'ask-wfcard-title', block.state === 'saved' ? 'Saved workflow' : 'Proposed workflow'));
2010
+ head.appendChild(make('span', 'ask-wfcard-round', `round ${card.round || 1}`));
2011
+ if (block.state === 'saved') { head.appendChild(make('span', 'ask-wfcard-spacer')); head.appendChild(make('span', 'ask-wfcard-tag', 'Auto')); }
2012
+ rootEl.appendChild(head);
2013
+ if (block.state === 'building') {
2014
+ const trace = buildTrace(doc, { mode: card.mode });
2015
+ rootEl.appendChild(trace.el);
2016
+ const stop = scheduleTrace(trace, { win, mode: card.mode });
2017
+ return { el: rootEl, dispose: stop };
2018
+ }
2019
+ const proposed = block.state === 'proposed';
2020
+ const matched = !!(card.match && card.match.id);
2021
+ const editable = proposed && !matched; // PD4: only a NEW row takes a name and chip edits
2022
+ const wf = { nodes: {}, name };
2023
+ const handle = renderAutoProposal({ ...card, reasoning: card.reasoning || card.note || '' }, {
2024
+ doc, order: proposed ? AUTO_PROPOSAL_ORDER_CARD : ['name', 'graph', 'loops'],
2025
+ editableName: editable, pick: editable, onName: (v) => { wf.name = v; },
2026
+ });
2027
+ if (!proposed) {
2028
+ const saved = make('div', 'ask-wfcard-saved');
2029
+ saved.appendChild(svgIcon(WF_ICO.check, 15, 2.4));
2030
+ saved.appendChild(make('span', null, name));
2031
+ const line = make('div', 'ask-wfcard-savedline', card.adopted ? 'Uses your saved workflow' : 'Saved as a new workflow, tagged Auto');
2032
+ handle.parts.name.replaceWith(saved);
2033
+ saved.after(line);
2034
+ } else if (matched) {
2035
+ const hint = make('div', 'ask-wfcard-hint', 'Model and effort come from that saved workflow — edit them in the composer.');
2036
+ handle.parts.match.after(hint);
2037
+ }
2038
+ rootEl.appendChild(handle.el);
2039
+ rootEl.appendChild(make('div', 'ask-card-err'));
2040
+ const actions = make('div', 'ask-wfcard-actions');
2041
+ const btn = (cls, text, attr, icon) => {
2042
+ const b = make('button', cls, text); b.type = 'button'; b.setAttribute(attr, '');
2043
+ if (icon) b.prepend(svgIcon(icon, 12, 2.2));
2044
+ return b;
2045
+ };
2046
+ if (proposed) {
2047
+ const decline = btn('ask-card-not-now', 'Decline', 'data-ask-wf-decline');
2048
+ decline.addEventListener('click', () => postCard(block, rootEl, { state: 'declined' }, decline));
2049
+ const save = btn('ask-card-start', card.thenRun ? 'Save & propose run' : 'Save as workflow', 'data-ask-wf-save', WF_ICO.save);
2050
+ // A 200 means the row exists now: drop the cached option lists so every later consumer sees it.
2051
+ save.addEventListener('click', () => postCard(block, rootEl, { state: 'saved', name: handle.getName(), nodes: diffNodes(card.nodes, wf.nodes) }, save)
2052
+ .then((out) => { if (out) st.cardOptions = null; }));
2053
+ actions.append(make('span', 'ask-card-actions-spacer'), decline, save);
2054
+ } else {
2055
+ const open = btn('ask-card-open-np', 'Open in composer', 'data-ask-wf-open');
2056
+ open.disabled = !(typeof openComposer === 'function' && block.workflowId); // v7: an inert button beats a dead click
2057
+ open.addEventListener('click', () => { if (typeof openComposer === 'function' && block.workflowId) openComposer(block.workflowId); });
2058
+ // No "Run with this": the save already fired the event turn, which proposes the run
2059
+ // itself (thenRun) or offers one in chat — a card verb would only queue a second paid turn.
2060
+ actions.append(open);
2061
+ }
2062
+ rootEl.appendChild(actions);
2063
+ if (editable) {
2064
+ // PD12: paintBand re-creates the chips on every repaint, so the click is delegated from the card root.
2065
+ rootEl.addEventListener('click', (e) => {
2066
+ const chip = e.target && e.target.closest ? e.target.closest('.bchip[data-chip]') : null;
2067
+ if (!chip) return;
2068
+ const nodeEl = chip.closest('[data-node-id]');
2069
+ if (!nodeEl) return;
2070
+ e.stopPropagation();
2071
+ openChipPicker(chip, nodeEl.dataset.nodeId, card, wf, handle);
2072
+ });
2073
+ }
2074
+ rootEl.__wf = { handle, wf }; // tests (like the qpanel's panel.__wf)
2075
+ return { el: rootEl, handle, dispose: () => handle.destroy(), animate: proposed && !!prev && prev.state === 'building' };
2076
+ }
1372
2077
 
1373
- rootEl.appendChild(make('div', 'ask-card-title', card.title || 'Run proposal'));
2078
+ // ---- Metrics card (docs/team-metrics.md "Ask Worca"): a proposed team-metrics configuration change --------------
2079
+ const MC_KIND_LABEL = { enable: 'Enable team metrics', record: 'Include my runs', workspace_home: 'Metrics home', route_members: 'Route members' };
2080
+ const MC_APPLY_LABEL = { enable: 'Enable', record: 'Apply', workspace_home: 'Set home', route_members: 'Route members' };
2081
+ function buildMetricsCard(block) {
2082
+ const card = block.card || {};
2083
+ const summary = card.summary || 'metrics change';
2084
+ if (block.state === 'declined') return { el: make('div', 'ask-card-stub', `Declined — ${summary}`) };
2085
+ const rootEl = make('div', `ask-card ask-mcard is-${block.state}`);
2086
+ rootEl.setAttribute('data-ask-mcard', block.state);
2087
+ const head = make('div', 'ask-mcard-head');
2088
+ head.appendChild(make('span', 'ask-mcard-title', block.state === 'applied' ? 'Applied metrics change' : block.state === 'failed' ? 'Metrics change failed' : 'Proposed metrics change'));
2089
+ head.appendChild(make('span', 'ask-mcard-kind', MC_KIND_LABEL[card.kind] || card.kind || ''));
2090
+ rootEl.appendChild(head);
2091
+ const body = make('div', 'ask-mcard-body');
2092
+ const target = card.workspaceName ? `workspace ${card.workspaceName}` : card.projectName ? `project ${card.projectName}` : '';
2093
+ const sum = make('div', 'ask-mcard-summary');
2094
+ if (block.state === 'applied') sum.appendChild(svgIcon(WF_ICO.check, 15, 2.4));
2095
+ sum.appendChild(make('span', null, summary));
2096
+ body.appendChild(sum);
2097
+ if (target) body.appendChild(make('div', 'ask-mcard-target', target));
2098
+ if (card.note) body.appendChild(make('div', 'ask-mcard-note', card.note));
2099
+ if (Array.isArray(card.effects) && card.effects.length && block.state === 'proposed') {
2100
+ const ul = make('ul', 'ask-mcard-effects');
2101
+ for (const e of card.effects) ul.appendChild(make('li', null, e));
2102
+ body.appendChild(ul);
2103
+ }
2104
+ const result = card.result || null;
2105
+ if (block.state === 'failed') {
2106
+ body.appendChild(make('div', 'ask-mcard-failed', `Could not apply: ${block.error || (result && result.error) || 'unknown error'}`));
2107
+ if (result && result.hint) body.appendChild(make('div', 'ask-mcard-hint', result.hint));
2108
+ } else if (block.state === 'applied' && result) {
2109
+ if (result.detail) body.appendChild(make('div', 'ask-mcard-detail', result.detail));
2110
+ if (Array.isArray(result.results) && result.results.length) {
2111
+ const ul = make('ul', 'ask-mcard-results');
2112
+ for (const r of result.results) {
2113
+ const li = make('li', `is-${r.result || 'unknown'}`);
2114
+ li.appendChild(make('span', 'mono', r.slug || r.path || ''));
2115
+ li.appendChild(make('span', null, ` · ${r.result || ''}${r.reason ? ` · ${r.reason}` : ''}${r.error ? ` · ${r.error}` : ''}`));
2116
+ if (r.hint) li.appendChild(make('div', 'ask-mcard-hint', r.hint));
2117
+ ul.appendChild(li);
2118
+ }
2119
+ body.appendChild(ul);
2120
+ }
2121
+ }
2122
+ rootEl.appendChild(body);
2123
+ rootEl.appendChild(make('div', 'ask-card-err'));
2124
+ if (block.state === 'proposed') {
2125
+ const actions = make('div', 'ask-mcard-actions');
2126
+ const btn = (cls, text, attr, icon) => {
2127
+ const b = make('button', cls, text); b.type = 'button'; b.setAttribute(attr, '');
2128
+ if (icon) b.prepend(svgIcon(icon, 12, 2.2));
2129
+ return b;
2130
+ };
2131
+ const decline = btn('ask-card-not-now', 'Decline', 'data-ask-mc-decline');
2132
+ decline.addEventListener('click', () => postCard(block, rootEl, { state: 'declined' }, decline));
2133
+ const apply = btn('ask-card-start', MC_APPLY_LABEL[card.kind] || 'Apply', 'data-ask-mc-apply', WF_ICO.save);
2134
+ apply.addEventListener('click', () => postCard(block, rootEl, { state: 'applied' }, apply));
2135
+ actions.append(make('span', 'ask-card-actions-spacer'), decline, apply);
2136
+ rootEl.appendChild(actions);
2137
+ }
2138
+ return { el: rootEl };
2139
+ }
2140
+
2141
+ /** The model · effort picker (mockup §C): the panel's popover chrome, anchored under the chip. Rows are menuitems (PD28). */
2142
+ function openChipPicker(chip, nodeId, card, wf, handle) {
2143
+ const node = card.nodes && card.nodes[nodeId];
2144
+ if (!node) return;
2145
+ const models = Array.isArray(card.models) ? card.models : [];
2146
+ const cur = () => ({ ...node, ...(wf.nodes[nodeId] || {}) });
2147
+ const effortsOf = (mid) => (models.find((m) => m.id === mid) || {}).efforts || [];
2148
+ const set = (patch) => { wf.nodes[nodeId] = { ...(wf.nodes[nodeId] || {}), ...patch }; handle.setNodeTunables(nodeId, patch); };
2149
+ const panel = openPopover({
2150
+ panelClass: 'ask-pop-chip', trigger: chip,
2151
+ onClose: () => chip.setAttribute('aria-expanded', 'false'),
2152
+ build: (p) => {
2153
+ p.appendChild(make('div', 'ask-pop-cap', `Model · ${node.label || nodeId}`));
2154
+ for (const m of models) {
2155
+ const item = menuItem(`ask-model-item${m.id === cur().model ? ' on' : ''}`, () => {
2156
+ const list = effortsOf(m.id);
2157
+ const keep = list.includes(cur().effort) ? cur().effort : (list[1] || list[0] || ''); // the qpanel's rule (app.js buildTunablesTable)
2158
+ set({ model: m.id, effort: keep });
2159
+ closePopover({ focusTrigger: true });
2160
+ });
2161
+ item.appendChild(make('span', 'ask-model-name', m.label || m.id));
2162
+ if (m.id === cur().model) item.appendChild(make('span', 'ask-model-check', '✓'));
2163
+ p.appendChild(item);
2164
+ }
2165
+ p.appendChild(make('div', 'ask-pop-divider'));
2166
+ const row = make('div', 'ask-pop-effort');
2167
+ row.appendChild(make('span', 'ask-pop-effort-label', 'Effort'));
2168
+ for (const e of effortsOf(cur().model)) {
2169
+ const b = menuItem(`ask-effort-pill${e === cur().effort ? ' on' : ''}`, () => { set({ effort: e }); closePopover({ focusTrigger: true }); });
2170
+ b.textContent = e;
2171
+ row.appendChild(b);
2172
+ }
2173
+ if (!cur().model) row.appendChild(make('span', 'ask-pop-effort-none', 'pick a model first'));
2174
+ p.appendChild(row);
2175
+ },
2176
+ });
2177
+ if (!panel) return; // same chip toggled the open picker shut
2178
+ // Anchor under the chip (the composer's popovers are CSS-anchored; a chip lives anywhere in the transcript).
2179
+ const cr = chip.getBoundingClientRect();
2180
+ const sr = el.sheet.getBoundingClientRect();
2181
+ const width = 288;
2182
+ let left = cr.left - sr.left;
2183
+ if (left + width > el.sheet.clientWidth - 6) left = Math.max(6, el.sheet.clientWidth - width - 6);
2184
+ panel.style.left = `${Math.max(0, left)}px`;
2185
+ // Vertical: measured, then flipped/clamped — the sheet chops whatever hangs out of it.
2186
+ panel.style.top = `${chipPickerTop({
2187
+ top: cr.top - sr.top, bottom: cr.bottom - sr.top, panelH: panel.offsetHeight || 0, sheetH: el.sheet.clientHeight,
2188
+ })}px`;
2189
+ panel.style.right = 'auto';
2190
+ panel.style.bottom = 'auto';
2191
+ chip.setAttribute('aria-expanded', 'true');
2192
+ }
2193
+
2194
+ /** D9: the `@` popover — the thread's attachments not yet pilled; picking inserts the name at the caret
2195
+ * (right after the `@` the user typed) and adds the pill. Closes a same-trigger popover first:
2196
+ * openPopover toggles SHUT on the same trigger, so a second `@` would otherwise close it.
2197
+ * Focus: openPopover (:949-950) moves focus to the first menu item — right for a click-opened menu, wrong for
2198
+ * a picker opened by a keystroke — so the brief takes it back and keeps its caret; ArrowDown in the brief
2199
+ * enters the list (buildCardForm), Escape (onDocKeydown :891) and a pointerdown elsewhere close it. `pos` is
2200
+ * the `@` position at open time; the caret cannot move by typing while the picker is open (typing closes it),
2201
+ * only by mouse/arrow keys — the name still lands right after the `@`, which is what the user meant. */
2202
+ function openAtPopover(brief, pos, local, renderPills) {
2203
+ const have = new Set((local.pills || []).map((p) => p.id));
2204
+ const list = (st.model ? st.model.attachments() : []).filter((a) => a && a.id && !have.has(a.id));
2205
+ if (st.popover && st.popover.trigger === brief) closePopover({ focusTrigger: false });
2206
+ if (!list.length) return;
2207
+ const panel = openPopover({
2208
+ panelClass: 'ask-pop-at', trigger: brief,
2209
+ build: (p) => {
2210
+ p.appendChild(make('div', 'ask-pop-cap', 'Attach to the run'));
2211
+ for (const a of list) {
2212
+ const item = menuItem('ask-at-item', () => {
2213
+ brief.setRangeText(a.name, pos, pos, 'end');
2214
+ local.pills = [...local.pills, { id: a.id, name: a.name, bytes: a.bytes || 0, kind: a.kind || 'text' }];
2215
+ renderPills();
2216
+ closePopover({ focusTrigger: true });
2217
+ brief.dispatchEvent(new win.Event('input', { bubbles: true })); // count + autosize; the char before the caret is now a letter, so no reopen
2218
+ });
2219
+ item.textContent = a.name;
2220
+ p.appendChild(item);
2221
+ }
2222
+ },
2223
+ });
2224
+ if (!panel) return;
2225
+ const br = brief.getBoundingClientRect();
2226
+ const sr = el.sheet.getBoundingClientRect();
2227
+ panel.style.left = `${Math.max(0, br.left - sr.left + 12)}px`;
2228
+ // Same flip/clamp as the chip picker: the brief is the LAST section of the card, so under a
2229
+ // tall card the list would hang out of the overflow:hidden sheet and be chopped or invisible.
2230
+ panel.style.top = `${chipPickerTop({
2231
+ top: br.top - sr.top, bottom: br.bottom - sr.top,
2232
+ panelH: panel.offsetHeight || 0, sheetH: el.sheet.clientHeight,
2233
+ })}px`;
2234
+ panel.style.right = 'auto';
2235
+ panel.style.bottom = 'auto';
2236
+ // Take the caret back from the first menu item (openPopover focused it) — the user is still typing.
2237
+ try { brief.focus(); brief.setSelectionRange(pos, pos); } catch { /* ignore */ }
2238
+ }
2239
+
2240
+ function buildCardForm(block) {
2241
+ const card = block.card || {};
2242
+ const rootEl = make('div', 'ask-card ask-rp');
2243
+ const local = { target: card.target === 'workspace' ? 'workspace' : 'project', options: null, lane: null, pills: [], projectDir: () => '', workflowId: () => '' };
2244
+ const summary = make('span', 'ask-rp-summary'); // footer; renderLane paints it from the loaded lane
2245
+
2246
+ // head
2247
+ const head = make('header', 'ask-rp-head');
2248
+ const eyebrow = make('div', 'ask-rp-eyebrow');
2249
+ eyebrow.append(make('span', 'ask-rp-kicker', 'Run proposal'), make('span', 'ask-rp-from', 'from this chat'));
2250
+ head.appendChild(eyebrow);
2251
+ const titleRow = make('div', 'ask-rp-title');
2252
+ const titleInput = doc.createElement('input');
2253
+ titleInput.type = 'text';
2254
+ titleInput.value = card.title || '';
2255
+ titleInput.setAttribute('aria-label', 'Run title');
2256
+ titleInput.placeholder = 'Run title';
2257
+ titleRow.append(titleInput, make('span', 'ask-rp-edit', '✎ click to rename'));
2258
+ head.appendChild(titleRow);
2259
+ if (typeof card.note === 'string' && card.note) head.appendChild(make('p', 'ask-rp-why', card.note));
2260
+ rootEl.appendChild(head);
1374
2261
 
1375
2262
  // #397 guardrail: the model proposed a different target than the chat's pin.
1376
2263
  if (block.scopeMismatch) {
@@ -1378,147 +2265,275 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1378
2265
  'This proposal targets a different project or workspace than the one pinned for this chat — check the target before starting.'));
1379
2266
  }
1380
2267
 
2268
+ // target section
2269
+ const targetSec = make('div', 'ask-rp-sec');
2270
+ const targetHead = make('div', 'ask-rp-sec-head');
2271
+ const targetSub = make('span', 'ask-rp-sec-sub');
2272
+ targetHead.append(make('span', 'ask-rp-sec-title', 'Where it runs'), targetSub);
1381
2273
  const seg = make('div', 'ask-card-seg');
2274
+ seg.setAttribute('role', 'tablist');
1382
2275
  const segBtns = {};
1383
2276
  for (const [t, label] of [['project', 'Project'], ['workspace', 'Workspace']]) {
1384
2277
  const b = make('button', 'ask-card-seg-btn', label);
1385
2278
  b.type = 'button';
2279
+ b.setAttribute('role', 'tab');
1386
2280
  b.setAttribute('data-ask-card-seg', t);
1387
2281
  b.addEventListener('click', () => {
1388
2282
  if (local.target === t) return;
1389
2283
  local.target = t;
1390
- for (const k of Object.keys(segBtns)) segBtns[k].classList.toggle('on', k === local.target);
2284
+ paintSeg();
1391
2285
  renderTarget();
2286
+ reloadLane();
1392
2287
  });
1393
2288
  segBtns[t] = b;
1394
2289
  seg.appendChild(b);
1395
2290
  }
1396
- segBtns[local.target].classList.add('on');
1397
- rootEl.appendChild(seg);
1398
-
2291
+ // Both tabs carry aria-selected from the first paint (not only the active one).
2292
+ function paintSeg() { for (const k of Object.keys(segBtns)) { segBtns[k].classList.toggle('on', k === local.target); segBtns[k].setAttribute('aria-selected', String(k === local.target)); } }
2293
+ paintSeg();
2294
+ targetHead.appendChild(seg);
2295
+ targetSec.appendChild(targetHead);
1399
2296
  const targetHost = make('div', 'ask-card-target');
1400
- rootEl.appendChild(targetHost);
1401
-
1402
- const field = (label, control) => {
1403
- const f = make('div', 'ask-card-field');
1404
- f.appendChild(make('label', 'ask-card-label', label));
1405
- f.appendChild(control);
1406
- return f;
1407
- };
1408
-
1409
- const workflowSel = doc.createElement('select');
1410
- workflowSel.className = 'ask-card-workflow';
1411
- rootEl.appendChild(field('Workflow', workflowSel));
2297
+ targetSec.appendChild(targetHost);
2298
+
2299
+ const wfRow = make('div', 'ask-rp-grid two');
2300
+ const workflowSel = rpSelect('ask-card-workflow', 'Workflow');
2301
+ const wfDesc = make('span', 'ask-rp-wfdesc', '');
2302
+ wfDesc.setAttribute('data-for', 'workflow');
2303
+ const wfField = rpField('Workflow', workflowSel);
2304
+ wfField.appendChild(wfDesc);
2305
+ const guardSel = rpSelect('ask-card-guardrails', 'Guardrails');
2306
+ const guardDesc = make('span', 'ask-rp-wfdesc', 'Applies to every agent in this run');
2307
+ guardDesc.setAttribute('data-for', 'guardrails');
2308
+ const guardField = rpField('Guardrails', guardSel);
2309
+ guardField.appendChild(guardDesc);
2310
+ wfRow.append(wfField, guardField);
2311
+ targetSec.appendChild(wfRow);
2312
+ rootEl.appendChild(targetSec);
2313
+ workflowSel.addEventListener('change', () => {
2314
+ if (local.workflowUnavailable) { local.workflowUnavailable = false; err.textContent = ''; startBtn.disabled = false; }
2315
+ reloadLane();
2316
+ });
1412
2317
 
1413
- const guardSel = doc.createElement('select');
1414
- guardSel.className = 'ask-card-guardrails';
1415
- rootEl.appendChild(field('Guardrails', guardSel));
2318
+ // agents lane (reloadLane → renderLane fills laneSec)
2319
+ const laneSec = make('div', 'ask-rp-sec ask-rp-lane');
2320
+ rootEl.appendChild(laneSec);
1416
2321
 
2322
+ const briefSec = make('div', 'ask-rp-sec ask-rp-brief-host');
2323
+ const briefHead = make('div', 'ask-rp-sec-head');
2324
+ briefHead.append(make('span', 'ask-rp-sec-title', 'Task brief'), make('span', 'ask-rp-sec-sub', 'what the first agent reads · Markdown ok'));
2325
+ const pillRow = make('div', 'ask-rp-pills');
2326
+ briefHead.appendChild(pillRow);
2327
+ briefSec.appendChild(briefHead);
1417
2328
  const brief = doc.createElement('textarea');
1418
- brief.className = 'ask-card-brief';
2329
+ brief.className = 'ask-card-brief ask-rp-brief';
1419
2330
  brief.value = card.brief || '';
1420
- brief.addEventListener('input', () => {
2331
+ brief.setAttribute('aria-label', 'Task brief');
2332
+ briefSec.appendChild(brief);
2333
+ const briefFoot = make('div', 'ask-rp-brief-foot');
2334
+ const hint = make('span');
2335
+ hint.appendChild(make('kbd', null, '@'));
2336
+ hint.appendChild(doc.createTextNode(' mention an attached file'));
2337
+ const count = make('span', 'ask-rp-count');
2338
+ briefFoot.append(hint, count);
2339
+ briefSec.appendChild(briefFoot);
2340
+ rootEl.appendChild(briefSec);
2341
+ local.pills = Array.isArray(card.attachments) ? card.attachments.filter((a) => a && a.id).map((a) => ({ ...a })) : [];
2342
+ function renderPills() {
2343
+ pillRow.replaceChildren();
2344
+ for (const p of local.pills) {
2345
+ const pill = make('span', 'ask-rp-pill', `@${p.name} `);
2346
+ const x = make('button', null, '×');
2347
+ x.type = 'button';
2348
+ x.setAttribute('aria-label', `Remove ${p.name}`);
2349
+ x.addEventListener('click', () => { local.pills = local.pills.filter((q) => q.id !== p.id); renderPills(); }); // the text stays (D9)
2350
+ pill.appendChild(x);
2351
+ pillRow.appendChild(pill);
2352
+ }
2353
+ }
2354
+ const grow = () => {
1421
2355
  brief.style.height = 'auto';
1422
- brief.style.height = `${Math.min(brief.scrollHeight || 0, 160)}px`;
2356
+ brief.style.height = `${Math.min((brief.scrollHeight || 0) + 2, 420)}px`; // jsdom: scrollHeight 0 → 2px, harmless (CSS min-height wins)
2357
+ count.textContent = `${brief.value.length.toLocaleString('en-US')} chars`;
2358
+ };
2359
+ brief.addEventListener('input', () => {
2360
+ grow();
2361
+ const pos = typeof brief.selectionStart === 'number' ? brief.selectionStart : brief.value.length;
2362
+ if (brief.value[pos - 1] === '@') openAtPopover(brief, pos, local, renderPills);
2363
+ else if (st.popover && st.popover.trigger === brief) closePopover({ focusTrigger: false }); // typed past the @: the picker never filters, so it goes
2364
+ });
2365
+ // The caret stays in the brief while the picker is open (see openAtPopover); ArrowDown hands focus to the
2366
+ // list, where the popover's own keydown handler (arrows / Enter / Escape → back to the brief) takes over.
2367
+ brief.addEventListener('keydown', (e) => {
2368
+ if (e.key !== 'ArrowDown' || !st.popover || st.popover.trigger !== brief) return;
2369
+ const first = st.popover.panel.querySelector('[role="menuitem"]');
2370
+ if (!first) return;
2371
+ e.preventDefault();
2372
+ first.tabIndex = 0;
2373
+ try { first.focus(); } catch { /* ignore */ }
1423
2374
  });
1424
- rootEl.appendChild(field('Task brief', brief));
2375
+ renderPills();
2376
+ grow();
1425
2377
 
2378
+ // ONE feature-branch input for both targets; renderTarget moves it into the current grid.
1426
2379
  const feature = doc.createElement('input');
1427
2380
  feature.type = 'text';
1428
2381
  feature.className = 'ask-card-feature';
1429
2382
  feature.value = card.featureBranch || '';
1430
- rootEl.appendChild(field('Feature branch', feature));
2383
+ feature.setAttribute('aria-label', 'Feature branch');
1431
2384
 
1432
2385
  const err = make('div', 'ask-card-err');
1433
2386
  rootEl.appendChild(err);
1434
2387
 
1435
- const actions = make('div', 'ask-card-actions');
1436
- const openNp = make('button', 'ask-card-open-np', 'Open in New Pipeline');
2388
+ // footer
2389
+ const foot = make('footer', 'ask-rp-foot');
2390
+ const openNp = make('button', 'ask-card-open-np', '↗ Open in New Pipeline');
1437
2391
  openNp.type = 'button';
1438
2392
  openNp.setAttribute('data-ask-card-open-np', '');
1439
2393
  openNp.addEventListener('click', () => prefillFromCard(block, rootEl, local));
1440
- actions.appendChild(openNp);
1441
- actions.appendChild(make('span', 'ask-card-actions-spacer'));
1442
2394
  const dismissBtn = make('button', 'ask-card-not-now', 'Not now');
1443
2395
  dismissBtn.type = 'button';
1444
2396
  dismissBtn.setAttribute('data-ask-card-dismiss', '');
1445
2397
  dismissBtn.addEventListener('click', () => dismissCard(block, rootEl));
1446
- actions.appendChild(dismissBtn);
1447
- const startBtn = make('button', 'ask-card-start', 'Start');
2398
+ const startBtn = make('button', 'ask-card-start');
1448
2399
  startBtn.type = 'button';
1449
2400
  startBtn.setAttribute('data-ask-card-start', '');
2401
+ const play = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); // filled, unlike svgIcon's stroked glyphs
2402
+ play.setAttribute('viewBox', '0 0 24 24'); play.setAttribute('fill', 'currentColor'); play.setAttribute('aria-hidden', 'true');
2403
+ const playPath = doc.createElementNS('http://www.w3.org/2000/svg', 'path'); playPath.setAttribute('d', 'M6 4l14 8-14 8V4Z');
2404
+ play.appendChild(playPath); startBtn.appendChild(play);
2405
+ startBtn.appendChild(doc.createTextNode('Start run'));
1450
2406
  startBtn.addEventListener('click', () => startCard(block, rootEl, local));
1451
- actions.appendChild(startBtn);
1452
- rootEl.appendChild(actions);
2407
+ foot.append(openNp, summary, dismissBtn, startBtn);
2408
+ rootEl.appendChild(foot);
2409
+
2410
+ local.projectDir = () => (local.target === 'project' ? ((rootEl.querySelector('.ask-card-project-select') || {}).value || '') : '');
2411
+ local.workflowId = () => workflowSel.value || card.workflowId || 'wf_default';
2412
+
2413
+ function updateTargetSub() {
2414
+ const opts = local.options;
2415
+ if (local.target === 'project') {
2416
+ const projSel = rootEl.querySelector('.ask-card-project-select');
2417
+ const srcSel = rootEl.querySelector('.ask-card-source');
2418
+ const name = projSel && projSel.selectedOptions[0] ? projSel.selectedOptions[0].textContent : (card.projectName || '');
2419
+ targetSub.textContent = `${name} · branch ${(srcSel && srcSel.value) || 'current'} · feature ${feature.value.trim() || 'auto'}`;
2420
+ } else {
2421
+ const wsSel = rootEl.querySelector('.ask-card-workspace-select');
2422
+ const row = opts && wsSel && opts.workspaces.find((w) => w && w.id === wsSel.value);
2423
+ const n = row && Array.isArray(row.projectKeys) ? row.projectKeys.length : (Array.isArray(card.members) ? card.members.length : 0);
2424
+ targetSub.textContent = `${(row && row.name) || card.workspaceName || 'workspace'} · ${n} member${n === 1 ? '' : 's'} · feature ${feature.value.trim() || 'auto'}`;
2425
+ }
2426
+ }
2427
+ feature.addEventListener('input', updateTargetSub);
1453
2428
 
1454
2429
  function renderTarget() {
1455
2430
  targetHost.replaceChildren();
1456
2431
  const opts = local.options;
2432
+ const grid = make('div', 'ask-rp-grid');
1457
2433
  if (local.target === 'project') {
1458
- const projSel = doc.createElement('select');
1459
- projSel.className = 'ask-card-project-select';
1460
- const srcSel = doc.createElement('select');
1461
- srcSel.className = 'ask-card-source';
2434
+ const projSel = rpSelect('ask-card-project-select', 'Project');
2435
+ const srcSel = rpSelect('ask-card-source', 'Source branch');
1462
2436
  if (opts) {
1463
2437
  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) || '');
1464
- loadBranchesInto(srcSel, projSel.value, card.sourceBranch || '');
2438
+ // The fill is async: the sub-line reads the select again when the branches land,
2439
+ // or a proposed sourceBranch would read "branch current" until the user touches it.
2440
+ loadBranchesInto(srcSel, projSel.value, card.sourceBranch || '').then(updateTargetSub);
1465
2441
  }
1466
- projSel.addEventListener('change', () => loadBranchesInto(srcSel, projSel.value, ''));
1467
- targetHost.appendChild(field('Project', projSel));
1468
- targetHost.appendChild(field('Source branch', srcSel));
1469
- } else {
1470
- const wsSel = doc.createElement('select');
1471
- wsSel.className = 'ask-card-workspace-select';
1472
- const members = make('div', 'ask-card-members');
1473
- const srcInput = doc.createElement('input');
1474
- srcInput.type = 'text';
1475
- srcInput.className = 'ask-card-source-input';
1476
- srcInput.placeholder = 'auto';
1477
- srcInput.value = card.sourceBranch || '';
1478
- const details = doc.createElement('details');
1479
- // .disclosure swaps the OS triangle for the app's own chevron
1480
- details.className = 'ask-card-members-src disclosure';
1481
- details.appendChild(make('summary', null, 'Per-member source branches'));
1482
- const memberHost = make('div', 'ask-card-members-src-list');
1483
- details.appendChild(memberHost);
1484
- const renderMembers = () => {
1485
- members.replaceChildren();
1486
- memberHost.replaceChildren();
1487
- const row = opts && opts.workspaces.find((w) => w && w.id === wsSel.value);
1488
- const list = row && Array.isArray(row.projectKeys)
1489
- ? row.projectKeys.map((k, i) => ({ projectKey: k, name: wsBasename(row.projectPaths && row.projectPaths[i]) }))
1490
- : Array.isArray(card.members) ? card.members.map((m) => ({ projectKey: m.projectKey, name: m.projectName })) : [];
1491
- members.textContent = list.map((m) => m.name).join(', ');
1492
- for (const m of list) {
1493
- const inp = doc.createElement('input');
1494
- inp.type = 'text';
1495
- inp.className = 'ask-card-member-src';
1496
- inp.placeholder = 'auto';
1497
- inp.setAttribute('data-project-key', m.projectKey);
1498
- if (card.sourceBranchByKey && card.sourceBranchByKey[m.projectKey]) inp.value = card.sourceBranchByKey[m.projectKey];
1499
- memberHost.appendChild(field(m.name, inp));
1500
- }
1501
- };
1502
- if (opts) {
1503
- fillSelect(wsSel, opts.workspaces.map((w) => ({ value: w.id, label: w.name || w.id })), card.workspaceId || (opts.workspaces[0] && opts.workspaces[0].id) || '');
1504
- renderMembers();
2442
+ projSel.addEventListener('change', () => { loadBranchesInto(srcSel, projSel.value, '').then(updateTargetSub); updateTargetSub(); reloadLane(); });
2443
+ srcSel.addEventListener('change', updateTargetSub);
2444
+ grid.append(rpField('Project', projSel), rpField('Source branch', srcSel), rpField('Feature branch', feature, 'created for the run'));
2445
+ targetHost.appendChild(grid);
2446
+ updateTargetSub();
2447
+ return;
2448
+ }
2449
+ const wsSel = rpSelect('ask-card-workspace-select', 'Workspace');
2450
+ const members = make('div', 'ask-card-members');
2451
+ const srcInput = doc.createElement('input');
2452
+ srcInput.type = 'text';
2453
+ srcInput.className = 'ask-card-source-input';
2454
+ srcInput.placeholder = 'auto';
2455
+ srcInput.value = card.sourceBranch || '';
2456
+ srcInput.setAttribute('aria-label', 'Source branch default');
2457
+ const details = doc.createElement('details');
2458
+ details.className = 'ask-card-members-src disclosure'; // .disclosure swaps the OS triangle for the app's chevron
2459
+ details.appendChild(make('summary', null, 'Per-member source branches'));
2460
+ const memberHost = make('div', 'ask-card-members-src-list');
2461
+ details.appendChild(memberHost);
2462
+ const renderMembers = () => {
2463
+ members.replaceChildren();
2464
+ memberHost.replaceChildren();
2465
+ const row = opts && opts.workspaces.find((w) => w && w.id === wsSel.value);
2466
+ const list = row && Array.isArray(row.projectKeys)
2467
+ ? row.projectKeys.map((k, i) => ({ projectKey: k, name: wsBasename(row.projectPaths && row.projectPaths[i]) }))
2468
+ : Array.isArray(card.members) ? card.members.map((m) => ({ projectKey: m.projectKey, name: m.projectName })) : [];
2469
+ members.textContent = list.map((m) => m.name).join(', ');
2470
+ for (const m of list) {
2471
+ const inp = doc.createElement('input');
2472
+ inp.type = 'text';
2473
+ inp.className = 'ask-card-member-src';
2474
+ inp.placeholder = 'auto';
2475
+ inp.setAttribute('data-project-key', m.projectKey);
2476
+ if (card.sourceBranchByKey && card.sourceBranchByKey[m.projectKey]) inp.value = card.sourceBranchByKey[m.projectKey];
2477
+ memberHost.appendChild(rpField(m.name, inp));
1505
2478
  }
1506
- wsSel.addEventListener('change', renderMembers);
1507
- targetHost.appendChild(field('Workspace', wsSel));
1508
- targetHost.appendChild(members);
1509
- targetHost.appendChild(field('Source branch (default)', srcInput));
1510
- targetHost.appendChild(details);
2479
+ updateTargetSub();
2480
+ };
2481
+ wsSel.addEventListener('change', renderMembers);
2482
+ const wsField = rpField('Workspace', wsSel);
2483
+ wsField.appendChild(members);
2484
+ grid.append(wsField, rpField('Source branch', srcInput, 'default for members'), rpField('Feature branch', feature));
2485
+ targetHost.appendChild(grid); // attach BEFORE filling: renderMembers → updateTargetSub finds the select through rootEl
2486
+ targetHost.appendChild(details);
2487
+ if (opts) {
2488
+ fillSelect(wsSel, opts.workspaces.map((w) => ({ value: w.id, label: w.name || w.id })), card.workspaceId || (opts.workspaces[0] && opts.workspaces[0].id) || '');
2489
+ renderMembers();
1511
2490
  }
2491
+ updateTargetSub();
2492
+ }
2493
+
2494
+ // A reload (workflow / project / target change) rebuilds the lane from scratch:
2495
+ // `edits` are per workflow-and-project and are DISCARDED, silently.
2496
+ const laneCtx = { summary, workflowId: () => local.workflowId() };
2497
+ let laneSeq = 0;
2498
+ function reloadLane() {
2499
+ const seq = ++laneSeq;
2500
+ const workflowId = local.workflowId();
2501
+ const projectDir = local.projectDir();
2502
+ local.lane = null;
2503
+ renderLane(laneSec, null, laneCtx, 'Loading agent settings…');
2504
+ loadLane(workflowId, projectDir).then((lane) => {
2505
+ if (st.destroyed || seq !== laneSeq) return; // a later reload won
2506
+ local.lane = lane;
2507
+ wfDesc.textContent = lane ? workflowDesc(lane.wf, lane.registry, lane.runConfig) : '';
2508
+ renderLane(laneSec, lane, laneCtx);
2509
+ });
2510
+ }
2511
+ local.reloadLane = reloadLane; // after a successful save the lane re-reads the persisted config
2512
+ renderLane(laneSec, null, laneCtx, 'Loading agent settings…'); // until the option lists arrive
2513
+
2514
+ // Fail loudly, never substitute: the proposed id is in no list, so the select shows
2515
+ // nothing, the error says which id is missing and Start stays inert until the user
2516
+ // picks a row (the change handler above lifts all three). local.workflowId() keeps
2517
+ // reporting the proposed id meanwhile, so the lane shows its unusable state instead
2518
+ // of another workflow's agents.
2519
+ function markWorkflowUnavailable() {
2520
+ workflowSel.selectedIndex = -1;
2521
+ err.textContent = `Workflow ${card.workflowId} is not available — pick one`;
2522
+ startBtn.disabled = true;
2523
+ local.workflowUnavailable = true;
1512
2524
  }
1513
2525
 
1514
2526
  renderTarget();
1515
- loadCardOptions().then((opts) => {
2527
+ loadCardOptions({ fresh: true }).then((opts) => {
1516
2528
  if (st.destroyed) return;
1517
2529
  local.options = opts;
1518
2530
  fillSelect(workflowSel, opts.workflows.map((w) => ({ value: w.id, label: workflowPickerLabel(w, null) || w.name || w.id })), card.workflowId || 'wf_default');
2531
+ if (card.workflowId && workflowSel.value !== card.workflowId) markWorkflowUnavailable();
1519
2532
  fillSelect(guardSel, opts.guardrails.map((g) => ({ value: g.id, label: g.id === 'permissive' ? 'Permissive' : (g.name || g.id) })), card.guardrailsId || 'normal');
1520
2533
  renderTarget();
2534
+ reloadLane();
1521
2535
  });
2536
+ rootEl.__rp = { local, wfDesc, summary, laneSec, briefSec, brief, titleInput, lane: () => local.lane }; // consumed by later tasks + tests
1522
2537
  return rootEl;
1523
2538
  }
1524
2539
 
@@ -1527,9 +2542,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1527
2542
  prompt: rootEl.querySelector('.ask-card-brief').value,
1528
2543
  workflowId: rootEl.querySelector('.ask-card-workflow').value,
1529
2544
  guardrailsId: rootEl.querySelector('.ask-card-guardrails').value, // ALWAYS sent (spec §9.4)
1530
- title: card.title || undefined,
2545
+ title: ((rootEl.querySelector('.ask-rp-title input') || {}).value || '').trim() || card.title || undefined,
1531
2546
  mock: false,
1532
2547
  };
2548
+ // Agent memory (§7.3 / B17): the card's scope rides along only while its workflow is still the
2549
+ // defragment one — a user who switched the picker to another workflow gets a legacy body.
2550
+ if (card.memoryScope && body.workflowId === 'wf_memory_defrag') body.memoryScope = card.memoryScope;
1533
2551
  const feature = rootEl.querySelector('.ask-card-feature').value.trim();
1534
2552
  if (feature) body.featureBranch = feature;
1535
2553
  if (local.target === 'workspace') {
@@ -1551,13 +2569,29 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1551
2569
  return body;
1552
2570
  }
1553
2571
 
2572
+ /** Freeze the controls Start's two awaits straddle. A workflow or target change mid-flight
2573
+ * repoints the lane (reloadLane nulls local.lane) while saveLaneEdits still holds the old
2574
+ * one, so the config written and the body posted would describe different runs. */
2575
+ function freezeTargetInputs(rootEl, on) {
2576
+ for (const sel of ['.ask-card-workflow', '.ask-card-project-select', '.ask-card-workspace-select', '[data-ask-card-seg]']) {
2577
+ for (const node of rootEl.querySelectorAll(sel)) node.disabled = on;
2578
+ }
2579
+ }
2580
+
1554
2581
  async function startCard(block, rootEl, local) {
1555
2582
  const err = rootEl.querySelector('.ask-card-err');
1556
2583
  const startBtn = rootEl.querySelector('[data-ask-card-start]');
1557
2584
  err.textContent = '';
1558
2585
  startBtn.disabled = true;
2586
+ freezeTargetInputs(rootEl, true);
1559
2587
  try {
2588
+ // D3: read-only, so it runs BEFORE saveLaneEdits — a refusal here must not leave the config already written.
2589
+ const ex = await collectCardExtras(local);
2590
+ if (ex.error) { err.textContent = ex.error; return; }
2591
+ const saveErr = await saveLaneEdits(local); // the previous phase's guard, now second
2592
+ if (saveErr) { err.textContent = saveErr; return; }
1560
2593
  const body = { ...collectCardBody(rootEl, local, block.card || {}), askThreadId: st.threadId, askCardId: block.id };
2594
+ if (ex.extras.length) body.extras = ex.extras;
1561
2595
  let res = null;
1562
2596
  try {
1563
2597
  res = await fetch('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -1573,23 +2607,43 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1573
2607
  // (beginRun is NEVER called — spec §10.5).
1574
2608
  } finally {
1575
2609
  startBtn.disabled = false;
2610
+ freezeTargetInputs(rootEl, false);
1576
2611
  }
1577
2612
  }
1578
2613
 
1579
- async function dismissCard(block, rootEl) {
2614
+ /** POST the card endpoint; the flip FRAME renders the next state (never a local flip). Returns the body or null. */
2615
+ async function postCard(block, rootEl, body, btn = null) {
1580
2616
  const err = rootEl.querySelector('.ask-card-err');
1581
- err.textContent = '';
2617
+ if (err) err.textContent = '';
2618
+ // `posting` marks OUR disable so the per-flush run-button sync leaves it alone.
2619
+ if (btn) { btn.disabled = true; btn.dataset.posting = '1'; }
2620
+ const release = () => { if (btn) { btn.disabled = false; delete btn.dataset.posting; } };
1582
2621
  let res = null;
1583
2622
  try {
1584
- res = await fetch(`/api/ask/threads/${st.threadId}/cards/${block.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: 'dismissed' }) });
1585
- } catch { err.textContent = 'network error'; return; }
2623
+ res = await fetch(`/api/ask/threads/${st.threadId}/cards/${block.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
2624
+ } catch { if (err) err.textContent = 'network error'; release(); return null; }
1586
2625
  if (!res.ok) {
1587
2626
  let msg = `request failed (${res.status})`;
1588
2627
  try { const b = await res.json(); if (b && b.error) msg = b.error; } catch { /* keep */ }
1589
- err.textContent = msg;
2628
+ // One turn per thread: the route refuses every card verb that starts one while a
2629
+ // reply streams. Say what to do instead of echoing the wire's `turn in flight`.
2630
+ if (res.status === 409 && msg === 'turn in flight') msg = 'Ask Worca is still replying — try again once the answer lands.';
2631
+ if (err) err.textContent = msg;
2632
+ release();
2633
+ return null;
1590
2634
  }
1591
- // the flip frame renders the stub
1592
- }
2635
+ let out = null;
2636
+ try { out = await res.json(); } catch { out = null; }
2637
+ // v6: re-enable on success for EVERY verb. For save/decline the flip frame replaces this element within milliseconds anyway;
2638
+ // a click landing in that window is answered 409 by the route's state check (+ askCardBusy) — harmless.
2639
+ release();
2640
+ // A failed event turn ALSO posts a system notice row (the server's failedEventTurn):
2641
+ // for save/decline the flip has already rebuilt this element, so the row is the only
2642
+ // message that survives. This line is what the run verb — which never flips — shows.
2643
+ if (out && out.turn && out.turn.error && err) err.textContent = `Ask Worca could not reply: ${out.turn.error}`;
2644
+ return out;
2645
+ }
2646
+ function dismissCard(block, rootEl) { return postCard(block, rootEl, { state: 'dismissed' }); }
1593
2647
 
1594
2648
  function prefillFromCard(block, rootEl, local) {
1595
2649
  const card = block.card || {};
@@ -1598,8 +2652,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1598
2652
  workflowId: rootEl.querySelector('.ask-card-workflow').value,
1599
2653
  guardrailsId: rootEl.querySelector('.ask-card-guardrails').value,
1600
2654
  prompt: rootEl.querySelector('.ask-card-brief').value,
1601
- title: card.title || '',
2655
+ title: ((rootEl.querySelector('.ask-rp-title input') || {}).value || '').trim() || card.title || '',
1602
2656
  featureBranch: rootEl.querySelector('.ask-card-feature').value.trim(),
2657
+ // The picker has no way to re-derive this: a `project` proposal opened in New Pipeline would
2658
+ // otherwise start with the row's default `global` and restructure the wrong scope (B17).
2659
+ memoryScope: card.memoryScope || null,
1603
2660
  };
1604
2661
  if (local.target === 'workspace') {
1605
2662
  p.workspaceId = rootEl.querySelector('.ask-card-workspace-select').value;
@@ -1617,16 +2674,195 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1617
2674
  const src = rootEl.querySelector('.ask-card-source');
1618
2675
  p.sourceBranch = src ? src.value : '';
1619
2676
  }
1620
- openNewPipeline(p);
2677
+ // Nothing to save or fetch → hand over synchronously (the caller may not await).
2678
+ const pending = cardPending(local);
2679
+ if (!pending) { openNewPipeline(p); return; }
2680
+ const err = rootEl.querySelector('.ask-card-err');
2681
+ if (err) err.textContent = '';
2682
+ // Same guard Start has: without it two quick clicks run two extras fetches, two save
2683
+ // rounds and two handovers. Released on every exit of the continuation.
2684
+ const openNp = rootEl.querySelector('[data-ask-card-open-np]');
2685
+ if (openNp) openNp.disabled = true;
2686
+ collectCardExtras(local).then(async (ex) => {
2687
+ if (st.destroyed) return;
2688
+ if (ex.error) { if (err) err.textContent = ex.error; return; }
2689
+ const saveErr = await saveLaneEdits(local);
2690
+ if (st.destroyed) return;
2691
+ if (saveErr) { if (err) err.textContent = saveErr; return; }
2692
+ if (ex.extras.length) p.extras = ex.extras;
2693
+ if (typeof local.reloadLane === 'function') local.reloadLane(); // the card stays proposed: show the persisted state, not stale edits
2694
+ openNewPipeline(p);
2695
+ }).finally(() => { if (openNp) openNp.disabled = false; });
2696
+ }
2697
+
2698
+ // ---- card cache: ONE element per card id, rebuilt only on a STATE change (V7 for run cards; every state for
2699
+ // workflow cards AND run progress cards, whose graph mount + ResizeObserver must not be re-created on each
2700
+ // streaming re-render, PD15 / D10).
2701
+ /** A block that renders as the live run progress card: a track_run card, or a run proposal the user started.
2702
+ * A `failed` proposal WITH a runId is a run that errored after launch — still the card; without one it is a
2703
+ * rejected proposal and stays the stub. */
2704
+ function isProgressBlock(block) {
2705
+ const card = block.card || {};
2706
+ if (card.type === PROGRESS_CARD_TYPE) return true;
2707
+ if (card.type === 'workflow' || card.type === 'metrics') return false;
2708
+ return block.state === 'started' || (block.state === 'failed' && !!block.runId);
1621
2709
  }
1622
-
1623
2710
  function buildCard(block) {
1624
2711
  if (!st.cardEls) st.cardEls = new Map();
1625
2712
  const cached = st.cardEls.get(block.id);
1626
- if (cached && cached.state === block.state && block.state === 'proposed') return cached.el;
1627
- const built = block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block);
1628
- st.cardEls.set(block.id, { el: built, state: block.state });
1629
- return built;
2713
+ const isWorkflow = !!(block.card && block.card.type === 'workflow');
2714
+ const isMetrics = !!(block.card && block.card.type === 'metrics');
2715
+ const isProgress = isProgressBlock(block);
2716
+ if (cached && cached.state === block.state && (isWorkflow || isMetrics || isProgress || block.state === 'proposed')) return cached.el;
2717
+ if (cached) disposeCardEntry(cached);
2718
+ const built = isWorkflow ? buildWorkflowCard(block, cached)
2719
+ : isMetrics ? buildMetricsCard(block)
2720
+ : isProgress ? buildProgressCard(block)
2721
+ : { el: block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block) };
2722
+ st.cardEls.set(block.id, { el: built.el, state: block.state, handle: built.handle || null, dispose: built.dispose || null, animate: !!built.animate, cancelAnim: null, lastW: -1 });
2723
+ return built.el;
2724
+ }
2725
+ function disposeCardEntry(c) {
2726
+ try { if (c.cancelAnim) c.cancelAnim(); } catch { /* ignore */ }
2727
+ try { if (c.dispose) c.dispose(); } catch { /* a dead mount never breaks a render */ }
2728
+ }
2729
+ /** Drop cached card elements whose block is gone (keep = the live ids), or every one. */
2730
+ function pruneCardEls(keep = null) {
2731
+ if (!st.cardEls) return;
2732
+ for (const [id, c] of st.cardEls) {
2733
+ if (keep && keep.has(id)) continue;
2734
+ disposeCardEntry(c);
2735
+ st.cardEls.delete(id);
2736
+ }
2737
+ }
2738
+ /** After a flush: measure the attached graph hosts once per width (jsdom: 0 ⇒ the 702 default) and start a pending build animation.
2739
+ * A width change DURING a build lands it first (v5): view.relayout re-creates the wire paths, which would drop their `is-hid`
2740
+ * mid-choreography (measured) — so cancel() (which lands everything) runs before the relayout. */
2741
+ function relayoutCards() {
2742
+ if (!st.cardEls) return;
2743
+ for (const c of st.cardEls.values()) {
2744
+ if (!c.handle || !c.el.isConnected) continue;
2745
+ const w = (c.handle.parts.graph && c.handle.parts.graph.clientWidth) || 0;
2746
+ if (w !== c.lastW) {
2747
+ if (c.cancelAnim) { try { c.cancelAnim(); } catch { /* ignore */ } c.cancelAnim = null; }
2748
+ c.lastW = w;
2749
+ c.handle.relayout(w);
2750
+ }
2751
+ if (c.animate) { c.animate = false; c.cancelAnim = playAssembly(c.handle, { win, onDone: () => { c.cancelAnim = null; } }); }
2752
+ }
2753
+ }
2754
+
2755
+ // ---- run progress cards (ui/public/ask-run-card.mjs; D1 runStore seam, D9 REST hydration, D11 cadence) ------------
2756
+ const PROGRESS_TICK_MS = 1000, PROGRESS_REHYDRATE_MS = 30_000;
2757
+ const PIPELINE_ID_RE = /^[0-9a-f]{8}$/;
2758
+
2759
+ /** The persisted identity a card starts from (§4.2). The pipeline id of a started proposal arrives later
2760
+ * (run store, then the thread's run links — keyed by the card, so a resume cannot orphan it). */
2761
+ function progressIdent(block) {
2762
+ const card = block.card || {};
2763
+ if (card.type === PROGRESS_CARD_TYPE) {
2764
+ return { cardId: block.id, pipelineId: card.pipelineId || null, runId: card.runId || null, projectKey: card.projectKey || null,
2765
+ workspaceId: card.workspaceId || null, title: card.title || '', label: card.label || '' };
2766
+ }
2767
+ return { cardId: block.id, pipelineId: null, runId: block.runId || null, projectKey: card.projectKey || null, workspaceId: card.workspaceId || null,
2768
+ title: card.title || card.brief || 'run', label: card.workspaceName || card.projectName || '' };
2769
+ }
2770
+ /** Fold the thread's run links into the identity: the first `state` reveals the pipeline id; a resume moves the runId. */
2771
+ function refreshProgressIdent(ident) {
2772
+ if (!st.model) return;
2773
+ const link = (ident.pipelineId && st.model.runLinkByPipeline(ident.pipelineId)) || st.model.runLinkForCard(ident.cardId)
2774
+ || (ident.runId ? (() => { const l = st.model.runLinks().get(ident.runId); return l ? { runId: ident.runId, ...l } : null; })() : null);
2775
+ if (!link) return;
2776
+ if (link.pipelineId) ident.pipelineId = link.pipelineId;
2777
+ if (link.runId && !PIPELINE_ID_RE.test(link.runId)) ident.runId = link.runId; // a pipeline-id-keyed link has no live UUID (D5)
2778
+ }
2779
+ function progressSnapshot(entry) {
2780
+ const ident = entry.ident;
2781
+ let snap = null;
2782
+ // D23: the pipeline id is the stable key; byPipeline resolves the LIVE lineage when a superseded twin shares it.
2783
+ if (runStore) {
2784
+ if (ident.pipelineId) snap = runStore.byPipeline(ident.pipelineId);
2785
+ if (!snap && ident.runId) snap = runStore.get(ident.runId);
2786
+ }
2787
+ if (snap) {
2788
+ if (snap.pipelineId && !ident.pipelineId) ident.pipelineId = snap.pipelineId;
2789
+ if (snap.runId) ident.runId = snap.runId;
2790
+ return snap;
2791
+ }
2792
+ return entry.rest;
2793
+ }
2794
+ function buildProgressCard(block) {
2795
+ if (!st.progress) st.progress = new Map();
2796
+ const ident = progressIdent(block);
2797
+ const handle = createRunProgressCard({ doc, ident, onOpen: (href) => {
2798
+ closeSheet(); // openNewPipeline precedent: close, then route
2799
+ if (href && href.startsWith('#') && win.location.hash !== href) win.location.hash = href.slice(1);
2800
+ } });
2801
+ const entry = { ident, handle, rest: null, hydrating: false, nextHydrateAt: 0 };
2802
+ st.progress.set(block.id, entry);
2803
+ if (block.state === 'failed' && block.error) handle.setReason(`Run failed: ${block.error}`);
2804
+ repaintProgress(entry, { hydrate: true });
2805
+ ensureRunTick();
2806
+ return { el: handle.el, handle, dispose: () => { if (st.progress && st.progress.get(block.id) === entry) st.progress.delete(block.id); handle.destroy(); } };
2807
+ }
2808
+ function repaintProgress(entry, { hydrate = false } = {}) {
2809
+ refreshProgressIdent(entry.ident);
2810
+ const snap = progressSnapshot(entry);
2811
+ // D24: a lineage this tab just dropped (the acting tab's resume evicts the superseded entry) must not regress the
2812
+ // card to "Starting" — keep the last paint until the new lineage's first state or REST lands.
2813
+ entry.handle.update(snap || entry.handle.snapshot, now());
2814
+ if (hydrate && !(snap && snap.source === 'live') && entry.ident.pipelineId) hydrateProgress(entry);
2815
+ }
2816
+ /** Repaint every attached progress card; `hydrate` re-reads REST for the ones the live map does not hold. */
2817
+ function repaintProgressCards({ hydrate = false } = {}) {
2818
+ st.runPoked = false;
2819
+ if (!st.progress || !st.progress.size) return;
2820
+ for (const entry of st.progress.values()) if (entry.handle.el.isConnected) repaintProgress(entry, { hydrate });
2821
+ }
2822
+ async function hydrateProgress(entry) {
2823
+ if (entry.hydrating || st.destroyed) return;
2824
+ entry.hydrating = true;
2825
+ const id = entry.ident.pipelineId;
2826
+ try {
2827
+ const res = await fetch(`/api/ask/runs/${id}`);
2828
+ if (res && res.ok) {
2829
+ const body = await res.json();
2830
+ // The envelope, not any 200: a stub that answers every URL must never paint a run (test/ui-ask-card.test.mjs boot stub).
2831
+ if (body && body.state && typeof body.state.status === 'string' && body.state.id === id) {
2832
+ entry.rest = snapshotFromState(body.state, { now: now() });
2833
+ if (body.live && body.live.runId) entry.ident.runId = body.live.runId;
2834
+ }
2835
+ }
2836
+ } catch { /* offline: the card keeps its last paint */ }
2837
+ entry.hydrating = false;
2838
+ entry.nextHydrateAt = now() + PROGRESS_REHYDRATE_MS;
2839
+ if (st.destroyed || !st.progress || st.progress.get(entry.ident.cardId) !== entry) return;
2840
+ entry.handle.update(progressSnapshot(entry) || entry.handle.snapshot, now()); // D24: a failed/junk hydrate keeps the last paint
2841
+ }
2842
+ function ensureRunTick() {
2843
+ if (st.runTick || st.destroyed) return;
2844
+ // Bare setInterval, unref'd — the startElapsed() precedent (a jsdom window timer has no unref()).
2845
+ st.runTick = setInterval(onRunTick, PROGRESS_TICK_MS);
2846
+ if (st.runTick && typeof st.runTick.unref === 'function') st.runTick.unref();
2847
+ }
2848
+ function onRunTick() {
2849
+ if (st.destroyed || !st.progress || !st.progress.size) { if (st.runTick) { clearInterval(st.runTick); st.runTick = null; } return; }
2850
+ if (!st.open) return; // the tick idles behind a closed sheet (a frame-driven flush may still patch the hidden DOM — harmless); openSheet() catches up
2851
+ const t = now();
2852
+ for (const entry of st.progress.values()) {
2853
+ if (!entry.handle.el.isConnected) continue;
2854
+ const snap = entry.handle.snapshot;
2855
+ if (!snap || snap.terminal) continue;
2856
+ if (snap.source === 'live') entry.handle.update(progressSnapshot(entry) || snap, t); // fresh elapsed + cost from the store (D24: never null)
2857
+ else if (t >= entry.nextHydrateAt) hydrateProgress(entry); // a run this tab gets no frames for
2858
+ }
2859
+ }
2860
+ if (runStore && typeof runStore.subscribe === 'function') {
2861
+ st.runUnsub = runStore.subscribe((runId, type) => {
2862
+ if (st.destroyed || type === 'log' || !st.progress || !st.progress.size) return;
2863
+ st.runPoked = true;
2864
+ if (st.open) scheduleFlush();
2865
+ });
1630
2866
  }
1631
2867
 
1632
2868
  function toolRow(block) {
@@ -1690,45 +2926,92 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1690
2926
  if (entry) entry.update(row);
1691
2927
  }
1692
2928
 
2929
+ const isLiveRow = (row) => !!(row && st.model && st.model.live() && st.model.live().messageId === row.id);
2930
+
2931
+ /**
2932
+ * The block above the answer, with an `update(row)` that patches it IN PLACE:
2933
+ * the head is re-stated (one line, fixed height), a tool/agent row already on
2934
+ * screen is replaced by its own fresh node, and a new one is appended into its
2935
+ * group. Nothing outside `.ask-activity` is touched, so an ask-label or
2936
+ * ask-block frame can no longer re-create the message around the text being
2937
+ * read (which replayed the entry animation on every frame of a turn).
2938
+ */
1693
2939
  function buildActivity(row) {
1694
- const isLive = !!(st.model && st.model.live() && st.model.live().messageId === row.id);
1695
2940
  const activity = make('div', 'ask-activity');
1696
2941
  const head = make('div', 'ask-activity-head');
1697
- const stopped = row.status === 'stopped' || row.status === 'error';
1698
- if (isLive) head.appendChild(make('span', 'ask-activity-label', 'Thinking'));
1699
- else if (!stopped) head.appendChild(make('span', 'ask-activity-label', 'Done'));
1700
- head.appendChild(make('span', `ask-dot${isLive ? ' ask-dot-run' : row.status === 'error' ? '' : ' ask-dot-done'}`));
1701
- // The head names its state in one word ahead of the dot — Thinking, Done, or
1702
- // Stopped after — and nothing more while the turn is live: the orb row at the
1703
- // bottom of the message owns the elapsed and the meter, and printing either
1704
- // set twice is the noise this replaced. A turn that ended badly says so
1705
- // instead of Done; nothing else marks a stop.
1706
- if (!isLive) {
1707
- if (stopped) head.appendChild(make('span', 'ask-activity-label', 'Stopped after'));
1708
- head.appendChild(make('span', 'ask-activity-elapsed', fmtElapsed(row.durationMs) || ''));
1709
- head.appendChild(make('span', 'ask-activity-spacer'));
1710
- const meter = [fmtCtx(row.usage && row.usage.ctx), fmtUsd(row.costUsd)].filter(Boolean).join(' · ');
1711
- head.appendChild(make('span', 'ask-activity-meter', meter));
1712
- }
1713
2942
  activity.appendChild(head);
1714
- const tools = (row.blocks || []).filter((b) => b && b.kind === 'tool');
1715
- for (const b of tools) activity.appendChild(toolRow(b));
1716
- const agents = (row.blocks || []).filter((b) => b && b.kind === 'agent');
1717
- if (agents.length) {
1718
- const sect = make('div', 'ask-agents');
1719
- const cap = make('div', 'ask-agents-cap');
1720
- cap.appendChild(make('span', null, 'Sub-agents'));
1721
- cap.appendChild(make('span', 'ask-agents-count', String(agents.length)));
1722
- sect.appendChild(cap);
1723
- for (const b of agents) sect.appendChild(agentRow(b));
1724
- activity.appendChild(sect);
1725
- }
1726
- return { el: activity };
1727
- }
1728
-
1729
- // The ONE orb: created on first live turn and re-parented into each rebuilt
1730
- // live row. Rebuilding it per row would restart the canvas — and since a tool
1731
- // block rebuilds the row, the sphere would visibly snap back mid-turn.
2943
+ const toolEls = new Map(); // block id → the row currently rendered for it
2944
+ const agentEls = new Map();
2945
+ let agents = null; // the .ask-agents section, once the row has one
2946
+ let agentsCount = null;
2947
+
2948
+ function renderHead(r) {
2949
+ const isLive = isLiveRow(r);
2950
+ const stopped = r.status === 'stopped' || r.status === 'error';
2951
+ const parts = [];
2952
+ if (isLive) parts.push(make('span', 'ask-activity-label', 'Thinking'));
2953
+ else if (!stopped) parts.push(make('span', 'ask-activity-label', 'Done'));
2954
+ parts.push(make('span', `ask-dot${isLive ? ' ask-dot-run' : r.status === 'error' ? '' : ' ask-dot-done'}`));
2955
+ // The head names its state in one word ahead of the dot — Thinking, Done, or
2956
+ // Stopped after — and nothing more while the turn is live: the orb row at the
2957
+ // bottom of the message owns the elapsed and the meter, and printing either
2958
+ // set twice is the noise this replaced. A turn that ended badly says so
2959
+ // instead of Done; nothing else marks a stop.
2960
+ if (!isLive) {
2961
+ if (stopped) parts.push(make('span', 'ask-activity-label', 'Stopped after'));
2962
+ parts.push(make('span', 'ask-activity-elapsed', fmtElapsed(r.durationMs) || ''));
2963
+ parts.push(make('span', 'ask-activity-spacer'));
2964
+ const meter = [fmtCtx(r.usage && r.usage.ctx), fmtUsd(r.costUsd)].filter(Boolean).join(' · ');
2965
+ parts.push(make('span', 'ask-activity-meter', meter));
2966
+ }
2967
+ head.replaceChildren(...parts);
2968
+ }
2969
+
2970
+ /** Re-render the tracked rows of one kind against `blocks`, in order, inside `host` before `before`. */
2971
+ function syncRows(blocks, els, build, host, before) {
2972
+ const keep = new Set();
2973
+ for (const b of blocks) {
2974
+ const fresh = build(b);
2975
+ const prev = els.get(b.id);
2976
+ if (prev) prev.replaceWith(fresh); // a block upserts by id: same slot, new node
2977
+ else host.insertBefore(fresh, before);
2978
+ els.set(b.id, fresh);
2979
+ keep.add(b.id);
2980
+ }
2981
+ for (const [id, node] of els) if (!keep.has(id)) { node.remove(); els.delete(id); }
2982
+ }
2983
+
2984
+ function sync(r) {
2985
+ renderHead(r);
2986
+ const blocks = Array.isArray(r.blocks) ? r.blocks : [];
2987
+ const agentBlocks = blocks.filter((b) => b && b.kind === 'agent');
2988
+ if (agentBlocks.length && !agents) {
2989
+ agents = make('div', 'ask-agents');
2990
+ const cap = make('div', 'ask-agents-cap');
2991
+ cap.appendChild(make('span', null, 'Sub-agents'));
2992
+ agentsCount = make('span', 'ask-agents-count', '');
2993
+ cap.appendChild(agentsCount);
2994
+ agents.appendChild(cap);
2995
+ activity.appendChild(agents);
2996
+ }
2997
+ // Tool rows go before the sub-agent section, so a tool that lands after the
2998
+ // first agent still slots into its own group.
2999
+ syncRows(blocks.filter((b) => b && b.kind === 'tool'), toolEls, toolRow, activity, agents);
3000
+ if (agents) {
3001
+ agentsCount.textContent = String(agentBlocks.length);
3002
+ syncRows(agentBlocks, agentEls, agentRow, agents, null);
3003
+ if (!agentBlocks.length) { agents.remove(); agents = null; agentsCount = null; }
3004
+ }
3005
+ }
3006
+
3007
+ sync(row);
3008
+ return { el: activity, update: sync };
3009
+ }
3010
+
3011
+ // The ONE orb: created on first live turn and re-parented into each live row a
3012
+ // STRUCTURAL repaint rebuilds (a tool block no longer rebuilds the row — see
3013
+ // buildMessage's patch path). Rebuilding it per row would restart the canvas
3014
+ // and the sphere would visibly snap back mid-turn.
1732
3015
  function ensureThinking() {
1733
3016
  if (el.thinking) return el.thinking;
1734
3017
  el.orb = createThinkingOrb({ doc, win, size: 28.5 });
@@ -1787,52 +3070,100 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1787
3070
  scheduleFlush();
1788
3071
  }
1789
3072
 
3073
+ /**
3074
+ * What the region BELOW the answer is made of — the notices, the cards, the
3075
+ * error line and whether the orb row belongs here. Rebuilding that region on
3076
+ * every frame would re-insert the cards inside the message, and a re-inserted
3077
+ * element replays its OWN entry animation (.ask-rp/.ask-wfcard/.ask-rc all
3078
+ * carry wr-rise), so it is rebuilt only when this string changes.
3079
+ */
3080
+ function tailSignature(row) {
3081
+ const parts = [];
3082
+ for (const b of row.blocks || []) {
3083
+ if (!b) continue;
3084
+ if (b.kind === 'notice') parts.push(`n:${b.id ?? ''}:${b.text || ''}:${b.href || ''}`);
3085
+ else if (b.kind === 'card') parts.push(`c:${b.id}:${b.state || ''}:${(b.card && b.card.type) || ''}:${b.runId || ''}:${b.error || ''}`);
3086
+ }
3087
+ parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${isLiveRow(row) ? 1 : 0}`);
3088
+ return parts.join('|');
3089
+ }
3090
+
1790
3091
  function buildMessage(row) {
1791
3092
  const wrap = make('div', `ask-msg ask-msg-${row.role}`);
3093
+ // The model REPLACES a row object on upsert (ask-model upsertRow), so every
3094
+ // closure below reads the latest one through this, never the captured `row`.
3095
+ let cur = row;
3096
+ let patch = null; // the in-place update for an assistant row; null ⇒ update() rebuilds
1792
3097
  let renderAnswer = null;
1793
3098
  if (row.role === 'user') {
1794
- const bubble = make('div', 'ask-user-bubble', row.text || '');
1795
- wrap.appendChild(bubble);
1796
- const atts = (row.blocks || []).filter((b) => b && b.kind === 'attachment');
1797
- if (atts.length) {
1798
- const pills = make('div', 'extras-pills ask-user-pills');
1799
- for (const b of atts) pills.appendChild(buildAttachmentPill(b));
1800
- wrap.appendChild(pills);
3099
+ // PD6: a synthetic row (a workflow-card event) is a notice, never a bubble — its text is the model-facing event line.
3100
+ const synthetic = (row.blocks || []).filter((b) => b && b.kind === 'notice' && b.synthetic);
3101
+ if (synthetic.length) {
3102
+ for (const b of synthetic) wrap.appendChild(buildNotice(b));
3103
+ } else {
3104
+ const bubble = make('div', 'ask-user-bubble', row.text || '');
3105
+ wrap.appendChild(bubble);
3106
+ const atts = (row.blocks || []).filter((b) => b && b.kind === 'attachment');
3107
+ if (atts.length) {
3108
+ const pills = make('div', 'extras-pills ask-user-pills');
3109
+ for (const b of atts) pills.appendChild(buildAttachmentPill(b));
3110
+ wrap.appendChild(pills);
3111
+ }
1801
3112
  }
1802
3113
  } else if (row.role === 'system') {
1803
3114
  const notices = (row.blocks || []).filter((b) => b && b.kind === 'notice');
1804
3115
  if (notices.length) for (const b of notices) wrap.appendChild(buildNotice(b));
1805
3116
  else wrap.appendChild(buildNotice({ text: row.text }));
1806
3117
  } else {
1807
- wrap.appendChild(buildActivity(row).el);
3118
+ const activity = buildActivity(row);
3119
+ wrap.appendChild(activity.el);
1808
3120
  const answer = make('div', 'ask-answer');
1809
3121
  wrap.appendChild(answer);
1810
- renderAnswer = () => renderAnswerInto(answer, row);
3122
+ renderAnswer = () => renderAnswerInto(answer, cur);
1811
3123
  renderAnswer();
1812
- for (const b of row.blocks || []) {
1813
- if (!b) continue;
1814
- if (b.kind === 'notice') wrap.appendChild(buildNotice(b));
1815
- else if (b.kind === 'card') wrap.appendChild(buildCard(b, row));
1816
- }
1817
- if (row.status === 'error') {
1818
- const explained = (row.blocks || []).some((b) => b && b.kind === 'notice');
1819
- if (row.errorMessage) wrap.appendChild(make('div', 'ask-error-line', row.errorMessage));
1820
- else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
1821
- }
1822
- if (st.model && st.model.live() && st.model.live().messageId === row.id) {
1823
- wrap.appendChild(ensureThinking()); // last child: the bottom of the message
1824
- el.elapsed = el.thinkingElapsed; // the ONE live elapsed node
1825
- // Idempotent, and the only re-arm on the adoption path: a thread whose
1826
- // ask-start the ring buffer already evicted goes live without ever
1827
- // passing through startElapsed(), and would otherwise show a dead orb.
1828
- el.orb.start();
1829
- updateThinking();
1830
- }
3124
+ let tailSig = null;
3125
+ const renderTail = () => {
3126
+ const sig = tailSignature(cur);
3127
+ if (sig === tailSig) return;
3128
+ tailSig = sig;
3129
+ while (wrap.lastChild && wrap.lastChild !== answer) wrap.removeChild(wrap.lastChild);
3130
+ for (const b of cur.blocks || []) {
3131
+ if (!b) continue;
3132
+ if (b.kind === 'notice') wrap.appendChild(buildNotice(b));
3133
+ else if (b.kind === 'card') wrap.appendChild(buildCard(b, cur));
3134
+ }
3135
+ if (cur.status === 'error') {
3136
+ const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
3137
+ if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3138
+ else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
3139
+ }
3140
+ if (isLiveRow(cur)) {
3141
+ wrap.appendChild(ensureThinking()); // last child: the bottom of the message
3142
+ el.elapsed = el.thinkingElapsed; // the ONE live elapsed node
3143
+ // Idempotent, and the only re-arm on the adoption path: a thread whose
3144
+ // ask-start the ring buffer already evicted goes live without ever
3145
+ // passing through startElapsed(), and would otherwise show a dead orb.
3146
+ el.orb.start();
3147
+ updateThinking();
3148
+ }
3149
+ };
3150
+ renderTail();
3151
+ // The answer is deliberately NOT re-rendered here: its text only ever moves
3152
+ // on `dirty.answer` (renderAnswerFor) or with `dirty.structure`, and
3153
+ // replacing its subtree per tool row is exactly what defeated the browser's
3154
+ // scroll anchoring mid-turn.
3155
+ patch = (row2) => { cur = row2; activity.update(row2); renderTail(); };
1831
3156
  }
1832
3157
  const entry = {
1833
3158
  el: wrap,
1834
3159
  renderAnswer,
1835
3160
  update(row2) {
3161
+ // An assistant row is PATCHED. Rebuilding it handed the column a brand-new
3162
+ // `.ask-msg` on every ask-label and every ask-block frame, so the whole
3163
+ // message — entry animation, answer subtree and cards — was re-created
3164
+ // under the text being read. A user/system row carries nothing live and
3165
+ // changes only through a structural repaint, so it still rebuilds.
3166
+ if (patch) { patch(row2); return; }
1836
3167
  const fresh = buildMessage(row2);
1837
3168
  wrap.replaceWith(fresh.el);
1838
3169
  st.rowEls.set(row2.id, fresh);
@@ -1842,13 +3173,23 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1842
3173
  }
1843
3174
 
1844
3175
  function renderTranscript() {
3176
+ // A card element outlives its row entry (it is cached by card id): drop — and dispose — the ones no row carries any more.
3177
+ const keep = new Set();
3178
+ if (st.model) for (const row of st.model.messages()) for (const b of row.blocks || []) if (b && b.kind === 'card' && b.id != null) keep.add(b.id);
3179
+ pruneCardEls(keep);
1845
3180
  st.rowEls = new Map();
1846
- el.transcript.replaceChildren();
3181
+ el.transcriptCol.replaceChildren();
1847
3182
  if (!st.model) return;
1848
3183
  for (const row of st.model.messages()) {
1849
3184
  const entry = buildMessage(row);
3185
+ // The entry animation belongs to a message this transcript has never shown.
3186
+ // ask-start, ask-done and every ask-message rebuild the WHOLE column, so
3187
+ // without the ledger the rows already on screen would rise and fade in
3188
+ // again — including on a mid-turn resync, which repaints the same thread.
3189
+ // The ledger is written at the END of a flush, not here: see flush().
3190
+ if (!st.seenRows.has(row.id)) entry.el.setAttribute('data-ask-enter', '');
1850
3191
  st.rowEls.set(row.id, entry);
1851
- el.transcript.appendChild(entry.el);
3192
+ el.transcriptCol.appendChild(entry.el);
1852
3193
  }
1853
3194
  }
1854
3195
 
@@ -1868,6 +3209,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1868
3209
  let snap = null;
1869
3210
  try { snap = await res.json(); } catch { return null; }
1870
3211
  if (gen !== loadGen || st.destroyed) return null;
3212
+ // A SWITCH starts a fresh ledger, so the new chat rises in. A resync or a
3213
+ // reconnect re-loads the SAME thread and must keep it: those repaint rows the
3214
+ // user is already reading, mid-turn.
3215
+ if (st.threadId !== id) st.seenRows = new Set();
1871
3216
  st.threadId = id;
1872
3217
  st.model = createThreadModel({ threadId: id });
1873
3218
  st.model.load(snap);
@@ -1884,6 +3229,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1884
3229
  stopElapsed(); // a mid-stream thread switch must not leave the old
1885
3230
  updateSendStop(); // turn's timer or stop button behind (V3/D2 reset)
1886
3231
  if (snap.inFlight) { subscribe(id); startElapsed(); }
3232
+ repaintProgressCards({ hydrate: true }); // thread load + reconnect (onHello → resync → loadThread): re-resolve + re-hydrate every card
1887
3233
  return snap;
1888
3234
  }
1889
3235
 
@@ -1985,6 +3331,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1985
3331
  function pushServerFrame(frame) {
1986
3332
  if (st.destroyed || !frame) return;
1987
3333
  if (frame.type === 'ask-history-cleared') { onHistoryCleared(); return; }
3334
+ if (THREADS_REFRESH_FRAMES.has(frame.type)) scheduleThreadsRefresh();
1988
3335
  // 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).
1989
3336
  if (!st.model || frame.threadId !== st.threadId) return;
1990
3337
  const r = st.model.apply(frame);
@@ -2061,6 +3408,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2061
3408
  }
2062
3409
  if (d.meters) updateMeters();
2063
3410
  if (d.worktrees) setWorktrees(st.model.worktrees());
3411
+ if (d.runLinks) st.runPoked = true; // D13: a first `state` reveals the pipeline id; a resume moves the runId
2064
3412
  // An open popover that subscribed to this flush's dirt is rebuilt in place
2065
3413
  // (same node — never reopened, never refocused). Runs AFTER the mirror and
2066
3414
  // the meters above: the worktrees build() reads st.worktrees.
@@ -2079,7 +3427,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2079
3427
  function flush() {
2080
3428
  if (st.destroyed) return;
2081
3429
  flushExtra();
3430
+ if (st.runPoked) repaintProgressCards();
3431
+ relayoutCards();
2082
3432
  applyPin();
3433
+ // A row counts as SHOWN once a flush ends with it in the column. Marking it
3434
+ // inside renderTranscript() would be too early: loadThread paints once and the
3435
+ // structural flush behind it repaints immediately, and the browser only ever
3436
+ // shows the second element — a freshly loaded thread would never rise in.
3437
+ if (st.rowEls) for (const id of st.rowEls.keys()) st.seenRows.add(id);
2083
3438
  }
2084
3439
 
2085
3440
  function updatePinFromScroll() {
@@ -2090,7 +3445,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2090
3445
 
2091
3446
  function applyPin() {
2092
3447
  if (!st.open) return;
2093
- if (st.pinned) el.transcript.scrollTop = el.transcript.scrollHeight;
3448
+ if (st.pinned) {
3449
+ const t = el.transcript;
3450
+ // Only when the bottom has actually moved away. flush() runs this on EVERY
3451
+ // rAF of a stream, and an unconditional write re-snapped the scrollport on
3452
+ // each one — which is what turned a growing answer into a twitch.
3453
+ const max = t.scrollHeight - t.clientHeight;
3454
+ if (max > 0 && t.scrollTop < max) t.scrollTop = t.scrollHeight;
3455
+ }
2094
3456
  if (el.jump) el.jump.hidden = st.pinned;
2095
3457
  }
2096
3458
 
@@ -2104,15 +3466,30 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2104
3466
  const root = buildRoot();
2105
3467
  doc.addEventListener('keydown', onDocKeydown, true);
2106
3468
  doc.addEventListener('pointerdown', onDocPointerdown, true);
3469
+ win.addEventListener('resize', onWinResize);
3470
+ // The rail collapsing changes the dock width by 222px with no window resize;
3471
+ // observe the dock itself (guarded: jsdom has no ResizeObserver — P5's idiom).
3472
+ let dockRo = null;
3473
+ if (typeof win.ResizeObserver === 'function') {
3474
+ dockRo = new win.ResizeObserver(onWinResize);
3475
+ dockRo.observe(el.dock);
3476
+ }
2107
3477
 
2108
3478
  function destroy() {
2109
3479
  if (st.destroyed) return;
2110
3480
  st.destroyed = true;
3481
+ finishResize(); // a mid-drag unmount leaves no document listeners
2111
3482
  closePopover({ focusTrigger: false });
2112
3483
  if (st.elapsedTimer) { clearInterval(st.elapsedTimer); st.elapsedTimer = null; }
2113
3484
  if (el.orb) el.orb.stop();
3485
+ settlePillOrb();
2114
3486
  doc.removeEventListener('keydown', onDocKeydown, true);
2115
3487
  doc.removeEventListener('pointerdown', onDocPointerdown, true);
3488
+ win.removeEventListener('resize', onWinResize);
3489
+ if (dockRo) { dockRo.disconnect(); dockRo = null; }
3490
+ if (st.runTick) { clearInterval(st.runTick); st.runTick = null; }
3491
+ if (st.runUnsub) { try { st.runUnsub(); } catch { /* ignore */ } st.runUnsub = null; }
3492
+ pruneCardEls(); // every card graph mount and its ResizeObserver goes with the sheet
2116
3493
  root.remove();
2117
3494
  }
2118
3495