@worca/app 1.2.0 → 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 +3715 -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 +1359 -214
  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 +423 -25
  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 +1450 -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 +1282 -193
@@ -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
@@ -87,7 +93,7 @@ export function shortcutLabel(win) {
87
93
 
88
94
  /**
89
95
  * Sheet geometry shared with style.css: .ask-dock{padding:0 28px 26px} and
90
- * .ask-sheet{width:min(782px,100%);height:min(669px,calc(100% - 20px))}. The
96
+ * .ask-sheet{width:min(821px,100%);height:min(669px,calc(100% - 20px))}. The
91
97
  * user's size is clamped to [minW×minH, dock inner box]. The floor IS the
92
98
  * stylesheet default: the sheet grows from what it always was and never shrinks
93
99
  * below it, so every layout the fixed-size sheet was designed around (the
@@ -95,13 +101,43 @@ export function shortcutLabel(win) {
95
101
  * Border-box px throughout — the sheet has no padding.
96
102
  */
97
103
  export const ASK_SHEET_SIZE = Object.freeze({
98
- defaultW: 782, defaultH: 669,
99
- minW: 782, minH: 669,
104
+ defaultW: 821, defaultH: 669,
105
+ minW: 821, minH: 669,
100
106
  dockPadX: 28, dockPadBottom: 26, topGap: 20,
101
107
  });
102
- const SIZE_KEY = 'worca-cc.ask.size';
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
+ }
103
126
 
104
- export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, loadMarkdown, hljsLoader, storage, raf, now }) {
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 }) {
105
141
  const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
106
142
  const st = {
107
143
  open: false,
@@ -120,7 +156,8 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
120
156
  // #397: the thread's project/workspace scope. pinned:false = Auto (follow the
121
157
  // page — today's behaviour). label caches the display name once resolved.
122
158
  scope: { pinned: false, projectKey: null, workspaceId: null, label: null },
123
- 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
124
161
  expandedAgents: new Set(),
125
162
  worktrees: [], // P4 §10: the chat's open worktrees (snapshot-fed)
126
163
  pinned: true,
@@ -133,18 +170,25 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
133
170
  subscribedFor: null,
134
171
  elapsedTimer: null,
135
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
136
175
  flushArmed: false,
137
176
  resyncing: false,
138
177
  firstOpenDone: false,
139
178
  destroyed: false,
140
179
  lastAnswerRender: 0,
141
180
  rowEls: null,
181
+ seenRows: new Set(), // message ids the transcript has already shown — see renderTranscript
142
182
  cardEls: null,
143
183
  cardOptions: null,
144
184
  catalogLoading: null,
145
185
  mdKicked: false,
146
186
  answerPending: null,
147
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,
148
192
  };
149
193
  const el = {}; // element refs, filled by the builders
150
194
  const renderer = createMarkdownRenderer({ doc, load: loadMarkdown, hljsLoader });
@@ -248,11 +292,28 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
248
292
 
249
293
  const pill = make('button', 'ask-pill');
250
294
  pill.type = 'button';
251
- const pillLogo = doc.createElement('img');
252
- pillLogo.className = 'ask-pill-logo';
253
- pillLogo.src = '/assets/worca-favicon.png';
254
- pillLogo.alt = '';
255
- 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);
256
317
  pill.appendChild(make('span', 'ask-pill-label', 'Ask Worca'));
257
318
  pill.appendChild(make('span', 'ask-kbd', shortcutLabel(win)));
258
319
  pill.addEventListener('click', openSheet);
@@ -264,10 +325,8 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
264
325
  sheet.setAttribute('aria-label', 'Ask Worca');
265
326
 
266
327
  const header = make('header', 'ask-header');
267
- const logo = doc.createElement('img');
268
- logo.className = 'ask-header-logo';
269
- logo.src = '/assets/worca-favicon.png';
270
- logo.alt = '';
328
+ const logo = make('span', 'ask-header-logo');
329
+ logo.setAttribute('aria-hidden', 'true');
271
330
  header.appendChild(logo);
272
331
  el.title = make('div', 'ask-title', 'Ask Worca');
273
332
  // Header is logo → title → spacer → icon buttons. The #397 scope selector
@@ -287,6 +346,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
287
346
  el.transcript = make('div', 'ask-transcript');
288
347
  el.transcript.setAttribute('data-ask-scroll', '');
289
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);
290
354
  sheet.appendChild(el.transcript);
291
355
 
292
356
  sheet.appendChild(buildComposer());
@@ -390,6 +454,50 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
390
454
  const streaming = !!(st.model && st.model.live());
391
455
  el.send.hidden = streaming;
392
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();
393
501
  }
394
502
 
