agentgui 1.0.1041 → 1.0.1043

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.
@@ -1 +1 @@
1
- {"digest":"fb6606ab31d8a99d:n=50","total":50}
1
+ {"digest":"fc5b1d9e8eb6b62a:n=7","total":7}
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-231ff5de003f20f0-586
3
+ ns: default
4
+ created: 1783842773069
5
+ updated: 1783842773069
6
+ ---
7
+
8
+ {"claim":"All grants for opened docs + derived/created docs are pre-accepted","scope":"feedback","why":"Tiffany hit SCOPE_NEEDED on sheets_read because URL-heuristic grant fired for converted /spreadsheets/d/ URL but her OAuth lacked sheets scope. User rule: opening a doc implies all access needed to work with it AND any derivative is approved upfront.","witness_evidence":"public/js/streaming/acp-client.js DOC_TYPE_CATEGORIES broadened to read+write across doc-domain; created docs auto-tracked via docAccess.addCreated in functions/streaming/modules/anthropic-gemini-proxy.js:129"}
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-455b3beb4afabe14-591
3
+ ns: default
4
+ created: 1780592707612
5
+ updated: 1780592707612
6
+ ---
7
+
8
+ agentgui plugin tool provisioning on Windows: lib/tool-spawner.js must iterate bun->npx fallback. BUNX_RUNNERS=['bun','npx'] tries each in sequence; on Windows hosts without bun the missing-command error arrives via stderr+close (not the 'error' event), so isMissingCmdError regex matches /not recognized|ENOENT|command not found|cannot find/i on BOTH error.message and captured stdout+stderr. Only falls through to next runner when missing flag set. Pattern: binary-might-not-exist -> runner fallback + check both error.message and process output streams + cross-platform regex alternation.
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-4845ee594759151c-560
3
+ ns: default
4
+ created: 1780592706754
5
+ updated: 1780592706754
6
+ ---
7
+
8
+ agentgui WebSocket /sync endpoint testing: server sends sync_connected with clientId on connect. Legacy handler lib/ws-legacy-handlers.js handles ping->pong, subscribe->subscription_confirmed, get_subscriptions->subscriptions, unsubscribe, latency_report. All responses use codec encode/decode (lib/codec.js). Message ordering requires registering the ws.on('message') handler BEFORE sending. Test pattern: const queued=[]; let waiting; ws.on('message',...); queued.forEach(m=>ws.send(m)); waiting.resolve(...) — ensures handler is live before messages flow.
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-8f59884039787ef3-743
3
+ ns: default
4
+ created: 1780592686732
5
+ updated: 1780592686732
6
+ ---
7
+
8
+ agentgui live validation target: https://buildesk.acc.l-inc.co.za/gm — nginx basic-auth user 'abc' password '123,slam,123,slam' (also satisfies the app's own PASSWORD gate). App listens PORT=9897 BASE_URL=/gm behind nginx (proxy_pass 127.0.0.1:9897). For the gm browser verb, use the DEFAULT session (NOT 'session new <name>' which uses an extension bridge that cannot reach external HTTPS) and set page.setExtraHTTPHeaders Authorization Basic (embedding creds in the URL breaks the app's fetch('/health') with 'URL that includes credentials'). Static site/app edits go live immediately (etag-keyed asset cache, no restart). Playwright reuses page across goto so clearCookies/about:blank to defeat session caching when verifying fresh edits.
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-993ab31f7a10aefa-718
3
+ ns: default
4
+ created: 1780593487331
5
+ updated: 1780593487331
6
+ ---
7
+
8
+ agentgui DS loading: index.html loads anentrypoint-design from unpkg @latest (247420.css + 247420.js) at RUNTIME, not the local vendor/ copy. Deliberate (commit 947086e) because the chat surface uses the load-bearing AgentChat kit component which exists in @latest (0.0.186) but NOT in the older locally-vendored bundle. So: GUI needs network, shifts on upstream publish, local vendoring bypassed, and the vendored bundle's si/ri/_s jsdelivr markdown imports (marked@15/dompurify@3/prismjs) are moot while on unpkg. To restore offline: re-vendor 0.0.186+ (has AgentChat) + re-localize (gzip Ha blob, fonts, CDN consts) + rewire si/ri/_s to ../cdn/ + point index.html back to ./vendor/. PRD row revendor-ds-186-offline.
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-a3e440db285f655a-995
3
+ ns: default
4
+ created: 1779979137157
5
+ updated: 1779979137157
6
+ ---
7
+
8
+ agentgui GUI styling (site/app): The anentrypoint-design DS bundle (vendor/anentrypoint-design/247420.js) exposes CSS vars --ff-display (='Inter, system-ui' in this bundle version — NOT Space Grotesk) and --ff-mono (='JetBrains Mono'); there is NO --font-sans. index.html body must use var(--ff-display). CRITICAL CASCADE GOTCHA: installStyles() injects the DS bundle CSS into a runtime <style data-247420> appended AFTER index.html's head <style>, so equal-specificity local overrides LOSE on source order. To override a DS component rule (.app-topbar nav a.active, .chat-head .sub, focus rings, etc) you must out-specify the .ds-247420-prefixed DS selector OR use !important. The offline vendoring mechanism (gzip Ha CSS blob in 247420.js + 3 CDN string consts Us/Za/_a) is still valid. DS Chat component ignores its sub prop and renders its own zero-padded '00 msgs' count in .chat-head .sub — hide via CSS. EventList rows are div.row[role=button] (click-to-expand) inside .ds-event-list.
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-ade01d9c02677c55-1557
3
+ ns: default
4
+ created: 1783699218939
5
+ updated: 1783699218939
6
+ ---
7
+
8
+ {"key":"agentgui-36th-run-ai-tell-sweep","value":"agentgui 36th run (2026-07-10): follow-on AI-tell sweep after the 35th run. gui-ai-tell-sweep workflow wf_a53b7d8f-27b (26 agents, 5 hunt lenses, 20 findings -> 11 confirmed / 9 correctly refuted). Kit fixes: context-menu/toast shadow -> --shadow-overlay token; barchart/upload/bar meter fills --accent -> --fg-3 (routine metering shouldnt use the loudest brand hue); checkbox checked-fill -> --fg; drag-over triple-accent-stack (tint+border+ring) collapsed to single dashed border; skip-to-main link de-loudened from CTA-accent to neutral fg/bg; session-unread dot neutralized off --accent; dead .empty-state child rules + unused .ds-panel-trio/.ds-panel-grid hover-lift deleted; AgentChat empty-state copy de-boilerplated. App: keyboardPanel/preferencesPanel gained kind:wide for settings panel-weight hierarchy. Browser-witnessed live (0 pageErrors, CSS rules confirmed post-fix). Kit pushed 4de1771, agentgui pushed e6dc1d9, CI green both repos. IMPORTANT recurring tool-defect: gm-plugkit fs_write verb payload key is content (JSON string), NOT body -- passing {path,body:{...}} silently no-ops with bytes:0 and no error. This nearly let a stale-empty .ci-validated marker pass the COMPLETE gate this run; caught via recall mid-VERIFY (the memory already existed from a prior run) and corrected before transitioning to COMPLETE. Always use content, verify bytes>0 in the fs_write response, and spot-check the actual file content on disk before trusting a CONSOLIDATE/COMPLETE gate that depends on it."}
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-dd98b21c0896dc04-1557
3
+ ns: default
4
+ created: 1783699262524
5
+ updated: 1783699262524
6
+ ---
7
+
8
+ {"key":"agentgui-26th-run-file-move-lint-fix","value":"agentgui 26th run (2026-07-02): per-row file move + lint tooling fix. Landed gui-completion #0: single-file move previously required checkbox-select + BulkBar. Kit FileRow gains a move action (between rename/delete, arrow-right icon); app onAction(move,file) seeds state.files.marked with just that one path and opens the existing bulk-move dialog, reusing runBulkMove/api/move as-is - no new dialog kind, no new server surface. Also fixed a real bug in ../design scripts/lint-null-children.mjs: its bracket-matcher did not understand // line comments, so an apostrophe inside ANY comment anywhere in a scanned file (e.g. the kit's own) was read as opening a string literal, silently corrupting bracket-depth tracking for the rest of that file - a false positive here, but potentially a false NEGATIVE elsewhere (hiding a real missing .filter(Boolean) behind an unrelated apostrophe earlier in the file). Added stripLineComments() (string-literal-aware) run before the scan; verified by reintroducing the apostrophe and confirming it now lints clean. CI note: Deploy GH Pages repeatedly failed with Deployment cancelled / Timeout reached, aborting! across this run and the 25th - GitHub Pages deployment API appears to self-cancel when a rerun is triggered while a prior attempt for the same commit SHA is still settling (rapid successive gh run rerun calls made this worse, not better). Fix: dont rerun repeatedly in quick succession - trigger once and wait it out with zero concurrent interference."}
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-e490a181de6469fc-487
3
+ ns: default
4
+ created: 1783843022402
5
+ updated: 1783843022402
6
+ ---
7
+
8
+ CORRECTION (37th run, 2026-07-12): agentgui's live index.html loads the design kit from LOCAL ./vendor/anentrypoint-design/247420.{css,js}, NOT unpkg. Witnessed directly on https://buildesk.acc.l-inc.co.za/gm/ via browser dom= query. An older memory record (mem-1780593485911-5-718) claiming unpkg @latest is STALE - it described a transient state that was reverted; AGENTS.md's 'DS load = LOCAL vendor' section is correct and current. Trust this record over the unpkg one going forward.
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-f9f0dc8895b21e18-1217
3
+ ns: default
4
+ created: 1783699265581
5
+ updated: 1783699265581
6
+ ---
7
+
8
+ {"key":"agentgui-25th-run-mid-thread-retry","value":"agentgui 25th run (2026-07-02): mid-thread retry. Landed the highest-severity finding deferred from the 24th run (gui-completion #7): retry was hard-gated to the LAST message only. Kit AgentChat now offers retry on every settled assistant turn, not just i===lastIdx; onRetryMessage(m) passes the specific message. App retryTurn(m) truncates+resends from ms preceding user turn. Retrying anything before the trailing turn discards every later turn too - arms the same confirm banner edit-and-resend already uses (kind:retry tag) instead of firing immediately; retrying the actual last turn (no data loss) still executes immediately. Caught and fixed a real bug while generalizing: the old retryLastTurn() never cleared state.chat.resumeSid/resumeNote on truncation, unlike confirmEditAndResend() which already did per the never --resume a diverged tail rule - both paths now share executeTruncateAndResend() so this cant drift apart again. Kit pushed 834c81d, agentgui pushed 18cd971. Browser-witnessed end-to-end, 0 console errors, 28/28 tests. This runs Deploy GH Pages CI step flaked twice before succeeding on a 3rd rerun - infra flake, not a code regression."}
@@ -0,0 +1,8 @@
1
+ ---
2
+ key: mem-fe3faa1652f07239-480
3
+ ns: default
4
+ created: 1780592683616
5
+ updated: 1780592683616
6
+ ---
7
+
8
+ agentgui DS (anentrypoint-design 247420.js) SearchInput component (internal De) sets aria-label = (label || placeholder) and IGNORES any aria-label prop you pass. To give the history search box a real accessible name, pass label: AND placeholder: (same string). A post-render setAttribute race-loses against the DS re-render. Signature: De({value,placeholder='search…',onInput,onSubmit,name='q',key,label}). This is the SearchInput export. Witnessed live on buildesk 2026-06-04.
package/AGENTS.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## GUI AI-tell sweep (2026-07-10) — thirty-sixth run
4
+
5
+ Follow-on to the 35th run's AI-tell sweep (commit 1702fa8). `gui-ai-tell-sweep` workflow wf_a53b7d8f-27b (26 agents, 25 done/1 verify-agent hit a mid-response connection error, 200 tool calls): 5 hunter lenses (gradient-glow-glass, emoji-generic-icons, oversaturated-color, boilerplate-copy, layout-uniformity-tells) found 20 candidates, adversarial verify confirmed 11 (9 correctly refuted as already-deliberate — status-disc pulse triplicate, `--sun` "retired" claim, radius-scale uniformity — the kept-typography-style guard doing its job on non-typography claims too). Landed in the kit (`design/app-shell.css`/`chat.css`/`src/components/agent-chat.js`): hardcoded one-off context-menu/toast shadow → `var(--shadow-overlay)` token; three data-metering fills (barchart/upload/bar) demoted from the loudest acid-lime `--accent` to neutral `--fg-3` (color reserved for state, not routine progress); checkbox checked-fill `--accent`→`--fg`; drag-over collapsed from a triple accent stack (tint+border+ring) to a single dashed border; the keyboard-only `.skip-to-main` link de-loudened from CTA-accent to neutral ink (it was visually identical to the primary send button despite being invisible to sighted users); session-unread dot neutralized off `--accent` so it stops competing with the active row's accent; dead template-shaped `.empty-state` child rules and an unused `.ds-panel-trio`/`.ds-panel-grid` hover-lift block deleted; AgentChat empty-state copy de-boilerplated away from "invitation" framing. App: `keyboardPanel()`/`preferencesPanel()` gained `kind:'wide'` so settings' purely-informational/utility panels read lighter than the consequence-bearing backend/agents panels. Kit rebuilt+re-vendored, browser-witnessed live (0 pageErrors, all 4 sampled CSS rules confirmed post-fix in the real CSSOM). Kit pushed `4de1771` (after a rebase — a concurrent version-bump commit had landed), agentgui pushed `e6dc1d9`, all CI green both repos. **Tool-defect note (recorded, not re-fixed — the workaround is durable): the gm-plugkit `fs_write` verb's payload key is `content` (a JSON string), not `body` — passing `{path, body:{...}}` silently no-ops with `bytes:0` and no error, which nearly let a stale/empty `.ci-validated` marker slip through this run; caught via `recall` mid-VERIFY and corrected before COMPLETE.**
6
+
3
7
  ## GUI ux-craft sweep (2026-07-10) — thirty-fourth run
