@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.
- package/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/ui/public/ask-panel.mjs
CHANGED
|
@@ -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(
|
|
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:
|
|
99
|
-
minW:
|
|
104
|
+
defaultW: 821, defaultH: 669,
|
|
105
|
+
minW: 821, minH: 669,
|
|
100
106
|
dockPadX: 28, dockPadBottom: 26, topGap: 20,
|
|
101
107
|
});
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 =
|
|
268
|
-
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
|
-
|
|
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
|
-
|
|
622
|
+
box.appendChild(el.input);
|
|
508
623
|
|
|
509
624
|
el.composerMsg = make('div', 'ask-composer-msg');
|
|
510
625
|
el.composerMsg.hidden = true;
|
|
511
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
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
|
-
|
|
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
|
|
1592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2284
|
+
paintSeg();
|
|
1612
2285
|
renderTarget();
|
|
2286
|
+
reloadLane();
|
|
1613
2287
|
});
|
|
1614
2288
|
segBtns[t] = b;
|
|
1615
2289
|
seg.appendChild(b);
|
|
1616
2290
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
-
|
|
1635
|
-
|
|
1636
|
-
rootEl.appendChild(
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1657
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1673
|
-
rootEl.appendChild(
|
|
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 =
|
|
1680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
return
|
|
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
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
wrap.appendChild(
|
|
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
|
-
|
|
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,
|
|
3122
|
+
renderAnswer = () => renderAnswerInto(answer, cur);
|
|
2032
3123
|
renderAnswer();
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
const
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
|