395
503
  function updateMeters() {
@@ -427,6 +535,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
427
535
  if (!text) return;
428
536
  st.sending = true;
429
537
  setComposerMsg(null);
538
+ updateSendStop(); // the pill lights the moment the user sends; Send/Stop do not move (nothing streams yet)
430
539
  try {
431
540
  let id = st.threadId;
432
541
  if (!id) {
@@ -491,10 +600,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
491
600
 
492
601
  function buildComposer() {
493
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);
494
609
 
495
610
  el.chips = make('div', 'ask-chips');
496
611
  el.chips.hidden = true;
497
- wrap.appendChild(el.chips);
612
+ box.appendChild(el.chips);
498
613
 
499
614
  el.input = doc.createElement('textarea');
500
615
  el.input.className = 'ask-input';
@@ -504,11 +619,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
504
619
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
505
620
  });
506
621
  el.input.addEventListener('input', fitInput);
507
- wrap.appendChild(el.input);
622
+ box.appendChild(el.input);
508
623
 
509
624
  el.composerMsg = make('div', 'ask-composer-msg');
510
625
  el.composerMsg.hidden = true;
511
- wrap.appendChild(el.composerMsg);
626
+ box.appendChild(el.composerMsg);
512
627
 
513
628
  const row = make('div', 'ask-composer-row');
514
629
 
@@ -527,7 +642,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
527
642
  // independent of the page behind the sheet. It sits right after "+"
528
643
  // (attach → scope → spacer → meter …): the pill keeps its width (style.css
529
644
  // .ask-scope-btn flex:none), the spacer absorbs the slack. Its popover
530
- // (.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.
531
646
  const scopeBtn = make('button', 'ask-scope-btn');
532
647
  scopeBtn.type = 'button';
533
648
  scopeBtn.setAttribute('data-ask-scope-btn', '');
@@ -545,10 +660,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
545
660
  meter.setAttribute('data-ask-meter', '');
546
661
  el.meterTokens = make('span', 'ask-meter-tokens', '0 ctx');
547
662
  meter.appendChild(el.meterTokens);
548
- meter.appendChild(make('span', 'ask-meter-sep', '|'));
663
+ { const sep = make('span', 'ask-meter-sep', '|'); sep.setAttribute('aria-hidden', 'true'); meter.appendChild(sep); }
549
664
  el.meterCost = make('span', 'ask-meter-cost', '');
550
665
  meter.appendChild(el.meterCost);
551
- meter.appendChild(make('span', 'ask-meter-sep', '|'));
666
+ { const sep = make('span', 'ask-meter-sep', '|'); sep.setAttribute('aria-hidden', 'true'); meter.appendChild(sep); }
552
667
  row.appendChild(meter);
553
668
 
554
669
  const wtBtn = make('button', 'ask-agents-btn ask-wt-btn');
@@ -610,7 +725,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
610
725
  el.stop.addEventListener('click', stopTurn);
611
726
  row.appendChild(el.stop);
612
727
 
613
- wrap.appendChild(row);
728
+ box.appendChild(row);
614
729
  return wrap;
615
730
  }
616
731
 
@@ -625,12 +740,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
625
740
  st.open = true;
626
741
  st.prevFocus = doc.activeElement;
627
742
  el.pill.hidden = true;
743
+ syncPillOrb(); // nothing to paint behind the sheet
628
744
  el.sheet.hidden = false;
629
745
  restoreSize(); // the sheet has a box now — clamp the stored size to the dock
630
746
  st.pinned = true;
631
747
  ensureFirstOpen();
632
748
  focusComposer();
633
749
  scheduleFlush();
750
+ repaintProgressCards({ hydrate: true });
634
751
  }
635
752
 
636
753
  /**
@@ -657,6 +774,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
657
774
  st.open = false;
658
775
  el.sheet.hidden = true;
659
776
  el.pill.hidden = false;
777
+ syncPillOrb(); // still live? the orb loop comes back, whole
660
778
  const prev = st.prevFocus;
661
779
  st.prevFocus = null;
662
780
  if (prev && prev.isConnected && typeof prev.focus === 'function') { try { prev.focus(); return; } catch { /* fall through */ } }
@@ -714,7 +832,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
714
832
  };
715
833
  }
716
834
 
717
- /** Write the inline size, or clear it (null) so min(782px,100%) rules again. */
835
+ /** Write the inline size, or clear it (null) so min(821px,100%) rules again. */
718
836
  function applySize(size) {
719
837
  if (size) {
720
838
  el.sheet.style.width = `${size.w}px`;
@@ -833,10 +951,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
833
951
  storeSize(null);
834
952
  }
835
953
 
836
- /** Window resize or dock resize (the rail toggling, the dock's slide): re-clamp — never under a held pointer. */
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. */
837
956
  function onWinResize() {
838
- if (st.destroyed || !st.open || !st.size || st.drag) return;
839
- restoreSize();
957
+ if (st.destroyed || !st.open || st.drag) return;
958
+ if (st.size) restoreSize();
959
+ relayoutCards();
840
960
  }
841
961
 
842
962
  // ---- keyboard + pointer routing ------------------------------------------
@@ -886,6 +1006,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
886
1006
  const p = st.popover;
887
1007
  if (!p) return;
888
1008
  st.popover = null;
1009
+ if (st.threadsRefresh) { clearTimeout(st.threadsRefresh); st.threadsRefresh = null; }
889
1010
  p.panel.remove();
890
1011
  if (p.onClose) { try { p.onClose(); } catch { /* ignore */ } }
891
1012
  if (focusTrigger) { try { p.trigger.focus(); } catch { /* ignore */ } }
@@ -907,7 +1028,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
907
1028
  else if ((e.key === 'Enter' || e.key === ' ') && idx >= 0) { e.preventDefault(); items[idx].click(); }
908
1029
  }