4
8
 
5
9
  `gui-ux-craft` workflow wf_919164f1-648 (34 agents, 0 errors) + a parallel live-browser negative-space audit. Full detail in rs-learn (recall "agentgui 34th run ux-craft sweep"). Headline: negative-space audit came back **clean** for the 3rd consecutive run (31st/33rd/34th) — future sweeps should shift focus to interaction-level polish instead of re-running that lens. Kit: `--fs-2`/`--fs-0` shadow font tokens didn't exist in the real scale (silently fell back to hardcoded px, decoupled from `[data-typescale]`) — remapped to `--fs-lg`/`--fs-tiny`; stale `--lh-base` fallback (1.4, should be 1.55) fixed; ~29 more hardcoded focus-ring `outline:2px solid var(--accent-ink)` instances tokenized; `.chat-tool-copy` gained the coarse-pointer 44px touch target; `.ds-select` gained a base `min-height:32px` floor; `Btn`'s `className` prop renamed to `class` to match `Panel`/`Heading`; `AgentControls`/`AgentChat` gained an `agentsLoading` state. App: History no longer falls back to the raw session id (cwd/"Untitled conversation" instead), ACP error copy truncated+prefixed. Kit pushed `3b1080c` (after a rebase — a concurrent release commit had landed), agentgui pushed `9c8094b`, all CI green both repos.
