@worca/app 1.1.1 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +286 -65
package/ui/public/ask-panel.mjs
CHANGED
|
@@ -101,7 +101,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
101
101
|
pickerFromStore: !!(storedPick && storedPick.model),
|
|
102
102
|
effortFromStore: storedPick !== null,
|
|
103
103
|
catalog: null,
|
|
104
|
-
|
|
104
|
+
// #397: the thread's project/workspace scope. pinned:false = Auto (follow the
|
|
105
|
+
// page — today's behaviour). label caches the display name once resolved.
|
|
106
|
+
scope: { pinned: false, projectKey: null, workspaceId: null, label: null },
|
|
107
|
+
popover: null, // {panel, trigger, onClose, build, refreshOn}
|
|
105
108
|
expandedAgents: new Set(),
|
|
106
109
|
worktrees: [], // P4 §10: the chat's open worktrees (snapshot-fed)
|
|
107
110
|
pinned: true,
|
|
@@ -222,9 +225,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
222
225
|
logo.alt = '';
|
|
223
226
|
header.appendChild(logo);
|
|
224
227
|
el.title = make('div', 'ask-title', 'Ask Worca');
|
|
228
|
+
// Header is logo → title → spacer → icon buttons. The #397 scope selector
|
|
229
|
+
// used to sit here; it now lives in the composer's bottom row next to the
|
|
230
|
+
// "+" attach button (see buildComposer). A long haiku title still ellipsizes.
|
|
225
231
|
header.appendChild(el.title);
|
|
226
232
|
header.appendChild(make('span', 'ask-header-spacer'));
|
|
227
|
-
const threadsBtn = iconButton('ask-icon-btn', '
|
|
233
|
+
const threadsBtn = iconButton('ask-icon-btn', 'History', ICONS.threads, () => toggleThreadsPopover(threadsBtn));
|
|
228
234
|
threadsBtn.setAttribute('data-ask-threads-btn', '');
|
|
229
235
|
header.appendChild(threadsBtn);
|
|
230
236
|
const newBtn = iconButton('ask-icon-btn', 'New chat', ICONS.plus, () => newThread());
|
|
@@ -258,7 +264,17 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
258
264
|
return dock;
|
|
259
265
|
}
|
|
260
266
|
|
|
267
|
+
// Mirrors src/core/ask/attachment-kind.mjs + limits.mjs (#398): text kinds are
|
|
268
|
+
// UTF-8 capped at 512 KB, binary kinds (images + PDF) at 5 MB; the server
|
|
269
|
+
// re-validates everything, these are just early clear messages.
|
|
261
270
|
const ASK_ATTACH_EXT = ['.md', '.markdown', '.txt', '.json', '.csv', '.log'];
|
|
271
|
+
const ASK_ATTACH_BINARY = {
|
|
272
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
273
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf',
|
|
274
|
+
};
|
|
275
|
+
const ASK_MAX_TEXT_BYTES = 524_288;
|
|
276
|
+
const ASK_MAX_BINARY_BYTES = 5 * 1024 * 1024;
|
|
277
|
+
const ASK_MAX_THREAD_BYTES = 25 * 1024 * 1024;
|
|
262
278
|
|
|
263
279
|
function bytesToBase64(bytes) {
|
|
264
280
|
let bin = '';
|
|
@@ -276,6 +292,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
276
292
|
el.chips.hidden = !st.pendingFiles.length;
|
|
277
293
|
for (const f of st.pendingFiles) {
|
|
278
294
|
const chip = make('span', 'ask-chip');
|
|
295
|
+
if (f.attKind === 'image' && f.dataBase64) {
|
|
296
|
+
// #398: composer thumbnail straight from the bytes just read — no
|
|
297
|
+
// object-URL lifecycle to manage, the chip owns its data URI.
|
|
298
|
+
const img = doc.createElement('img');
|
|
299
|
+
img.className = 'ask-chip-thumb';
|
|
300
|
+
img.alt = f.name;
|
|
301
|
+
img.src = `data:${f.mime};base64,${f.dataBase64}`;
|
|
302
|
+
chip.appendChild(img);
|
|
303
|
+
}
|
|
279
304
|
chip.appendChild(make('span', 'ask-chip-name', f.name));
|
|
280
305
|
const x = make('button', 'ask-chip-x', '×');
|
|
281
306
|
x.type = 'button';
|
|
@@ -294,18 +319,21 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
294
319
|
const name = String(f.name || '');
|
|
295
320
|
const dot = name.lastIndexOf('.');
|
|
296
321
|
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : '';
|
|
297
|
-
|
|
298
|
-
if (
|
|
322
|
+
const binMime = ASK_ATTACH_BINARY[ext];
|
|
323
|
+
if (!ASK_ATTACH_EXT.includes(ext) && !binMime) { setComposerMsg(`attachment type not allowed: ${name}`); continue; }
|
|
324
|
+
const cap = binMime ? ASK_MAX_BINARY_BYTES : ASK_MAX_TEXT_BYTES;
|
|
325
|
+
if (f.size > cap) { setComposerMsg(`attachment over ${cap} bytes: ${name}`); continue; }
|
|
299
326
|
const others = st.pendingFiles.filter((p) => p.name !== name); // dedupe by name, newest wins
|
|
300
327
|
if (others.length >= 8) { setComposerMsg('at most 8 attachments per message'); continue; }
|
|
301
328
|
const serverBytes = st.model ? st.model.attachmentsBytes() : 0;
|
|
302
329
|
const pendingBytes = others.reduce((n, p) => n + p.bytes, 0);
|
|
303
|
-
if (serverBytes + pendingBytes + f.size >
|
|
330
|
+
if (serverBytes + pendingBytes + f.size > ASK_MAX_THREAD_BYTES) { setComposerMsg('attachment budget for this thread exceeded'); continue; }
|
|
304
331
|
let dataBase64 = '';
|
|
305
332
|
try {
|
|
306
333
|
dataBase64 = bytesToBase64(new Uint8Array(await f.arrayBuffer()));
|
|
307
334
|
} catch { setComposerMsg(`could not read ${name}`); continue; }
|
|
308
|
-
|
|
335
|
+
const attKind = binMime ? (binMime.startsWith('image/') ? 'image' : 'binary') : 'text';
|
|
336
|
+
st.pendingFiles = [...others, { name, bytes: f.size, dataBase64, attKind, mime: binMime || null }];
|
|
309
337
|
}
|
|
310
338
|
renderChips();
|
|
311
339
|
}
|
|
@@ -325,10 +353,17 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
325
353
|
const liveCtx = totals.live && totals.live.usage ? totals.live.usage.ctx : null;
|
|
326
354
|
const ctx = Number.isFinite(liveCtx) ? liveCtx : totals.ctx;
|
|
327
355
|
el.meterTokens.textContent = fmtCtx(ctx) || ((totals.turns || 0) > 0 ? '' : '0 ctx');
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
356
|
+
// Cost: the stored thread total; while a turn streams, "≈" + that total plus
|
|
357
|
+
// this turn's live figure — the CLI's once its result landed, else the
|
|
358
|
+
// display-only list-price estimate the ask-usage frame carries. ask-done
|
|
359
|
+
// nulls `live` and replaces the totals in one frame, so the authoritative
|
|
360
|
+
// figure takes over with no special case. No figure at all → empty cell,
|
|
361
|
+
// never a fabricated $0.00 (P3-F5).
|
|
362
|
+
const lv = totals.live;
|
|
363
|
+
const liveCost = lv ? (Number.isFinite(lv.costUsd) ? lv.costUsd : (Number.isFinite(lv.estimatedCostUsd) ? lv.estimatedCostUsd : null)) : null;
|
|
364
|
+
if (liveCost != null) el.meterCost.textContent = `≈${fmtUsd((Number.isFinite(totals.costUsd) ? totals.costUsd : 0) + liveCost)}`;
|
|
365
|
+
else el.meterCost.textContent = totals.costUsd == null ? '' : (fmtUsd(totals.costUsd) || '');
|
|
366
|
+
el.agentsBtnLabel.textContent = fmtAgents(totals.agents) || '0 agents'; // totals().agents already includes the live row's agents
|
|
332
367
|
}
|
|
333
368
|
|
|
334
369
|
function stopTurn() {
|
|
@@ -366,7 +401,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
366
401
|
text,
|
|
367
402
|
model: st.picker.model,
|
|
368
403
|
effort: st.picker.effort,
|
|
369
|
-
context: getPageContext() || {},
|
|
404
|
+
context: scopedContext(getPageContext() || {}),
|
|
370
405
|
...(st.pendingFiles.length ? { attachments: st.pendingFiles.map((f) => ({ name: f.name, dataBase64: f.dataBase64 })) } : {}),
|
|
371
406
|
};
|
|
372
407
|
const model = st.model;
|
|
@@ -384,15 +419,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
384
419
|
setComposerMsg(msg);
|
|
385
420
|
return;
|
|
386
421
|
}
|
|
387
|
-
const { userMessageId } = await res.json();
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
st.
|
|
394
|
-
|
|
395
|
-
|
|
422
|
+
const { userMessageId, attachments: stored } = await res.json();
|
|
423
|
+
// Prefer the server's rows: they carry the store-minted ids that key the
|
|
424
|
+
// image thumbnail (#398) and the thread's attachment ledger. The pending
|
|
425
|
+
// files are the fallback for a server that predates the field.
|
|
426
|
+
const echoAtts = Array.isArray(stored)
|
|
427
|
+
? stored.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind ?? 'text', mime: a.mime ?? null }))
|
|
428
|
+
: st.pendingFiles.map((f) => ({ name: f.name, bytes: f.bytes, attKind: f.attKind, mime: f.mime }));
|
|
429
|
+
st.model.noteLocalUserMessage({ id: userMessageId, text, attachments: echoAtts });
|
|
430
|
+
// No provisional title from the prompt: the header keeps "Ask Worca" until
|
|
431
|
+
// the ask-title frame lands (ask-model marks title dirty, flushExtra repaints).
|
|
396
432
|
el.input.value = '';
|
|
397
433
|
st.pendingFiles = [];
|
|
398
434
|
renderChips();
|
|
@@ -434,7 +470,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
434
470
|
el.fileInput = doc.createElement('input');
|
|
435
471
|
el.fileInput.type = 'file';
|
|
436
472
|
el.fileInput.multiple = true;
|
|
437
|
-
el.fileInput.accept = `${ASK_ATTACH_EXT.join(',')},text/*`;
|
|
473
|
+
el.fileInput.accept = `${ASK_ATTACH_EXT.join(',')},${Object.keys(ASK_ATTACH_BINARY).join(',')},text/*`;
|
|
438
474
|
el.fileInput.hidden = true;
|
|
439
475
|
el.fileInput.addEventListener('change', () => { addFiles(el.fileInput.files); el.fileInput.value = ''; });
|
|
440
476
|
row.appendChild(el.fileInput);
|
|
@@ -442,6 +478,22 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
442
478
|
attach.setAttribute('data-ask-attach-btn', '');
|
|
443
479
|
row.appendChild(attach);
|
|
444
480
|
|
|
481
|
+
// #397: the scope selector — which project/workspace this chat is about,
|
|
482
|
+
// independent of the page behind the sheet. It sits right after "+"
|
|
483
|
+
// (attach → scope → spacer → meter …): the pill keeps its width (style.css
|
|
484
|
+
// .ask-scope-btn flex:none), the spacer absorbs the slack. Its popover
|
|
485
|
+
// (.ask-pop-scope) opens upward from the sheet's bottom-left.
|
|
486
|
+
const scopeBtn = make('button', 'ask-scope-btn');
|
|
487
|
+
scopeBtn.type = 'button';
|
|
488
|
+
scopeBtn.setAttribute('data-ask-scope-btn', '');
|
|
489
|
+
scopeBtn.title = 'Project scope for this chat';
|
|
490
|
+
el.scopeLabel = make('span', 'ask-scope-label', 'Auto');
|
|
491
|
+
scopeBtn.appendChild(el.scopeLabel);
|
|
492
|
+
scopeBtn.appendChild(svgIcon(ICONS.chevronDown, 11, 2));
|
|
493
|
+
scopeBtn.addEventListener('click', () => openScopePopover(scopeBtn));
|
|
494
|
+
el.scopeBtn = scopeBtn;
|
|
495
|
+
row.appendChild(scopeBtn);
|
|
496
|
+
|
|
445
497
|
row.appendChild(make('span', 'ask-composer-spacer'));
|
|
446
498
|
|
|
447
499
|
const meter = make('span', 'ask-meter');
|
|
@@ -634,7 +686,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
634
686
|
else if ((e.key === 'Enter' || e.key === ' ') && idx >= 0) { e.preventDefault(); items[idx].click(); }
|
|
635
687
|
}
|
|
636
688
|
|
|
637
|
-
function openPopover({ panelClass, trigger, build, onClose }) {
|
|
689
|
+
function openPopover({ panelClass, trigger, build, onClose, refreshOn }) {
|
|
638
690
|
if (st.popover && st.popover.trigger === trigger) { closePopover({ focusTrigger: false }); return null; }
|
|
639
691
|
closePopover({ focusTrigger: false });
|
|
640
692
|
const panel = make('div', `ask-pop ${panelClass}`);
|
|
@@ -642,7 +694,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
642
694
|
panel.addEventListener('keydown', onPopKeydown);
|
|
643
695
|
build(panel);
|
|
644
696
|
el.sheet.appendChild(panel);
|
|
645
|
-
|
|
697
|
+
// refreshOn(dirty) → true re-runs build() on that flush (flushExtra), so an
|
|
698
|
+
// OPEN popover follows the live meters / worktrees instead of freezing at open.
|
|
699
|
+
st.popover = { panel, trigger, onClose: onClose || null, build, refreshOn: refreshOn || null };
|
|
646
700
|
const first = menuItems(panel)[0];
|
|
647
701
|
if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
|
|
648
702
|
return panel;
|
|
@@ -672,16 +726,38 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
672
726
|
return meter;
|
|
673
727
|
}
|
|
674
728
|
|
|
729
|
+
/** History meter: "12 chats" / "1 chat" / '' at 0 (same empty-string convention as the agents meter). */
|
|
730
|
+
function fmtChats(n) {
|
|
731
|
+
return Number.isFinite(n) && n > 0 ? `${n} chat${n === 1 ? '' : 's'}` : '';
|
|
732
|
+
}
|
|
733
|
+
|
|
675
734
|
function toggleThreadsPopover(trigger) {
|
|
676
|
-
|
|
735
|
+
let meter = null;
|
|
736
|
+
const panel = openPopover({
|
|
737
|
+
panelClass: 'ask-pop-threads',
|
|
738
|
+
trigger,
|
|
739
|
+
build: (p) => {
|
|
740
|
+
// Same caption-row pattern as the agents popover: the row paints at once,
|
|
741
|
+
// the meter fills once the list lands (the popover opens synchronously).
|
|
742
|
+
const head = make('div', 'ask-pop-caption-row');
|
|
743
|
+
head.appendChild(make('span', 'ask-pop-caption', 'History'));
|
|
744
|
+
meter = make('span', 'ask-pop-caption-meter', '');
|
|
745
|
+
head.appendChild(meter);
|
|
746
|
+
p.appendChild(head);
|
|
747
|
+
},
|
|
748
|
+
});
|
|
677
749
|
if (!panel) return;
|
|
678
750
|
Promise.resolve()
|
|
679
751
|
.then(() => fetch('/api/ask/threads?limit=50'))
|
|
680
752
|
.then((r) => (r && r.ok ? r.json() : { threads: [] }))
|
|
681
753
|
.catch(() => ({ threads: [] }))
|
|
682
|
-
.then(({ threads }) => {
|
|
754
|
+
.then(({ threads, total }) => {
|
|
683
755
|
if (st.popover === null || st.popover.panel !== panel) return; // closed meanwhile
|
|
684
|
-
|
|
756
|
+
const rows = Array.isArray(threads) ? threads : [];
|
|
757
|
+
// `total` is EVERY saved chat (the route caps rows at limit); an older
|
|
758
|
+
// server without it degrades to the page size.
|
|
759
|
+
if (meter) meter.textContent = fmtChats(Number.isInteger(total) && total >= 0 ? total : rows.length);
|
|
760
|
+
renderThreadRows(panel, rows);
|
|
685
761
|
});
|
|
686
762
|
}
|
|
687
763
|
|
|
@@ -703,7 +779,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
703
779
|
// starts at the left edge. The date rides the meter line under the title.
|
|
704
780
|
pick.appendChild(make('span', `ask-dot ask-thread-dot${t.inFlight ? ' ask-dot-live' : ''}`));
|
|
705
781
|
const col = make('span', 'ask-thread-col');
|
|
706
|
-
|
|
782
|
+
// A null title = the haiku title has not landed yet (the message route
|
|
783
|
+
// stamps nothing); "New chat" is the same label the turn falls back to.
|
|
784
|
+
col.appendChild(make('span', 'ask-thread-title', t.title || 'New chat'));
|
|
707
785
|
col.appendChild(threadMeter(t));
|
|
708
786
|
pick.appendChild(col);
|
|
709
787
|
row.appendChild(pick);
|
|
@@ -908,6 +986,121 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
908
986
|
renderPane('main');
|
|
909
987
|
}
|
|
910
988
|
|
|
989
|
+
// ---- scope selector (#397) ------------------------------------------------
|
|
990
|
+
/** Per-field merge: the pinned scope replaces the page context's TARGET keys;
|
|
991
|
+
* view/run/diff-file context still follow the page. Auto sends pinned:false so
|
|
992
|
+
* the server never resurrects a stale thread pin over an explicit choice. */
|
|
993
|
+
function scopedContext(page) {
|
|
994
|
+
const ctx = { ...page };
|
|
995
|
+
if (!st.scope.pinned) return { ...ctx, pinned: false };
|
|
996
|
+
delete ctx.projectDir;
|
|
997
|
+
delete ctx.projectKey;
|
|
998
|
+
delete ctx.workspaceId;
|
|
999
|
+
ctx.pinned = true;
|
|
1000
|
+
if (st.scope.projectKey) ctx.projectKey = st.scope.projectKey;
|
|
1001
|
+
else if (st.scope.workspaceId) ctx.workspaceId = st.scope.workspaceId;
|
|
1002
|
+
return ctx;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function updateScopeButton() {
|
|
1006
|
+
if (!el.scopeLabel) return;
|
|
1007
|
+
el.scopeLabel.textContent = st.scope.pinned
|
|
1008
|
+
? (st.scope.label || st.scope.projectKey || st.scope.workspaceId || 'Pinned')
|
|
1009
|
+
: 'Auto';
|
|
1010
|
+
el.scopeBtn.classList.toggle('is-pinned', st.scope.pinned);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function setScope(next) {
|
|
1014
|
+
st.scope = {
|
|
1015
|
+
pinned: !!next.pinned,
|
|
1016
|
+
projectKey: next.projectKey || null,
|
|
1017
|
+
workspaceId: next.workspaceId || null,
|
|
1018
|
+
label: next.label || null,
|
|
1019
|
+
};
|
|
1020
|
+
updateScopeButton();
|
|
1021
|
+
closePopover({ focusTrigger: false });
|
|
1022
|
+
focusComposer();
|
|
1023
|
+
// Persist on the thread so the pin survives reload with no message sent. A
|
|
1024
|
+
// brand-new chat has no row yet — the first message's context (pinned:true)
|
|
1025
|
+
// persists it then instead.
|
|
1026
|
+
if (!st.threadId) return;
|
|
1027
|
+
const scope = st.scope.pinned
|
|
1028
|
+
? (st.scope.projectKey ? { pinned: true, projectKey: st.scope.projectKey } : { pinned: true, workspaceId: st.scope.workspaceId })
|
|
1029
|
+
: { pinned: false };
|
|
1030
|
+
Promise.resolve()
|
|
1031
|
+
.then(() => fetch(`/api/ask/threads/${st.threadId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }) }))
|
|
1032
|
+
.catch(() => { /* the next message carries the scope in its context anyway */ });
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Restore the selector from a stored thread context (loadThread / reopen). */
|
|
1036
|
+
function applyThreadScope(context) {
|
|
1037
|
+
const c = context && typeof context === 'object' ? context : null;
|
|
1038
|
+
const key = c && c.pinned === true && typeof c.projectKey === 'string' && c.projectKey ? c.projectKey : null;
|
|
1039
|
+
const ws = c && c.pinned === true && typeof c.workspaceId === 'string' && c.workspaceId ? c.workspaceId : null;
|
|
1040
|
+
st.scope = key
|
|
1041
|
+
? { pinned: true, projectKey: key, workspaceId: null, label: null }
|
|
1042
|
+
: ws
|
|
1043
|
+
? { pinned: true, projectKey: null, workspaceId: ws, label: null }
|
|
1044
|
+
: { pinned: false, projectKey: null, workspaceId: null, label: null };
|
|
1045
|
+
updateScopeButton(); // the raw key shows until the name resolves
|
|
1046
|
+
if (st.scope.pinned) resolveScopeLabel();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function resolveScopeLabel() {
|
|
1050
|
+
const want = { projectKey: st.scope.projectKey, workspaceId: st.scope.workspaceId };
|
|
1051
|
+
loadCardOptions().then((opts) => {
|
|
1052
|
+
if (st.destroyed || !st.scope.pinned) return;
|
|
1053
|
+
if (st.scope.projectKey !== want.projectKey || st.scope.workspaceId !== want.workspaceId) return;
|
|
1054
|
+
const p = want.projectKey ? opts.projects.find((x) => x && x.key === want.projectKey) : null;
|
|
1055
|
+
const w = want.workspaceId ? opts.workspaces.find((x) => x && x.id === want.workspaceId) : null;
|
|
1056
|
+
st.scope.label = (p && p.name) || (w && (w.name || w.id)) || null;
|
|
1057
|
+
updateScopeButton();
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function openScopePopover(trigger) {
|
|
1062
|
+
const panel = openPopover({ panelClass: 'ask-pop-scope', trigger, build: (p) => {
|
|
1063
|
+
p.appendChild(make('div', 'ask-pop-caption', 'Chat scope'));
|
|
1064
|
+
} });
|
|
1065
|
+
if (!panel) return;
|
|
1066
|
+
loadCardOptions().then((opts) => {
|
|
1067
|
+
if (!st.popover || st.popover.panel !== panel) return;
|
|
1068
|
+
const item = (label, on, onPick) => {
|
|
1069
|
+
const it = menuItem('ask-scope-item', onPick);
|
|
1070
|
+
it.appendChild(make('span', 'ask-model-name', label));
|
|
1071
|
+
if (on) it.appendChild(make('span', 'ask-model-check', '✓'));
|
|
1072
|
+
return it;
|
|
1073
|
+
};
|
|
1074
|
+
panel.appendChild(item('Auto (follow current page)', !st.scope.pinned, () => setScope({ pinned: false })));
|
|
1075
|
+
const projects = opts.projects.filter((p) => p && p.key);
|
|
1076
|
+
if (projects.length) {
|
|
1077
|
+
panel.appendChild(make('div', 'ask-pop-divider'));
|
|
1078
|
+
panel.appendChild(make('div', 'ask-pop-caption', 'Projects'));
|
|
1079
|
+
for (const p of projects) {
|
|
1080
|
+
panel.appendChild(item(
|
|
1081
|
+
p.exists === false ? `${p.name} (missing)` : p.name,
|
|
1082
|
+
st.scope.pinned && st.scope.projectKey === p.key,
|
|
1083
|
+
() => setScope({ pinned: true, projectKey: p.key, label: p.name }),
|
|
1084
|
+
));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const workspaces = opts.workspaces.filter((w) => w && w.id);
|
|
1088
|
+
if (workspaces.length) {
|
|
1089
|
+
panel.appendChild(make('div', 'ask-pop-divider'));
|
|
1090
|
+
panel.appendChild(make('div', 'ask-pop-caption', 'Workspaces'));
|
|
1091
|
+
for (const w of workspaces) {
|
|
1092
|
+
panel.appendChild(item(
|
|
1093
|
+
w.name || w.id,
|
|
1094
|
+
st.scope.pinned && st.scope.workspaceId === w.id,
|
|
1095
|
+
() => setScope({ pinned: true, workspaceId: w.id, label: w.name || w.id }),
|
|
1096
|
+
));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const first = menuItems(panel)[0];
|
|
1100
|
+
if (first) { first.tabIndex = 0; try { first.focus(); } catch { /* ignore */ } }
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
911
1104
|
// ---- run-info popover ("Agents this chat") --------------------------------
|
|
912
1105
|
// ---- worktrees (P4 §10) ---------------------------------------------------
|
|
913
1106
|
function setWorktrees(list) {
|
|
@@ -926,7 +1119,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
926
1119
|
.catch(() => null)
|
|
927
1120
|
.then((snap) => {
|
|
928
1121
|
if (st.threadId !== tid) return st.worktrees;
|
|
929
|
-
|
|
1122
|
+
const list = snap && Array.isArray(snap.worktrees) ? snap.worktrees : [];
|
|
1123
|
+
// The model owns the list (ask-worktrees frames land there too) and the
|
|
1124
|
+
// flush repaints an open popover; the count is ALSO written synchronously
|
|
1125
|
+
// — deleteWorktree and the tests read it right after the awaited refetch.
|
|
1126
|
+
if (st.model) { st.model.setWorktrees(list); scheduleFlush(); }
|
|
1127
|
+
setWorktrees(list);
|
|
930
1128
|
return st.worktrees;
|
|
931
1129
|
});
|
|
932
1130
|
}
|
|
@@ -946,39 +1144,47 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
946
1144
|
}
|
|
947
1145
|
|
|
948
1146
|
function openWorktreesPopover(trigger) {
|
|
949
|
-
const panel = openPopover({ panelClass: 'ask-pop-runinfo ask-pop-worktrees', trigger, build: (p) => {
|
|
1147
|
+
const panel = openPopover({ panelClass: 'ask-pop-runinfo ask-pop-worktrees', trigger, refreshOn: (d) => d.worktrees, build: (p) => {
|
|
950
1148
|
p.appendChild(make('div', 'ask-pop-caption', 'Worktrees this chat'));
|
|
1149
|
+
// Synchronous: st.worktrees is the DOM mirror, already fed by the snapshot
|
|
1150
|
+
// or the last frame, and flushExtra refreshes it BEFORE re-running build().
|
|
1151
|
+
renderWorktreeRows(p, st.worktrees);
|
|
951
1152
|
} });
|
|
952
1153
|
if (!panel) return;
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1154
|
+
// Heal on open (one snapshot GET): the list lands in the model and the
|
|
1155
|
+
// dirty.worktrees flush re-runs build() above — one render path.
|
|
1156
|
+
refreshWorktrees();
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function renderWorktreeRows(panel, list) {
|
|
1160
|
+
if (!list.length) { panel.appendChild(make('div', 'ask-pop-empty', 'No worktrees open.')); return; }
|
|
1161
|
+
for (const w of list) {
|
|
1162
|
+
const row = make('div', 'ask-runinfo-row ask-wt-row');
|
|
1163
|
+
const col = make('span', 'ask-runinfo-col');
|
|
1164
|
+
col.appendChild(make('span', 'ask-runinfo-name', `${w.projectKey} · ${w.ref}@${wtShortSha(w.commit)}`));
|
|
1165
|
+
const path = make('span', 'ask-runinfo-sub ask-wt-path', w.path);
|
|
1166
|
+
path.title = 'Click to copy';
|
|
1167
|
+
path.addEventListener('click', () => { try { win.navigator.clipboard.writeText(w.path); } catch { /* unsupported */ } });
|
|
1168
|
+
col.appendChild(path);
|
|
1169
|
+
row.appendChild(col);
|
|
1170
|
+
// AGE (spec §10 row: project · ref@sha7 · AGE · path · trash). Reuses the
|
|
1171
|
+
// run-info popover's `.ask-runinfo-elapsed` cell — its `margin-left:auto`
|
|
1172
|
+
// also right-aligns the trash that follows.
|
|
1173
|
+
row.appendChild(make('span', 'ask-runinfo-elapsed', w.createdAt ? fmtElapsed(now() - Date.parse(w.createdAt)) : '—'));
|
|
1174
|
+
const trash = make('button', 'ask-thread-trash');
|
|
1175
|
+
trash.type = 'button';
|
|
1176
|
+
trash.setAttribute('aria-label', `Remove worktree ${w.worktreeId}`);
|
|
1177
|
+
trash.appendChild(svgIcon('M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2h9.2L17.5 7', 14, 1.8));
|
|
1178
|
+
trash.addEventListener('click', (e) => { e.stopPropagation(); closePopover({ focusTrigger: false }); deleteWorktree(w); });
|
|
1179
|
+
row.appendChild(trash);
|
|
1180
|
+
panel.appendChild(row);
|
|
1181
|
+
}
|
|
978
1182
|
}
|
|
979
1183
|
|
|
980
1184
|
function openRunInfoPopover(trigger) {
|
|
981
|
-
|
|
1185
|
+
// Rebuilt on every meters flush: agent blocks mark meters dirty (ask-model),
|
|
1186
|
+
// so rows, dots, ctx and cost move while agents run.
|
|
1187
|
+
openPopover({ panelClass: 'ask-pop-runinfo', trigger, refreshOn: (d) => d.meters, build: (p) => {
|
|
982
1188
|
const agents = [];
|
|
983
1189
|
if (st.model) {
|
|
984
1190
|
for (const row of st.model.messages()) {
|
|
@@ -1014,6 +1220,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1014
1220
|
stopElapsed();
|
|
1015
1221
|
storeThread(null);
|
|
1016
1222
|
el.title.textContent = 'Ask Worca';
|
|
1223
|
+
applyThreadScope(null); // #397: a brand-new chat starts on Auto
|
|
1017
1224
|
renderTranscript();
|
|
1018
1225
|
updateMeters();
|
|
1019
1226
|
setWorktrees([]);
|
|
@@ -1026,7 +1233,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1026
1233
|
closePopover({ focusTrigger: false });
|
|
1027
1234
|
const ok = await confirm({
|
|
1028
1235
|
title: 'Delete this chat?',
|
|
1029
|
-
message: `“${t.title || '
|
|
1236
|
+
message: `“${t.title || 'New chat'}” and its transcript are removed${t.worktrees ? ` along with ${t.worktrees} worktree${t.worktrees === 1 ? '' : 's'}` : ''}. This cannot be undone.`,
|
|
1030
1237
|
confirmLabel: 'Delete',
|
|
1031
1238
|
danger: true,
|
|
1032
1239
|
});
|
|
@@ -1040,7 +1247,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1040
1247
|
function buildThreadTrash(t) {
|
|
1041
1248
|
const b = make('button', 'ask-thread-trash');
|
|
1042
1249
|
b.type = 'button';
|
|
1043
|
-
b.setAttribute('aria-label', `Delete "${t.title || '
|
|
1250
|
+
b.setAttribute('aria-label', `Delete "${t.title || 'New chat'}"`);
|
|
1044
1251
|
b.appendChild(svgIcon('M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2h9.2L17.5 7', 14, 1.8));
|
|
1045
1252
|
b.addEventListener('click', (e) => { e.stopPropagation(); deleteThread(t); });
|
|
1046
1253
|
return b;
|
|
@@ -1049,6 +1256,23 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1049
1256
|
// ---- stubs the later tasks replace wholesale ------------------------------
|
|
1050
1257
|
// ---- transcript (spec §10.5) ---------------------------------------------
|
|
1051
1258
|
function buildAttachmentPill(b) {
|
|
1259
|
+
// #398: an image attachment renders as a thumbnail served by the download
|
|
1260
|
+
// route (sniff-verified mime, inline disposition); everything else keeps the
|
|
1261
|
+
// name pill. The id comes from the 202 body or the ask-message broadcast; an
|
|
1262
|
+
// echo without one (older server) pills until the snapshot.
|
|
1263
|
+
if (b.attKind === 'image' && b.id && st.threadId) {
|
|
1264
|
+
const link = make('a', 'ask-attachment-thumb-link');
|
|
1265
|
+
link.href = `/api/ask/threads/${st.threadId}/attachments/${b.id}`;
|
|
1266
|
+
link.target = '_blank';
|
|
1267
|
+
link.rel = 'noopener';
|
|
1268
|
+
const img = doc.createElement('img');
|
|
1269
|
+
img.className = 'ask-attachment-thumb';
|
|
1270
|
+
img.alt = b.name || '(image)';
|
|
1271
|
+
img.loading = 'lazy';
|
|
1272
|
+
img.src = link.href;
|
|
1273
|
+
link.appendChild(img);
|
|
1274
|
+
return link;
|
|
1275
|
+
}
|
|
1052
1276
|
const pill = make('span', 'extra-pill ask-attachment-pill');
|
|
1053
1277
|
pill.appendChild(make('span', 'extra-pill-name', b.name || '(attachment)'));
|
|
1054
1278
|
return pill;
|
|
@@ -1146,6 +1370,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1146
1370
|
|
|
1147
1371
|
rootEl.appendChild(make('div', 'ask-card-title', card.title || 'Run proposal'));
|
|
1148
1372
|
|
|
1373
|
+
// #397 guardrail: the model proposed a different target than the chat's pin.
|
|
1374
|
+
if (block.scopeMismatch) {
|
|
1375
|
+
rootEl.appendChild(make('div', 'ask-card-scope-warn',
|
|
1376
|
+
'This proposal targets a different project or workspace than the one pinned for this chat — check the target before starting.'));
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1149
1379
|
const seg = make('div', 'ask-card-seg');
|
|
1150
1380
|
const segBtns = {};
|
|
1151
1381
|
for (const [t, label] of [['project', 'Project'], ['workspace', 'Workspace']]) {
|
|
@@ -1640,12 +1870,13 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1640
1870
|
st.model = createThreadModel({ threadId: id });
|
|
1641
1871
|
st.model.load(snap);
|
|
1642
1872
|
el.title.textContent = (snap.thread && snap.thread.title) || 'Ask Worca';
|
|
1873
|
+
applyThreadScope(snap.thread && snap.thread.context); // #397: restore the pin
|
|
1643
1874
|
renderTranscript();
|
|
1644
1875
|
updateMeters();
|
|
1645
1876
|
// P4: the count rides the snapshot loadThread ALREADY fetched — no extra GET.
|
|
1646
|
-
// It belongs here, not in
|
|
1647
|
-
//
|
|
1648
|
-
setWorktrees(
|
|
1877
|
+
// The model owns the list (load() seeded it). It belongs here, not in
|
|
1878
|
+
// switchThread: resync()/onHello() come through loadThread too.
|
|
1879
|
+
setWorktrees(st.model.worktrees());
|
|
1649
1880
|
st.pinned = true;
|
|
1650
1881
|
scheduleFlush();
|
|
1651
1882
|
stopElapsed(); // a mid-stream thread switch must not leave the old
|
|
@@ -1722,10 +1953,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1722
1953
|
|
|
1723
1954
|
function afterFrame(frame) {
|
|
1724
1955
|
if (frame.type === 'ask-start') { startElapsed(Date.parse(frame.startedAt)); updateSendStop(); }
|
|
1725
|
-
else if (frame.type !== 'ask-done' && frame.type !== 'ask-error' && st.model && st.model.live() && el.send && !el.send.hidden) {
|
|
1726
|
-
// A frame ADOPTED mid-turn (no ask-start seen — the ring buffer evicted
|
|
1727
|
-
// a broadcast delta beat the subscribe replay): the turn is live now,
|
|
1728
|
-
// composer must show Stop and the timer must run (review of PR #376).
|
|
1956
|
+
else if (typeof frame.seq === 'number' && frame.type !== 'ask-done' && frame.type !== 'ask-error' && st.model && st.model.live() && el.send && !el.send.hidden) {
|
|
1957
|
+
// A JOB frame ADOPTED mid-turn (no ask-start seen — the ring buffer evicted
|
|
1958
|
+
// it, or a broadcast delta beat the subscribe replay): the turn is live now,
|
|
1959
|
+
// so the composer must show Stop and the timer must run (review of PR #376).
|
|
1960
|
+
// Out-of-turn frames (ask-title — early now — ask-worktrees, ask-message)
|
|
1961
|
+
// never adopt: startElapsed() here would reset the running clock.
|
|
1729
1962
|
startElapsed(); updateSendStop();
|
|
1730
1963
|
}
|
|
1731
1964
|
if (frame.type === 'ask-done' || frame.type === 'ask-error') {
|
|
@@ -1739,9 +1972,19 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1739
1972
|
&& /is waiting for your answer/.test(frame.message.text)) announce('run needs an answer');
|
|
1740
1973
|
}
|
|
1741
1974
|
|
|
1975
|
+
// Settings → "Delete all chat history" broadcast (seq-less, threadId-less): every
|
|
1976
|
+
// row is gone server-side, so a tab still holding st.threadId would keep a dead
|
|
1977
|
+
// chat in memory until its next fetch 404s. Reset exactly like the "+" button.
|
|
1978
|
+
function onHistoryCleared() {
|
|
1979
|
+
closePopover({ focusTrigger: false });
|
|
1980
|
+
if (st.threadId) newThread();
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1742
1983
|
function pushServerFrame(frame) {
|
|
1984
|
+
if (st.destroyed || !frame) return;
|
|
1985
|
+
if (frame.type === 'ask-history-cleared') { onHistoryCleared(); return; }
|
|
1743
1986
|
// Defence-in-depth: the model's own threadId filter is the real router — this early return only saves an apply() call and cannot be observed from tests (the model would drop the frame identically).
|
|
1744
|
-
if (
|
|
1987
|
+
if (!st.model || frame.threadId !== st.threadId) return;
|
|
1745
1988
|
const r = st.model.apply(frame);
|
|
1746
1989
|
if (r && r.gap) { resync(); return; }
|
|
1747
1990
|
if (!r || !r.ok) return;
|
|
@@ -1815,6 +2058,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1815
2058
|
for (const id of d.answer) renderAnswerFor(id);
|
|
1816
2059
|
}
|
|
1817
2060
|
if (d.meters) updateMeters();
|
|
2061
|
+
if (d.worktrees) setWorktrees(st.model.worktrees());
|
|
2062
|
+
// An open popover that subscribed to this flush's dirt is rebuilt in place
|
|
2063
|
+
// (same node — never reopened, never refocused). Runs AFTER the mirror and
|
|
2064
|
+
// the meters above: the worktrees build() reads st.worktrees.
|
|
2065
|
+
const pop = st.popover;
|
|
2066
|
+
if (pop && typeof pop.refreshOn === 'function' && pop.refreshOn(d)) { pop.panel.replaceChildren(); pop.build(pop.panel); }
|
|
1818
2067
|
updateLiveElapsed();
|
|
1819
2068
|
}
|
|
1820
2069
|
|
package/ui/public/index.html
CHANGED
|
@@ -1131,6 +1131,48 @@
|
|
|
1131
1131
|
<button type="button" id="askLimitsSave" class="btn btn-primary btn-mini">Save</button>
|
|
1132
1132
|
</div>
|
|
1133
1133
|
<small class="hint" id="askLimitsMsg"></small>
|
|
1134
|
+
<!-- Chat history: the counts paint with the view; the button opens a
|
|
1135
|
+
danger confirm quoting FRESH counts, then DELETE /api/ask/threads. -->
|
|
1136
|
+
<div class="field" style="margin-top:14px">
|
|
1137
|
+
<div class="label-row">
|
|
1138
|
+
<label for="askHistoryDelete">Chat history</label>
|
|
1139
|
+
<button type="button" class="info-tip" aria-label="About Chat history">i<span class="tip-content hidden">
|
|
1140
|
+
Every saved Ask Worca chat: transcripts, attachments and the git worktrees checked out for them. Runs started from a chat are not affected.
|
|
1141
|
+
</span></button>
|
|
1142
|
+
</div>
|
|
1143
|
+
<button type="button" id="askHistoryDelete" class="ask-history-delete" disabled>Delete all chat history</button>
|
|
1144
|
+
<small class="hint" id="askHistoryCounts"></small>
|
|
1145
|
+
<small class="hint" id="askHistoryMsg"></small>
|
|
1146
|
+
</div>
|
|
1147
|
+
</section>
|
|
1148
|
+
|
|
1149
|
+
<!-- Spawn-debug diagnostics: the stored side of WORCA_DEBUG_SPAWN.
|
|
1150
|
+
claude-runner.mjs reads the setting per spawn (UI server AND CLI);
|
|
1151
|
+
a non-empty env var overrides it. This card only flips the gate —
|
|
1152
|
+
the runner decides what is printable (routing keys readable,
|
|
1153
|
+
credentials as "<set, N chars>"). -->
|
|
1154
|
+
<section class="card settings-card" id="debug-spawn-settings-card">
|
|
1155
|
+
<div class="card-head">
|
|
1156
|
+
<div class="label-row">
|
|
1157
|
+
<h2>Spawn diagnostics</h2>
|
|
1158
|
+
<button type="button" class="info-tip" aria-label="About spawn diagnostics">i<span class="tip-content hidden">
|
|
1159
|
+
Logs the exact claude binary, argv and routing env handed to every spawn, in the run
|
|
1160
|
+
log. Credentials never appear: routing keys (endpoint, wire model) print readable,
|
|
1161
|
+
every other value as <code><set, N chars></code>. Applies to runs started here
|
|
1162
|
+
and from the CLI, with no restart. A non-empty <code>WORCA_DEBUG_SPAWN</code> in the
|
|
1163
|
+
environment overrides this setting.
|
|
1164
|
+
</span></button>
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
<div class="field">
|
|
1168
|
+
<label class="check-row" for="debugSpawnEnabled"><input id="debugSpawnEnabled" type="checkbox" /> Log claude spawn details (binary, argv, routing env)</label>
|
|
1169
|
+
</div>
|
|
1170
|
+
<small class="hint" id="debugSpawnEnvNote"></small>
|
|
1171
|
+
<div class="add-project-actions" style="margin-top:14px">
|
|
1172
|
+
<button type="button" id="debugSpawnReset" class="btn btn-ghost btn-mini">Reset to default</button>
|
|
1173
|
+
<button type="button" id="debugSpawnSave" class="btn btn-primary btn-mini">Save</button>
|
|
1174
|
+
</div>
|
|
1175
|
+
<small class="hint" id="debugSpawnMsg"></small>
|
|
1134
1176
|
</section>
|
|
1135
1177
|
|
|
1136
1178
|
<!-- Chat notifications (chat-connectivity-design.md §4.8): which run
|