909
1030
 
910
- function openPopover({ panelClass, trigger, build, onClose, refreshOn }) {
1031
+ function openPopover({ panelClass, trigger, build, onClose, refreshOn, refresh }) {
911
1032
  if (st.popover && st.popover.trigger === trigger) { closePopover({ focusTrigger: false }); return null; }
912
1033
  closePopover({ focusTrigger: false });
913
1034
  const panel = make('div', `ask-pop ${panelClass}`);
@@ -917,7 +1038,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
917
1038
  el.sheet.appendChild(panel);
918
1039
  // refreshOn(dirty) → true re-runs build() on that flush (flushExtra), so an
919
1040
  // OPEN popover follows the live meters / worktrees instead of freezing at open.
920
- 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 };
921
1044
  const first = menuItems(panel)[0];
922
1045
  if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
923
1046
  return panel;
@@ -966,8 +1089,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
966
1089
  head.appendChild(meter);
967
1090
  p.appendChild(head);
968
1091
  },
1092
+ refresh: (p) => loadThreadRows(p, meter),
969
1093
  });
970
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) {
971
1102
  Promise.resolve()
972
1103
  .then(() => fetch('/api/ask/threads?limit=50'))
973
1104
  .then((r) => (r && r.ok ? r.json() : { threads: [] }))
@@ -978,11 +1109,34 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
978
1109
  // `total` is EVERY saved chat (the route caps rows at limit); an older
979
1110
  // server without it degrades to the page size.
980
1111
  if (meter) meter.textContent = fmtChats(Number.isInteger(total) && total >= 0 ? total : rows.length);
981
- 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);
982
1120
  });
983
1121
  }
984
1122
 
985
- 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) {
986
1140
  if (!threads.length) {
987
1141
  panel.appendChild(make('div', 'ask-pop-empty', 'No saved chats.'));
988
1142
  return;
@@ -994,11 +1148,13 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
994
1148
  for (const t of threads) {
995
1149
  const row = make('div', 'ask-thread-row');
996
1150
  const pick = menuItem('ask-thread-pick', () => { closePopover({ focusTrigger: false }); switchThread(t.id); });
997
- // The dot leads the row, sitting against the title where it reads as "this
998
- // chat is live" -- .ask-thread-dot collapses it (display:none) unless the
999
- // 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
1000
1155
  // starts at the left edge. The date rides the meter line under the title.
1001
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' : ''}`));
1002
1158
  const col = make('span', 'ask-thread-col');
1003
1159
  // A null title = the haiku title has not landed yet (the message route
1004
1160
  // stamps nothing); "New chat" is the same label the turn falls back to.
@@ -1010,8 +1166,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1010
1166
  list.appendChild(row);
1011
1167
  }
1012
1168
  panel.appendChild(list);
1013
- const first = menuItems(panel)[0];
1014
- 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 */ } }
1015
1173
  }
1016
1174
 
1017
1175
  // ---- catalog + picker (D8) ------------------------------------------------
@@ -1439,11 +1597,13 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1439
1597
  loadGen += 1; // a load still in flight must not resurrect the old thread
1440
1598
  st.threadId = null;
1441
1599
  st.model = null;
1600
+ st.seenRows = new Set(); // a different chat: its rows have never been shown
1442
1601
  st.subscribedFor = null;
1443
1602
  stopElapsed();
1444
1603
  storeThread(null);
1445
1604
  el.title.textContent = 'Ask Worca';
1446
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
1447
1607
  renderTranscript();
1448
1608
  updateMeters();
1449
1609
  setWorktrees([]);
@@ -1518,8 +1678,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1518
1678
  // by card id and REUSED across message re-renders, so streaming updates and
1519
1679
  // proposed re-emits never clobber what the user typed. Only a STATE change
1520
1680
  // (started/dismissed/failed) builds a fresh terminal element.
1521
- function loadCardOptions() {
1522
- 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;
1523
1687
  const grab = (url, key) => Promise.resolve()
1524
1688
  .then(() => fetch(url))
1525
1689
  .then((r) => (r && r.ok ? r.json() : null))
@@ -1553,10 +1717,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1553
1717
  if (value != null && [...select.options].some((o) => o.value === value)) select.value = value;
1554
1718
  }