@@ -34,13 +38,11 @@ Scoped to the specific ask (negative space / attention), not a full agent fan-ou
34
38
 
35
39
  ## Per-row file move + lint tooling fix (2026-07-02) — twenty-sixth run
36
40
 
37
- Landed gui-completion #0: single-file move previously required checkbox-select + BulkBar. Kit `FileRow` gains a `move` action (between rename/delete, `arrow-right` icon); app `onAction('move', file)` seeds `state.files.marked` with just that one path and opens the existing bulk-move dialog, reusing `runBulkMove`/`/api/move` as-is — no new dialog kind, no new server surface. **Also fixed a real bug in `../design`'s `scripts/lint-null-children.mjs`**: its bracket-matcher did not understand `//` line comments, so an apostrophe inside ANY comment anywhere in a scanned file (e.g. "the kit's own") was read as opening a string literal, silently corrupting bracket-depth tracking for the rest of that file — a false positive here, but potentially a false NEGATIVE elsewhere (hiding a real missing `.filter(Boolean)` behind an unrelated apostrophe earlier in the file). Added `stripLineComments()` (string-literal-aware) run before the scan; verified by reintroducing the apostrophe and confirming it now lints clean.
38
-
39
- **CI note: `Deploy GH Pages` repeatedly failed with "Deployment cancelled" / "Timeout reached, aborting!" across this run and the 25th** — GitHub Pages' deployment API appears to self-cancel when a rerun is triggered while a prior attempt for the same commit SHA is still settling (rapid successive `gh run rerun` calls made this worse, not better). Fix: don't rerun repeatedly in quick succession — trigger once (`gh run rerun` or `gh workflow run "Deploy GH Pages" --ref main` for a fresh dispatch if reruns keep reusing a wedged deployment ID) and wait it out with zero concurrent interference; both repos' deploys succeeded the moment reruns stopped racing each other. `npm publish`/`Publish and Release` (the actual consumer-facing artifacts) were green from the first attempt every time — only the docs-site GH Pages deploy flaked.
41
+ Single-file move action on `FileRow` reusing the bulk-move dialog; fixed a `lint-null-children.mjs` bracket-matcher bug (apostrophes in `//` comments corrupted bracket-depth tracking). Full detail in rs-learn (recall "agentgui 26th run file-move-lint-fix").
40
42
 
