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