1555
1719
 
1720
+ /** Returns the fill promise so a caller can re-read the select once the list lands. */
1556
1721
  function loadBranchesInto(select, projectDir, want) {
1557
1722
  fillSelect(select, [{ value: '', label: 'current branch (auto)' }], '');
1558
- if (!projectDir) return;
1559
- Promise.resolve()
1723
+ if (!projectDir) return Promise.resolve();
1724
+ return Promise.resolve()
1560
1725
  .then(() => fetch(`/api/branches?projectDir=${encodeURIComponent(projectDir)}`))
1561
1726
  .then((r) => (r && r.ok ? r.json() : null))
1562
1727
  .catch(() => null)
@@ -1570,28 +1735,529 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1570
1735
 
1571
1736
  function wsBasename(p) { return String(p || '').replace(/\/+$/, '').split('/').pop() || String(p || ''); }
1572
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. */
1573
1978
  function buildCardTerminal(block) {
1574
1979
  const card = block.card || {};
1575
- if (block.state === 'started') {
1576
- const n = make('div', 'ask-card ask-card-started');
1577
- n.appendChild(make('span', null, `Run started — ${card.title || card.brief || 'run'} `));
1578
- const a = make('a', 'ask-card-link', 'open');
1579
- a.setAttribute('href', `#running/${block.runId || ''}`);
1580
- n.appendChild(a);
1581
- return n;
1582
- }
1583
1980
  if (block.state === 'failed') {
1584
1981
  return make('div', 'ask-card-stub ask-card-failed', `Run failed${block.error ? `: ${block.error}` : ''} — ${card.title || card.brief || ''}`);
1585
1982
  }
1586
1983
  return make('div', 'ask-card-stub', `Not now — ${card.title || card.brief || 'run proposal'}`);
1587
1984
  }
1588
1985
 
1589
- 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) {
1590
2001
  const card = block.card || {};
1591
- const rootEl = make('div', 'ask-card');
1592
- 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
+ }
1593
2077
 
1594
- 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);
1595
2261
 
1596
2262
  // #397 guardrail: the model proposed a different target than the chat's pin.
1597
2263
  if (block.scopeMismatch) {
@@ -1599,147 +2265,275 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1599
2265
  'This proposal targets a different project or workspace than the one pinned for this chat — check the target before starting.'));
1600
2266
  }
1601
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);
1602
2273
  const seg = make('div', 'ask-card-seg');
2274
+ seg.setAttribute('role', 'tablist');
1603
2275
  const segBtns = {};
1604
2276
  for (const [t, label] of [['project', 'Project'], ['workspace', 'Workspace']]) {
1605
2277
  const b = make('button', 'ask-card-seg-btn', label);
1606
2278
  b.type = 'button';
2279
+ b.setAttribute('role', 'tab');
1607
2280
  b.setAttribute('data-ask-card-seg', t);
1608
2281
  b.addEventListener('click', () => {
1609
2282
  if (local.target === t) return;
1610
2283
  local.target = t;
1611
- for (const k of Object.keys(segBtns)) segBtns[k].classList.toggle('on', k === local.target);
2284
+ paintSeg();
1612
2285
  renderTarget();
2286
+ reloadLane();
1613
2287
  });
1614
2288
  segBtns[t] = b;
1615
2289
  seg.appendChild(b);
1616
2290
  }
1617
- segBtns[local.target].classList.add('on');
1618
- rootEl.appendChild(seg);
1619
-
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);
1620
2296
  const targetHost = make('div', 'ask-card-target');
1621
- rootEl.appendChild(targetHost);
1622
-
1623
- const field = (label, control) => {
1624
- const f = make('div', 'ask-card-field');
1625
- f.appendChild(make('label', 'ask-card-label', label));
1626
- f.appendChild(control);
1627
- return f;
1628
- };
1629
-
1630
- const workflowSel = doc.createElement('select');
1631
- workflowSel.className = 'ask-card-workflow';
1632
- 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
+ });
1633
2317
 
1634
- const guardSel = doc.createElement('select');
1635
- guardSel.className = 'ask-card-guardrails';
1636
- 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);
1637
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);
1638
2328
  const brief = doc.createElement('textarea');
1639
- brief.className = 'ask-card-brief';
2329
+ brief.className = 'ask-card-brief ask-rp-brief';
1640
2330
  brief.value = card.brief || '';
1641
- 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 = () => {
1642
2355
  brief.style.height = 'auto';
1643
- 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 */ }
1644
2374
  });
1645
- rootEl.appendChild(field('Task brief', brief));
2375
+ renderPills();
2376
+ grow();
1646
2377
 
2378
+ // ONE feature-branch input for both targets; renderTarget moves it into the current grid.
1647
2379
  const feature = doc.createElement('input');