41
43
  ## Mid-thread retry (2026-07-02) — twenty-fifth run
42
44
 
43
- Landed the highest-severity finding deferred from the 24th run (gui-completion #7): retry was hard-gated to the LAST message only — a user unhappy with an earlier assistant reply had no way to redo it. Kit `AgentChat` now offers the retry action on every settled assistant turn, not just `i === lastIdx`; `onRetryMessage(m)` passes the specific message. App `retryTurn(m)` truncates+resends from `m`'s preceding user turn (falls back to the trailing assistant turn when called with no argument, preserving the old "retry last" behavior). **Retrying anything before the trailing turn discards every later turn too** — it now arms the same confirm banner edit-and-resend already uses (generalized with a `kind: 'retry'` tag: "Retry this turn? Retrying will remove the later turns") instead of firing immediately; retrying the actual last turn (no data loss) still executes immediately. Caught and fixed a real bug while generalizing: the old `retryLastTurn()` never cleared `state.chat.resumeSid`/`resumeNote` on truncation, unlike `confirmEditAndResend()` which already did per the "never `--resume` a diverged tail" rule — both paths now share `executeTruncateAndResend()` so this can't drift apart again. Kit pushed `834c81d`, agentgui pushed `18cd971`. Browser-witnessed end-to-end (mid-thread retry click -> confirm banner armed with the correct index -> confirm -> truncated to exactly the retried turn -> resent -> resumeSid cleared), 0 console errors, 28/28 tests. **Note:** this run's Deploy GH Pages CI step flaked twice ("Timeout reached" / "Deployment cancelled") before succeeding on a 3rd rerun — an infra flake in the docs-site deploy environment, not a code regression (`npm publish`, the actual consumer artifact, was green from the first attempt every time).
45
+ Retry ungated from last-message-only to every settled assistant turn, with truncate-and-resend + `resumeSid` clearing shared via one `executeTruncateAndResend()` path. Full detail in rs-learn (recall "agentgui 25th run mid-thread-retry").
44
46
 
45
47
  ## GUI completion sweep (2026-07-02) — twenty-fourth run
46
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1041",
3
+ "version": "1.0.1043",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar, Checkbox, ShortcutList, FocusTrap, AgentListSkeleton } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar, Checkbox, ShortcutList, FocusTrap, AgentListSkeleton } = C;
7
7
 
8
8
  // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
