@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.
@@ -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
- popover: null, // {panel, trigger, onClose}
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', 'Recent chats', ICONS.threads, () => toggleThreadsPopover(threadsBtn));
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
- if (!ASK_ATTACH_EXT.includes(ext)) { setComposerMsg(`attachment type not allowed: ${name}`); continue; }
298
- if (f.size > 524_288) { setComposerMsg(`attachment over 524288 bytes: ${name}`); continue; }
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 > 4 * 1024 * 1024) { setComposerMsg('attachment budget for this thread exceeded'); continue; }
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
- st.pendingFiles = [...others, { name, bytes: f.size, dataBase64 }];
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
- // cost comes from thread totals only never from a live null (P3-F5)
329
- // No cost yet empty cell, never a fabricated $0.00 (P3-F5).
330
- el.meterCost.textContent = totals.costUsd == null ? '' : (fmtUsd(totals.costUsd) || '');
331
- el.agentsBtnLabel.textContent = fmtAgents(totals.agents) || '0 agents';
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
- st.model.noteLocalUserMessage({ id: userMessageId, text, attachments: st.pendingFiles.map((f) => ({ name: f.name, bytes: f.bytes })) });
389
- if (!st.model.thread().title) {
390
- // The deterministic first title has NO frame record it in the MODEL
391
- // as well as the header: model.load() left `title` dirty and the very
392
- // next flushExtra() repaints el.title from thread().title.
393
- st.model.thread().title = text.split('\n')[0].slice(0, 80);
394
- el.title.textContent = st.model.thread().title;
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
- st.popover = { panel, trigger, onClose: onClose || null };
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
- const panel = openPopover({ panelClass: 'ask-pop-threads', trigger, build: (p) => { p.appendChild(make('div', 'ask-pop-caption', 'Recent chats')); } });
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
- renderThreadRows(panel, Array.isArray(threads) ? threads : []);
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
- col.appendChild(make('span', 'ask-thread-title', t.title || '(untitled)'));
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
- setWorktrees(snap && Array.isArray(snap.worktrees) ? snap.worktrees : []);
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
- refreshWorktrees().then((list) => {
954
- if (!st.popover || st.popover.panel !== panel) return;
955
- if (!list.length) { panel.appendChild(make('div', 'ask-pop-empty', 'No worktrees open.')); return; }
956
- for (const w of list) {
957
- const row = make('div', 'ask-runinfo-row ask-wt-row');
958
- const col = make('span', 'ask-runinfo-col');
959
- col.appendChild(make('span', 'ask-runinfo-name', `${w.projectKey} · ${w.ref}@${wtShortSha(w.commit)}`));
960
- const path = make('span', 'ask-runinfo-sub ask-wt-path', w.path);
961
- path.title = 'Click to copy';
962
- path.addEventListener('click', () => { try { win.navigator.clipboard.writeText(w.path); } catch { /* unsupported */ } });
963
- col.appendChild(path);
964
- row.appendChild(col);
965
- // AGE (spec §10 row: project · ref@sha7 · AGE · path · trash). Reuses the
966
- // run-info popover's `.ask-runinfo-elapsed` cell its `margin-left:auto`
967
- // also right-aligns the trash that follows.
968
- row.appendChild(make('span', 'ask-runinfo-elapsed', w.createdAt ? fmtElapsed(now() - Date.parse(w.createdAt)) : '—'));
969
- const trash = make('button', 'ask-thread-trash');
970
- trash.type = 'button';
971
- trash.setAttribute('aria-label', `Remove worktree ${w.worktreeId}`);
972
- trash.appendChild(svgIcon('M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2h9.2L17.5 7', 14, 1.8));
973
- trash.addEventListener('click', (e) => { e.stopPropagation(); closePopover({ focusTrigger: false }); deleteWorktree(w); });
974
- row.appendChild(trash);
975
- panel.appendChild(row);
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
- openPopover({ panelClass: 'ask-pop-runinfo', trigger, build: (p) => {
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 || '(untitled)'}” and its transcript are removed${t.worktrees ? ` along with ${t.worktrees} worktree${t.worktrees === 1 ? '' : 's'}` : ''}. This cannot be undone.`,
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 || '(untitled)'}"`);
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 switchThread: resync()/onHello() come through
1647
- // loadThread too, and a hook on switchThread would miss both.
1648
- setWorktrees(snap.worktrees);
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 it, or
1727
- // a broadcast delta beat the subscribe replay): the turn is live now, so the
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 (st.destroyed || !frame || !st.model || frame.threadId !== st.threadId) return;
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
 
@@ -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>&lt;set, N chars&gt;</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