1648
2380
  feature.type = 'text';
1649
2381
  feature.className = 'ask-card-feature';
1650
2382
  feature.value = card.featureBranch || '';
1651
- rootEl.appendChild(field('Feature branch', feature));
2383
+ feature.setAttribute('aria-label', 'Feature branch');
1652
2384
 
1653
2385
  const err = make('div', 'ask-card-err');
1654
2386
  rootEl.appendChild(err);
1655
2387
 
1656
- const actions = make('div', 'ask-card-actions');
1657
- 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');
1658
2391
  openNp.type = 'button';
1659
2392
  openNp.setAttribute('data-ask-card-open-np', '');
1660
2393
  openNp.addEventListener('click', () => prefillFromCard(block, rootEl, local));
1661
- actions.appendChild(openNp);
1662
- actions.appendChild(make('span', 'ask-card-actions-spacer'));
1663
2394
  const dismissBtn = make('button', 'ask-card-not-now', 'Not now');
1664
2395
  dismissBtn.type = 'button';
1665
2396
  dismissBtn.setAttribute('data-ask-card-dismiss', '');
1666
2397
  dismissBtn.addEventListener('click', () => dismissCard(block, rootEl));
1667
- actions.appendChild(dismissBtn);
1668
- const startBtn = make('button', 'ask-card-start', 'Start');
2398
+ const startBtn = make('button', 'ask-card-start');
1669
2399
  startBtn.type = 'button';
1670
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'));
1671
2406
  startBtn.addEventListener('click', () => startCard(block, rootEl, local));
1672
- actions.appendChild(startBtn);
1673
- 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);
1674
2428
 
1675
2429
  function renderTarget() {
1676
2430
  targetHost.replaceChildren();
1677
2431
  const opts = local.options;
2432
+ const grid = make('div', 'ask-rp-grid');
1678
2433
  if (local.target === 'project') {
1679
- const projSel = doc.createElement('select');
1680
- projSel.className = 'ask-card-project-select';
1681
- const srcSel = doc.createElement('select');
1682
- srcSel.className = 'ask-card-source';
2434
+ const projSel = rpSelect('ask-card-project-select', 'Project');
2435
+ const srcSel = rpSelect('ask-card-source', 'Source branch');
1683
2436
  if (opts) {
1684
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) || '');
1685
- 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);
1686
2441
  }
1687
- projSel.addEventListener('change', () => loadBranchesInto(srcSel, projSel.value, ''));
1688
- targetHost.appendChild(field('Project', projSel));
1689
- targetHost.appendChild(field('Source branch', srcSel));
1690
- } else {
1691
- const wsSel = doc.createElement('select');
1692
- wsSel.className = 'ask-card-workspace-select';
1693
- const members = make('div', 'ask-card-members');
1694
- const srcInput = doc.createElement('input');
1695
- srcInput.type = 'text';
1696
- srcInput.className = 'ask-card-source-input';
1697
- srcInput.placeholder = 'auto';
1698
- srcInput.value = card.sourceBranch || '';
1699
- const details = doc.createElement('details');
1700
- // .disclosure swaps the OS triangle for the app's own chevron
1701
- details.className = 'ask-card-members-src disclosure';
1702
- details.appendChild(make('summary', null, 'Per-member source branches'));
1703
- const memberHost = make('div', 'ask-card-members-src-list');
1704
- details.appendChild(memberHost);
1705
- const renderMembers = () => {
1706
- members.replaceChildren();
1707
- memberHost.replaceChildren();
1708
- const row = opts && opts.workspaces.find((w) => w && w.id === wsSel.value);
1709
- const list = row && Array.isArray(row.projectKeys)
1710
- ? row.projectKeys.map((k, i) => ({ projectKey: k, name: wsBasename(row.projectPaths && row.projectPaths[i]) }))
1711
- : Array.isArray(card.members) ? card.members.map((m) => ({ projectKey: m.projectKey, name: m.projectName })) : [];
1712
- members.textContent = list.map((m) => m.name).join(', ');
1713
- for (const m of list) {
1714
- const inp = doc.createElement('input');
1715
- inp.type = 'text';
1716
- inp.className = 'ask-card-member-src';
1717
- inp.placeholder = 'auto';
1718
- inp.setAttribute('data-project-key', m.projectKey);
1719
- if (card.sourceBranchByKey && card.sourceBranchByKey[m.projectKey]) inp.value = card.sourceBranchByKey[m.projectKey];
1720
- memberHost.appendChild(field(m.name, inp));
1721
- }
1722
- };
1723
- if (opts) {
1724
- fillSelect(wsSel, opts.workspaces.map((w) => ({ value: w.id, label: w.name || w.id })), card.workspaceId || (opts.workspaces[0] && opts.workspaces[0].id) || '');
1725
- 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));
1726
2478
  }