9
  // formatters (exported alongside the components), fall back to the local
@@ -147,6 +147,7 @@ function scheduleStreamRender() {
147
147
  });
148
148
  }
149
149
 
150
+ const UNTITLED_CONVERSATION = 'Untitled conversation'; // one fallback label, one casing, for every call site
150
151
  const NARROW_BP = 640; // unified with the CSS touch-target breakpoint in index.html
151
152
  function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < NARROW_BP; }
152
153
  function truncate(str, mobileLen, desktopLen) {
@@ -409,7 +410,15 @@ async function stopActiveChat(sid) {
409
410
  if (st.has(sid)) return; // re-entry guard: one cancel per sid in flight
410
411
  st.add(sid);
411
412
  render();
412
- try { await B.cancelChat(state.backend, sid); } catch {}
413
+ try {
414
+ await B.cancelChat(state.backend, sid);
415
+ } catch {
416
+ st.delete(sid);
417
+ state.live.bulkStopError = 'conversation did not stop - try again';
418
+ announce('stop failed');
419
+ render();
420
+ return;
421
+ }
413
422
  // Optimistically drop the row so the card doesn't read 'running' for up to
414
423
  // 3s after the click; the immediate refresh confirms.
415
424
  state.active = (Array.isArray(state.active) ? state.active : []).filter(a => a.sessionId !== sid);
@@ -746,7 +755,7 @@ function sessionsColumn() {
746
755
  const hits = (state.searchHits.results || []).slice(0, state.sessionsLimit);
747
756
  const items = hits.map((r, i) => ({
748
757
  sid: r.sid,
749
- title: r.snippet || r.title || 'untitled conversation',
758
+ title: r.snippet || r.title || UNTITLED_CONVERSATION,
750
759
  project: projectLabel(r.project) || '',
751
760
  time: r.ts ? fmtRelTime(r.ts) : '',
752
761
  running: false,
@@ -757,9 +766,14 @@ function sessionsColumn() {
757
766
  return ConversationList({
758
767
  sessions: items,
759
768
  selected: state.selectedSid,
769
+ // Search runs across every project (runSearch() clears projectFilter so
770
+ // it doesn't silently re-narrow later) - say so, since that reset is
771
+ // otherwise invisible and would surprise the user when they clear the
772
+ // search and the session list is unexpectedly wider than before.
773
+ caption: 'Searching across all projects',
760
774
  search: {
761
775
  value: state.searchQ,
762
- placeholder: 'Search conversations (2+ chars)',
776
+ placeholder: 'Search all conversations - titles + messages (2+ chars)',
763
777
  onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
764
778
  },
765
779
  onNew: () => { navTo('chat'); newChat(); },
@@ -770,6 +784,8 @@ function sessionsColumn() {
770
784
  loading: state.searchBusy,
771
785
  error: state.searchHits.error || null,
772
786
  emptyText: 'No matches for "' + state.searchQ + '"',
787
+ hasMore: (state.searchHits.results || []).length > state.sessionsLimit,
788
+ onLoadMore: () => { state.sessionsLimit += 60; render(); },
773
789
  });
774
790
  }
775
791
  const sessionsView = visibleSessions();
@@ -811,7 +827,7 @@ function sessionsColumn() {
811
827
  selected: state.selectedSid,
812
828
  search: {
813
829
  value: state.searchQ,
814
- placeholder: 'Search conversations',
830
+ placeholder: 'Search all conversations - titles + messages',
815
831
  onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
816
832
  },
817
833
  onNew: () => { navTo('chat'); newChat(); },
@@ -829,6 +845,8 @@ function sessionsColumn() {
829
845
  loadingText: state.historySlow ? 'Indexing your Claude history — the first load can take a minute…' : undefined,
830
846
  error: state.historyError,
831
847
  emptyText: 'No conversations yet',
848
+ hasMore: sessionsView.length > state.sessionsLimit,
849
+ onLoadMore: () => { state.sessionsLimit += 60; render(); },
832
850
  });
833
851
  }
834
852
 
@@ -946,9 +964,16 @@ function fileMutationCopy(e) {
946
964
  return e.message || 'The operation failed.';
947
965
  }
948
966
  function openFileDialog(kind, file) {
949
- state.files.dialog = { kind, file: file || null, error: null, busy: false };
967
+ const trigger = typeof document !== 'undefined' ? document.activeElement : null;
968
+ state.files.dialog = { kind, file: file || null, error: null, busy: false, _trigger: trigger || null };
950
969
  render();
951
970
  }
971
+ // Restore focus to whatever row/button opened the dialog so keyboard/AT users
972
+ // don't lose their place to document.body on close.
973
+ function restoreFileDialogFocus(trigger) {
974
+ if (!trigger) return;
975
+ requestAnimationFrame(() => { if (document.contains(trigger)) trigger.focus(); });
976
+ }
952
977
  // --- multi-select (marked) helpers. Marked entries are keyed by path so the
953
978
  // set survives sort/filter changes; loadDir owns clearing/pruning it.
954
979
  function filesMarked() {
@@ -990,14 +1015,19 @@ async function runBulkDelete() {
990
1015
  if (!d || d.kind !== 'bulk-delete' || d.busy) return;
991
1016
  const marked = filesMarked();
992
1017
  const targets = (state.files.entries || []).filter((e) => marked.has(e.path || e.name));
993
- if (!targets.length) { state.files.dialog = null; render(); return; }
1018
+ if (!targets.length) { state.files.dialog = null; restoreFileDialogFocus(d._trigger); render(); return; }
994
1019
  d.busy = true; d.error = null; render();
995
1020
  const results = await Promise.allSettled(
996
1021
  targets.map((e) => B.deleteEntry(state.backend, e.path, e.type === 'dir')));
1022
+ // The dialog can detach mid-flight (state replaced/closed elsewhere) - a
1023
+ // write onto a dead object would be silently lost, same race runFileMutation
1024
+ // guards below.
1025
+ if (state.files.dialog !== d) { announce('delete finished after the dialog closed'); render(); return; }
997
1026
  const failed = targets.filter((_, i) => results[i].status === 'rejected');
998
1027
  const okCount = targets.length - failed.length;
999
1028
  if (!failed.length) {
1000
1029
  state.files.dialog = null;
1030
+ restoreFileDialogFocus(d._trigger);
1001
1031
  marked.clear(); state.files._lastMarkIdx = null;
1002
1032
  announce('deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
1003
1033
  // Patch the visible list immediately instead of waiting on a second full
@@ -1026,22 +1056,28 @@ async function runBulkMove(destDir) {
1026
1056
  if (!d || d.kind !== 'bulk-move' || d.busy) return;
1027
1057
  const marked = filesMarked();
1028
1058
  const targets = (state.files.entries || []).filter((e) => marked.has(e.path || e.name));
1029
- if (!targets.length) { state.files.dialog = null; render(); return; }
1059
+ if (!targets.length) { state.files.dialog = null; restoreFileDialogFocus(d._trigger); render(); return; }
1030
1060
  d.busy = true; d.error = null; render();
1031
1061
  // Validate the destination once up front so 20 identical 403s read as one
1032
1062
  // clear message instead of a partial-failure split.
1033
1063
  try { await B.statPath(state.backend, destDir); }
1034
1064
  catch (e) {
1065
+ if (state.files.dialog !== d) { announce(fileMutationCopy(e)); render(); return; }
1035
1066
  d.busy = false;
1036
1067
  d.error = e.status === 404 ? 'that folder does not exist' : fileMutationCopy(e);
1037
1068
  render(); return;
1038
1069
  }
1039
1070
  const results = await Promise.allSettled(
1040
1071
  targets.map((e) => B.moveEntry(state.backend, e.path, destDir)));
1072
+ // The dialog can detach mid-flight (state replaced/closed elsewhere) - a
1073
+ // write onto a dead object would be silently lost, same race runFileMutation
1074
+ // guards below.
1075
+ if (state.files.dialog !== d) { announce('move finished after the dialog closed'); render(); return; }
1041
1076
  const failed = targets.filter((_, i) => results[i].status === 'rejected');
1042
1077
  const okCount = targets.length - failed.length;
1043
1078
  if (!failed.length) {
1044
1079
  state.files.dialog = null;
1080
+ restoreFileDialogFocus(d._trigger);
1045
1081
  marked.clear(); state.files._lastMarkIdx = null;
1046
1082
  announce('moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
1047
1083
  // Moved entries leave the current dir - patch them out immediately rather
@@ -1065,7 +1101,9 @@ function closeFileDialog() {
1065
1101
  // A mid-flight close would orphan the mutation's result and swallow its
1066
1102
  // error - hold the dialog open until the operation settles.
1067
1103
  if (state.files.dialog?.busy) { announce('still working - please wait'); return; }
1104
+ const trigger = state.files.dialog?._trigger;
1068
1105
  state.files.dialog = null; render();
1106
+ restoreFileDialogFocus(trigger);
1069
1107
  }
1070
1108
  async function runFileMutation(fn, doneMsg, patch) {
1071
1109
  const d = state.files.dialog;
@@ -1074,6 +1112,7 @@ async function runFileMutation(fn, doneMsg, patch) {
1074
1112
  try {
1075
1113
  await fn();
1076
1114
  state.files.dialog = null;
1115
+ restoreFileDialogFocus(d._trigger);
1077
1116
  announce(doneMsg);
1078
1117
  if (patch) {
1079
1118
  // Patch the visible list immediately, matching the bulk-delete/move
@@ -1131,6 +1170,32 @@ async function uploadFiles(fileList) {
1131
1170
  if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
1132
1171
  render();
1133
1172
  }
1173
+ // Pasted-image handler for the chat composer: upload each pasted file through
1174
+ // the same confined endpoint the Files tab uses (into the chat's current cwd),
1175
+ // then insert the resulting relative path into the draft. Falls back to an
1176
+ // announce() if there's no cwd to upload into (chat hasn't picked one yet).
1177
+ async function pastedFilesToChat(files) {
1178
+ const list = Array.from(files || []);
1179
+ if (!list.length) return;
1180
+ const dir = state.chatCwd;
1181
+ if (!dir) { announce('set a working directory before pasting files'); return; }
1182
+ const inserted = [];
1183
+ for (const f of list) {
1184
+ try {
1185
+ await B.uploadFile(state.backend, dir, f);
1186
+ inserted.push(f.name);
1187
+ } catch (e) {
1188
+ announce('paste upload failed: ' + fileMutationCopy(e));
1189
+ }
1190
+ }
1191
+ if (inserted.length) {
1192
+ const draft = state.chat.draft || '';
1193
+ state.chat.draft = draft + (draft && !draft.endsWith('\n') && !draft.endsWith(' ') ? ' ' : '') + inserted.join(' ');
1194
+ debouncedPersistDraft();
1195
+ announce('pasted ' + plural(inserted.length, 'file') + ' into ' + pathBasename(dir));
1196
+ render();
1197
+ }
1198
+ }
1134
1199
  // 'replace' on a 409 row: re-PUT the same file with overwrite=1.
1135
1200
  async function retryUploadOverwrite(it) {
1136
1201
  if (!it || it._retrying || !it._file) return;
@@ -1720,9 +1785,14 @@ function computeLiveSessions() {
1720
1785
  lastTs: t.last,
1721
1786
  errors,
1722
1787
  currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
1723
- // Apply stale detection to external sessions too: no recent activity + no running tool = stale.
1788
+ // Apply stale detection to external sessions too: no recent activity + no
1789
+ // running tool = stale. Same STALE_AFTER_MS as owned sessions (line 1691)
1790
+ // AND the inclusion filter above (line 1731) - a card must never report
1791
+ // 'stale' before it's even old enough to be dropped from the list, and an
1792
+ // owned vs external session in the identical objective state must read
1793
+ // the same status.
1724
1794
  status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error'
1725
- : (!t.toolRunning && t.last && (nowS - t.last) > STALE_AFTER_MS * 0.6 ? 'stale' : 'running'),
1795
+ : (!t.toolRunning && t.last && (nowS - t.last) > STALE_AFTER_MS ? 'stale' : 'running'),
1726
1796
  stopping: false,
1727
1797
  });
1728
1798
  }
@@ -2004,6 +2074,11 @@ function chatMain() {
2004
2074
  children: [
2005
2075
  h('span', { key: 'petxt' }, 'New turns stay in this tab but will not survive a reload - export the transcript from settings. '),
2006
2076
  Btn({ key: 'pedismiss', onClick: () => { state.chat.persistError = false; render(); }, children: 'dismiss' })] }));
2077
+ } else if (state.chat.persistTruncated) {
2078
+ banners.push(Alert({ key: 'ptrunc', kind: 'warn', title: 'Older turns will not survive a reload',
2079
+ children: [
2080
+ h('span', { key: 'ptrunctxt' }, 'This transcript is longer than the ' + PERSIST_MSG_CAP + ' turns saved locally - a reload keeps only the most recent ' + PERSIST_MSG_CAP + '. Export the full transcript from settings first. '),
2081
+ Btn({ key: 'ptruncdismiss', onClick: () => { state.chat.persistTruncated = false; render(); }, children: 'dismiss' })] }));
2007
2082
  }
2008
2083
  if (state.agentsError) {
2009
2084
  banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
@@ -2126,9 +2201,23 @@ function chatMain() {
2126
2201
  cwdDraft: state.cwdDraft,
2127
2202
  cwdError: state.cwdError || null,
2128
2203
  cwdChecking: !!state.cwdChecking,
2129
- // Pasting an image has no upload path on the chat surface yet - say so
2130
- // politely instead of silently dropping it.
2131
- onPasteFiles: () => announce('images cannot be attached here yet'),
2204
+ // Pasted images upload through the same confined endpoint the Files tab
2205
+ // uses, into the chat's current cwd, then insert the resulting relative
2206
+ // path into the draft - matching the desktop-composer expectation
2207
+ // instead of the prior permanent no-op.
2208
+ onPasteFiles: (files) => pastedFilesToChat(files),
2209
+ // Dropped files resolve their server-relative path via the confined
2210
+ // Files API and insert it into the draft, matching desktop-composer
2211
+ // expectations instead of the prior dead-end 'not supported' toast.
2212
+ onDropFiles: (files) => pastedFilesToChat(files),
2213
+ // The toolbar emoji button opens the SAME inline picker typing ':' does
2214
+ // (EMOJI_TRIGGER_RE) - append the trigger character rather than
2215
+ // duplicating the picker-open logic, which lives inside the composer.
2216
+ onEmoji: () => {
2217
+ const draft = state.chat.draft || '';
2218
+ state.chat.draft = draft + (draft && !draft.endsWith(' ') ? ' ' : '') + ':';
2219
+ render();
2220
+ },
2132
2221
  // Long threads render a capped window with a 'show earlier' row (the
2133
2222
  // chat-side equivalent of history's eventsLimit), reset per conversation.
2134
2223
  shownMessages: state.chat.shownMessages || undefined,
@@ -2270,8 +2359,16 @@ function trimPartForStorage(p) {
2270
2359
  }
2271
2360
  function persistChat() {
2272
2361
  try {
2273
- const msgs = state.chat.messages
2274
- .filter(m => !isEmptyTurn(m))
2362
+ const eligible = state.chat.messages.filter(m => !isEmptyTurn(m));
2363
+ // On-screen transcript can exceed the cap; a reload only restores the last
2364
+ // PERSIST_MSG_CAP turns - surface that once so it's not a silent surprise,
2365
+ // same Alert pattern as the sibling quota-failure banner below.
2366
+ const wasCapped = eligible.length > PERSIST_MSG_CAP;
2367
+ if (wasCapped !== !!state.chat.persistTruncated) {
2368
+ state.chat.persistTruncated = wasCapped;
2369
+ scheduleRender();
2370
+ }
2371
+ const msgs = eligible
2275
2372
  .slice(-PERSIST_MSG_CAP)
2276
2373
  .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
2277
2374
  const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
@@ -2704,12 +2801,12 @@ function historyMain() {
2704
2801
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
2705
2802
  const lede = sess
2706
2803
  ? (projectLabel(sess.project) || sess.cwd || 'unknown location') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
2707
- : 'Untitled conversation';
2804
+ : UNTITLED_CONVERSATION;
2708
2805
 
2709
2806
  const head = PageHeader({
2710
2807
  compact: true,
2711
2808
  dense: true,
2712
- title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || sess?.cwd || 'Untitled conversation', 40, 80),
2809
+ title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || sess?.cwd || UNTITLED_CONVERSATION, 40, 80),
2713
2810
  lede,
2714
2811
  });
2715
2812
 
@@ -2977,7 +3074,7 @@ function resumeSidLabel(sid) {
2977
3074
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
2978
3075
  const sess = arr.find((s) => s.sid === sid);
2979
3076
  const label = sess && (projectLabel(sess.title) || projectLabel(sess.project));
2980
- return label || 'untitled conversation';
3077
+ return label || UNTITLED_CONVERSATION;
2981
3078
  }
2982
3079
 
2983
3080
  function uniqueProjects() {
@@ -2995,7 +3092,21 @@ function uniqueProjects() {
2995
3092
  // stays visible and stoppable from anywhere (active polling is global).
2996
3093
  function runningPanel() {
2997
3094
  const running = Array.isArray(state.active) ? state.active : [];
2998
- if (!running.length) return null;
3095
+ if (!running.length) {
3096
+ // A send is in flight but the active-session poll hasn't caught up yet
3097
+ // (up to ~3s) - without this, the Live tab's existence is undiscoverable
3098
+ // during that gap even though the chat is, in fact, already running.
3099
+ if (state.chat.busy) {
3100
+ return Panel({
3101
+ key: 'runningPanelPending',
3102
+ title: 'running',
3103
+ children: [h('div', { key: 'runpending', class: 'resume-banner', role: 'group' },
3104
+ h('span', { key: 'runpendinglbl', class: 't-meta' }, 'this chat will also appear in live once it starts'),
3105
+ Btn({ key: 'runpendinglive', onClick: () => navTo('live'), children: 'view live' }))],
3106
+ });
3107
+ }
3108
+ return null;
3109
+ }
2999
3110
  const stopping = state.live.stopping || new Set();
3000
3111
  return Panel({
3001
3112
  key: 'runningPanel',
@@ -3108,15 +3219,16 @@ function healthSummary() {
3108
3219
  if (hh.version) bits.push(['v' + hh.version, 'Server version']);
3109
3220
  if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
3110
3221
  if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
3111
- // db chip uses the same online/offline register as the connection chip
3112
- // (not ok/down, which the connection chip already translated away). Always
3113
- // render it: when /health is partial and hh.db is absent, show 'db unknown'
3114
- // rather than dropping the chip and leaving the db state ambiguous.
3222
+ // db chip uses the SAME connected/offline register as the connection chip
3223
+ // above (not ok/down, and not a third online/offline register) so the same
3224
+ // up/down category of fact reads the same word in both chips of this row.
3225
+ // Always render it: when /health is partial and hh.db is absent, show
3226
+ // 'db unknown' rather than dropping the chip and leaving the db state ambiguous.
3115
3227
  // Only show 'db unknown' when health has been fetched (not on initial null/unknown state).
3116
3228
  if (hh.status && hh.status !== 'unknown') {
3117
- bits.push(['db ' + (hh.db ? (hh.db.ok ? 'online' : 'offline') : 'unknown'), 'History database status']);
3229
+ bits.push(['db ' + (hh.db ? (hh.db.ok ? 'connected' : 'offline') : 'unknown'), 'History database status']);
3118
3230
  } else if (hh.db) {
3119
- bits.push(['db ' + (hh.db.ok ? 'online' : 'offline'), 'History database status']);
3231
+ bits.push(['db ' + (hh.db.ok ? 'connected' : 'offline'), 'History database status']);
3120
3232
  }
3121
3233
  return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
3122
3234
  ...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));