1727
- wsSel.addEventListener('change', renderMembers);
1728
- targetHost.appendChild(field('Workspace', wsSel));
1729
- targetHost.appendChild(members);
1730
- targetHost.appendChild(field('Source branch (default)', srcInput));
1731
- 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();
1732
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;
1733
2524
  }
1734
2525
 
1735
2526
  renderTarget();
1736
- loadCardOptions().then((opts) => {
2527
+ loadCardOptions({ fresh: true }).then((opts) => {
1737
2528
  if (st.destroyed) return;
1738
2529
  local.options = opts;
1739
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();
1740
2532
  fillSelect(guardSel, opts.guardrails.map((g) => ({ value: g.id, label: g.id === 'permissive' ? 'Permissive' : (g.name || g.id) })), card.guardrailsId || 'normal');
1741
2533
  renderTarget();
2534
+ reloadLane();
1742
2535
  });
2536
+ rootEl.__rp = { local, wfDesc, summary, laneSec, briefSec, brief, titleInput, lane: () => local.lane }; // consumed by later tasks + tests
1743
2537
  return rootEl;
1744
2538
  }
1745
2539
 
@@ -1748,9 +2542,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1748
2542
  prompt: rootEl.querySelector('.ask-card-brief').value,
1749
2543
  workflowId: rootEl.querySelector('.ask-card-workflow').value,
1750
2544
  guardrailsId: rootEl.querySelector('.ask-card-guardrails').value, // ALWAYS sent (spec §9.4)
1751
- title: card.title || undefined,
2545
+ title: ((rootEl.querySelector('.ask-rp-title input') || {}).value || '').trim() || card.title || undefined,
1752
2546
  mock: false,
1753
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;
1754
2551
  const feature = rootEl.querySelector('.ask-card-feature').value.trim();
1755
2552
  if (feature) body.featureBranch = feature;
1756
2553
  if (local.target === 'workspace') {
@@ -1772,13 +2569,29 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1772
2569
  return body;
1773
2570
  }
1774
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
+
1775
2581
  async function startCard(block, rootEl, local) {
1776
2582
  const err = rootEl.querySelector('.ask-card-err');
1777
2583
  const startBtn = rootEl.querySelector('[data-ask-card-start]');
1778
2584
  err.textContent = '';
1779
2585
  startBtn.disabled = true;
2586
+ freezeTargetInputs(rootEl, true);
1780
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; }
1781
2593
  const body = { ...collectCardBody(rootEl, local, block.card || {}), askThreadId: st.threadId, askCardId: block.id };
2594
+ if (ex.extras.length) body.extras = ex.extras;
1782
2595
  let res = null;
1783
2596
  try {
1784
2597
  res = await fetch('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -1794,23 +2607,43 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1794
2607
  // (beginRun is NEVER called — spec §10.5).
1795
2608
  } finally {
1796
2609
  startBtn.disabled = false;
2610
+ freezeTargetInputs(rootEl, false);
1797
2611
  }
1798
2612
  }
1799
2613
 
1800
- 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) {
1801
2616
  const err = rootEl.querySelector('.ask-card-err');
1802
- 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; } };
1803
2621
  let res = null;
1804
2622
  try {
1805
- res = await fetch(`/api/ask/threads/${st.threadId}/cards/${block.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: 'dismissed' }) });
1806
- } 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; }
1807
2625
  if (!res.ok) {
1808
2626
  let msg = `request failed (${res.status})`;
1809
2627
  try { const b = await res.json(); if (b && b.error) msg = b.error; } catch { /* keep */ }
1810
- 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;
1811
2634
  }
1812
- // the flip frame renders the stub
1813
- }
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' }); }
1814
2647
 
1815
2648
  function prefillFromCard(block, rootEl, local) {
1816
2649
  const card = block.card || {};
@@ -1819,8 +2652,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1819
2652
  workflowId: rootEl.querySelector('.ask-card-workflow').value,
1820
2653
  guardrailsId: rootEl.querySelector('.ask-card-guardrails').value,
1821
2654
  prompt: rootEl.querySelector('.ask-card-brief').value,
1822
- title: card.title || '',
2655
+ title: ((rootEl.querySelector('.ask-rp-title input') || {}).value || '').trim() || card.title || '',
1823
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,
1824
2660
  };
1825
2661
  if (local.target === 'workspace') {
1826
2662
  p.workspaceId = rootEl.querySelector('.ask-card-workspace-select').value;
@@ -1838,16 +2674,195 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1838
2674
  const src = rootEl.querySelector('.ask-card-source');
1839
2675
  p.sourceBranch = src ? src.value : '';
1840
2676
  }
1841
- 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);
1842
2709
  }
1843
-
1844
2710
  function buildCard(block) {
1845
2711
  if (!st.cardEls) st.cardEls = new Map();
1846
2712
  const cached = st.cardEls.get(block.id);
1847
- if (cached && cached.state === block.state && block.state === 'proposed') return cached.el;
1848
- const built = block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block);
1849
- st.cardEls.set(block.id, { el: built, state: block.state });
1850
- 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
+ });
1851
2866
  }
1852
2867
 
1853
2868
  function toolRow(block) {
@@ -1911,45 +2926,92 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1911
2926
  if (entry) entry.update(row);
1912
2927
  }
1913
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
+ */
1914
2939
  function buildActivity(row) {
1915
- const isLive = !!(st.model && st.model.live() && st.model.live().messageId === row.id);
1916
2940
  const activity = make('div', 'ask-activity');
1917
2941
  const head = make('div', 'ask-activity-head');
1918
- const stopped = row.status === 'stopped' || row.status === 'error';
1919
- if (isLive) head.appendChild(make('span', 'ask-activity-label', 'Thinking'));
1920
- else if (!stopped) head.appendChild(make('span', 'ask-activity-label', 'Done'));
1921
- head.appendChild(make('span', `ask-dot${isLive ? ' ask-dot-run' : row.status === 'error' ? '' : ' ask-dot-done'}`));
1922
- // The head names its state in one word ahead of the dot — Thinking, Done, or
1923
- // Stopped after — and nothing more while the turn is live: the orb row at the
1924
- // bottom of the message owns the elapsed and the meter, and printing either
1925
- // set twice is the noise this replaced. A turn that ended badly says so
1926
- // instead of Done; nothing else marks a stop.
1927
- if (!isLive) {
1928
- if (stopped) head.appendChild(make('span', 'ask-activity-label', 'Stopped after'));
1929
- head.appendChild(make('span', 'ask-activity-elapsed', fmtElapsed(row.durationMs) || ''));
1930
- head.appendChild(make('span', 'ask-activity-spacer'));
1931
- const meter = [fmtCtx(row.usage && row.usage.ctx), fmtUsd(row.costUsd)].filter(Boolean).join(' · ');
1932
- head.appendChild(make('span', 'ask-activity-meter', meter));
1933
- }
1934
2942
  activity.appendChild(head);
1935
- const tools = (row.blocks || []).filter((b) => b && b.kind === 'tool');
1936
- for (const b of tools) activity.appendChild(toolRow(b));
1937
- const agents = (row.blocks || []).filter((b) => b && b.kind === 'agent');
1938
- if (agents.length) {
1939
- const sect = make('div', 'ask-agents');
1940
- const cap = make('div', 'ask-agents-cap');
1941
- cap.appendChild(make('span', null, 'Sub-agents'));
1942
- cap.appendChild(make('span', 'ask-agents-count', String(agents.length)));
1943
- sect.appendChild(cap);
1944
- for (const b of agents) sect.appendChild(agentRow(b));
1945
- activity.appendChild(sect);
1946
- }
1947
- return { el: activity };
1948
- }
1949
-
1950
- // The ONE orb: created on first live turn and re-parented into each rebuilt
1951
- // live row. Rebuilding it per row would restart the canvas — and since a tool
1952
- // 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.
1953
3015
  function ensureThinking() {
1954
3016
  if (el.thinking) return el.thinking;
1955
3017
  el.orb = createThinkingOrb({ doc, win, size: 28.5 });
@@ -2008,52 +3070,100 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2008
3070
  scheduleFlush();
2009
3071
  }
2010
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
+
2011
3091
  function buildMessage(row) {
2012
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
2013
3097
  let renderAnswer = null;
2014
3098
  if (row.role === 'user') {
2015
- const bubble = make('div', 'ask-user-bubble', row.text || '');
2016
- wrap.appendChild(bubble);
2017
- const atts = (row.blocks || []).filter((b) => b && b.kind === 'attachment');
2018
- if (atts.length) {
2019
- const pills = make('div', 'extras-pills ask-user-pills');
2020
- for (const b of atts) pills.appendChild(buildAttachmentPill(b));
2021
- 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
+ }
2022
3112
  }
2023
3113
  } else if (row.role === 'system') {
2024
3114
  const notices = (row.blocks || []).filter((b) => b && b.kind === 'notice');
2025
3115
  if (notices.length) for (const b of notices) wrap.appendChild(buildNotice(b));
2026
3116
  else wrap.appendChild(buildNotice({ text: row.text }));
2027
3117
  } else {
2028
- wrap.appendChild(buildActivity(row).el);
3118
+ const activity = buildActivity(row);
3119
+ wrap.appendChild(activity.el);
2029
3120
  const answer = make('div', 'ask-answer');
2030
3121
  wrap.appendChild(answer);
2031
- renderAnswer = () => renderAnswerInto(answer, row);
3122
+ renderAnswer = () => renderAnswerInto(answer, cur);
2032
3123
  renderAnswer();
2033
- for (const b of row.blocks || []) {
2034
- if (!b) continue;
2035
- if (b.kind === 'notice') wrap.appendChild(buildNotice(b));
2036
- else if (b.kind === 'card') wrap.appendChild(buildCard(b, row));
2037
- }
2038
- if (row.status === 'error') {
2039
- const explained = (row.blocks || []).some((b) => b && b.kind === 'notice');
2040
- if (row.errorMessage) wrap.appendChild(make('div', 'ask-error-line', row.errorMessage));
2041
- else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
2042
- }
2043
- if (st.model && st.model.live() && st.model.live().messageId === row.id) {
2044
- wrap.appendChild(ensureThinking()); // last child: the bottom of the message
2045
- el.elapsed = el.thinkingElapsed; // the ONE live elapsed node
2046
- // Idempotent, and the only re-arm on the adoption path: a thread whose
2047
- // ask-start the ring buffer already evicted goes live without ever
2048
- // passing through startElapsed(), and would otherwise show a dead orb.
2049
- el.orb.start();
2050
- updateThinking();
2051
- }
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(); };
2052
3156
  }
2053
3157
  const entry = {
2054
3158
  el: wrap,
2055
3159
  renderAnswer,
2056
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; }
2057
3167
  const fresh = buildMessage(row2);
2058
3168
  wrap.replaceWith(fresh.el);
2059
3169
  st.rowEls.set(row2.id, fresh);
@@ -2063,13 +3173,23 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2063
3173
  }
2064
3174
 
2065
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);
2066
3180
  st.rowEls = new Map();
2067
- el.transcript.replaceChildren();
3181
+ el.transcriptCol.replaceChildren();
2068
3182
  if (!st.model) return;
2069
3183
  for (const row of st.model.messages()) {
2070
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', '');
2071
3191
  st.rowEls.set(row.id, entry);
2072
- el.transcript.appendChild(entry.el);
3192
+ el.transcriptCol.appendChild(entry.el);
2073
3193
  }
2074
3194
  }
2075
3195
 
@@ -2089,6 +3209,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2089
3209
  let snap = null;
2090
3210
  try { snap = await res.json(); } catch { return null; }
2091
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();
2092
3216
  st.threadId = id;
2093
3217
  st.model = createThreadModel({ threadId: id });
2094
3218
  st.model.load(snap);
@@ -2105,6 +3229,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2105
3229
  stopElapsed(); // a mid-stream thread switch must not leave the old
2106
3230
  updateSendStop(); // turn's timer or stop button behind (V3/D2 reset)
2107
3231
  if (snap.inFlight) { subscribe(id); startElapsed(); }
3232
+ repaintProgressCards({ hydrate: true }); // thread load + reconnect (onHello → resync → loadThread): re-resolve + re-hydrate every card
2108
3233
  return snap;
2109
3234
  }
2110
3235
 
@@ -2206,6 +3331,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2206
3331
  function pushServerFrame(frame) {
2207
3332
  if (st.destroyed || !frame) return;
2208
3333
  if (frame.type === 'ask-history-cleared') { onHistoryCleared(); return; }
3334
+ if (THREADS_REFRESH_FRAMES.has(frame.type)) scheduleThreadsRefresh();
2209
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).
2210
3336
  if (!st.model || frame.threadId !== st.threadId) return;
2211
3337
  const r = st.model.apply(frame);
@@ -2282,6 +3408,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2282
3408
  }
2283
3409
  if (d.meters) updateMeters();
2284
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
2285
3412
  // An open popover that subscribed to this flush's dirt is rebuilt in place
2286
3413
  // (same node — never reopened, never refocused). Runs AFTER the mirror and
2287
3414
  // the meters above: the worktrees build() reads st.worktrees.
@@ -2300,7 +3427,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2300
3427
  function flush() {
2301
3428
  if (st.destroyed) return;
2302
3429
  flushExtra();
3430
+ if (st.runPoked) repaintProgressCards();
3431
+ relayoutCards();
2303
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);
2304
3438
  }
2305
3439
 
2306
3440
  function updatePinFromScroll() {
@@ -2311,7 +3445,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2311
3445
 
2312
3446
  function applyPin() {
2313
3447
  if (!st.open) return;
2314
- 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
+ }
2315
3456
  if (el.jump) el.jump.hidden = st.pinned;
2316
3457
  }
2317
3458
 
@@ -2341,10 +3482,14 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2341
3482
  closePopover({ focusTrigger: false });
2342
3483
  if (st.elapsedTimer) { clearInterval(st.elapsedTimer); st.elapsedTimer = null; }
2343
3484
  if (el.orb) el.orb.stop();
3485
+ settlePillOrb();
2344
3486
  doc.removeEventListener('keydown', onDocKeydown, true);
2345
3487
  doc.removeEventListener('pointerdown', onDocPointerdown, true);
2346
3488
  win.removeEventListener('resize', onWinResize);
2347
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
2348
3493
  root.remove();
2349
3494
  }
2350
3495