agentgui 1.0.1042 → 1.0.1044

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-38b7e24dbcba2f3e-639
3
+ ns: default
4
+ created: 1783844902974
5
+ updated: 1783844902974
6
+ ---
7
+
8
+ HISTORICAL/SUPERSEDED (do not trust): agentgui briefly (2026-05-29) loaded anentrypoint-design from unpkg@latest at runtime instead of a local vendored copy, for always-latest tracking at the cost of requiring network + drift on upstream publish. This was REVERTED - as of the 2026-06-04/06-05 runs, index.html loads the DS kit from the LOCAL ./vendor/anentrypoint-design/247420.{js,css} again (re-confirmed live via browser witness on the 37th run, 2026-07-12: <link href='./vendor/anentrypoint-design/247420.css'>). AGENTS.md's 'DS load = LOCAL vendor' section is the current truth; any memory claiming unpkg is the live source is stale.
@@ -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-cdf04ff6a6a0aa6f-671
3
+ ns: default
4
+ created: 1783844901564
5
+ updated: 1783844901564
6
+ ---
7
+
8
+ agentgui webjsx applyDiff array-keying rule: children arrays that mix keyed VElements with raw text/numbers crash in vendor/webjsx/applyDiff.js with 'Cannot read properties of undefined (reading key)'. webjsx requires ALL siblings in an array prop (children, right, left, breadcrumbs, etc.) to either ALL be VElements with keys or ALL be non-keyed - passing Crumb({right:[h('span',{key:'a'},'A'), h('span',{key:'b'},'B'), 'connected']}) crashes because the render loop tries to read .key on the bare string. Fix: wrap any bare strings as keyed VElements, e.g. h('span',{key:'dot'},'connected'). Surfaced 2026-05-04 in crumbRight construction in site/app/js/app.js view().
@@ -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-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 logic+predictability sweep (2026-07-12) — thirty-seventh run
4
+
5
+ `gui-logic-predictability` workflow wf_5eec2403-09d (30 agents, 6 hunt lenses + verify + synthesize, 0 errors) — 15 confirmed findings, all implemented. App (`site/app/js/app.js`): `stopActiveChat` no longer leaves the per-card stop button permanently stuck on a failed cancel (deletes the sid from the stopping Set + surfaces an error on rejection instead of swallowing it); Files dialogs (rename/delete/mkdir/bulk) now restore keyboard focus to the triggering element on close, mirroring the existing cwd-edit pattern; `runBulkDelete`/`runBulkMove` gained the same stale-dialog guard `runFileMutation` already had; the "Untitled conversation" fallback consolidated to one `UNTITLED_CONVERSATION` constant (3 call sites had drifted casing); the settings db health chip now reads `connected`/`offline` instead of a third `online`/`offline` register, matching the adjacent connection chip; history search placeholder/caption now discloses it searches all projects' titles+messages; `runningPanel()` shows a lightweight "will also appear in live" hint during the send-to-active-poll gap instead of returning null; `computeLiveSessions()`'s external-session stale threshold no longer silently multiplies `STALE_AFTER_MS` by an undocumented 0.6 (owned and external sessions in the same objective state now report the same status); pasted/dropped composer files upload through the existing confined `PUT /api/upload-file` and insert the resulting path into the draft (was a permanent no-op/dead-end toast); the sessions rail's 60-item cap gained a load-more affordance (`state.sessionsLimit += 60`), mirroring the existing `eventsLimit` "load older" pattern; `PERSIST_MSG_CAP` truncation now surfaces a dismissible banner instead of silently dropping older turns from localStorage. Kit (`design/src/components/{chat,agent-chat,content,files,sessions}.js`, `app-shell.css`, `chat.css`): deleted dead `onAttach`/`onMenu` composer toolbar plumbing (no such feature existed); `AgentChat` now forwards `onEmoji` to its internal `ChatComposer` call (the emoji toolbar button was invisible because `AgentChat` — the wrapper agentgui actually calls, not `ChatComposer` directly — never destructured the prop; caught mid-EXECUTE via a `transition to=PLAN` re-scope); `SearchInput` and the Files filter input both gained Escape-to-clear + a visually-hidden `aria-live` result-count region; `ConversationList` gained `hasMore`/`onLoadMore`; composer textarea `overflow-y` changed from focus-only `auto` to always-`auto` so a capped-height composer keeps its scroll cue after blur. Kit built+tested (`bun test.js` all pass, all build lints pass), re-vendored, browser-witnessed live on buildesk across chat/history/settings/files (0 blocking errors on clean navigations). Kit pushed `af028ff`, agentgui pushed `a3b844d`, all CI green both repos. **Standing gotcha reconfirmed: this env's `gm browser` verb text-protocol (`dom=<selector>\n<script>`) only evaluates the selector query, not the trailing script — use `dom=<precise-selector>` alone to inspect attributes/rect, not as a general page-eval channel. Also: embedding Basic-Auth creds directly in a `page.goto` URL (`https://user:pass@host/...`) breaks the app's own same-origin `fetch()` calls with "Request cannot be constructed from a URL that includes credentials" — do one embedded-creds navigation to seed the browser's per-origin Basic-Auth cache, then navigate again WITHOUT embedded creds (plain URL or `?token=`) for any witness that needs real fetches to succeed.**
6
+
3
7
  ## GUI AI-tell sweep (2026-07-10) — thirty-sixth run
4
8
 
5
9
  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.**
@@ -226,21 +230,15 @@ Node v24 lacks a prebuilt better-sqlite3 binary; the node path needs a from-sour
226
230
 
227
231
  ## webjsx applyDiff Array Keying (2026-05-04)
228
232
 
229
- **Children arrays that mix keyed VElements with raw text/numbers crash in `vendor/webjsx/applyDiff.js`.**
230
-
231
- `webjsx` requires all siblings in an array prop (children, right, left, breadcrumbs, etc.) to either ALL be VElements with keys or ALL be non-keyed. Passing `Crumb({ right: [h('span', { key: 'a' }, 'A'), h('span', { key: 'b' }, 'B'), 'connected'] })` crashes on render with `TypeError: Cannot read properties of undefined (reading 'key')` at line 247420.js when the render loop tries to read `.key` on the bare string.
232
-
233
- Fix: wrap any bare strings as keyed VElements: `h('span', { key: 'dot' }, 'connected')`. Rule: if ANY sibling in an array has a key, ALL siblings must be VElements (no raw strings or numbers).
234
-
235
- Surfaced 2026-05-04 while validating the chat surface in `site/app/`. Fix applied to crumbRight construction in `site/app/js/app.js` view().
233
+ **Rule still binding: mixing keyed VElements with raw strings/numbers in any webjsx array prop crashes `applyDiff`.** Full detail in rs-learn (recall "agentgui webjsx applyDiff array-keying rule").
236
234
 
237
235
  ## Live History Stream Wiring (2026-05-05)
238
236
 
239
237
  `site/app/js/app.js` opens an SSE EventSource on `navTo('history')` (`B.streamHistory`, `state.live` shape, hello/event/conversation/error dispatch, rAF-throttled renders) against ccsniff's in-process `/v1/history/stream`; first `/v1/history/*` request triggers a 30-90s synchronous JSONL walk (`CLAUDE_PROJECTS_DIR`) — health-check timeouts during that window are normal. Full detail in rs-learn (recall "agentgui Live History Stream Wiring").
240
238
 
241
- ## Runtime CDN: GUI loaded from unpkg (2026-05-29)
239
+ ## Runtime CDN: GUI loaded from unpkg — SUPERSEDED, do not trust
242
240
 
243
- The GUI loads `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (import map + CSS link in `index.html`) so agentgui always tracks the latest published kit with no hand-maintained vendored copy. This **requires a network connection** — the earlier offline guarantee was intentionally dropped in favour of always-latest. The DS itself fetches its fonts from `unpkg.com/anentrypoint-design@latest/dist/fonts/` (set in the kit's `scripts/build.mjs` `FONT_BASE`). The `site/app/vendor/` tree is no longer imported; the old vendored DS bundle + `vendor/cdn/` assets are dead and may be removed. Witness the live GUI by confirming the import map resolves to unpkg and `.agentchat` renders with 0 console errors.
241
+ This 2026-05-29 unpkg-at-runtime approach was reverted; see "DS load = LOCAL vendor" below for the current truth (re-confirmed live on the 37th run, 2026-07-12). Historical detail in rs-learn (recall "agentgui unpkg superseded local vendor").
244
242
 
245
243
  ## Agent/model/session management (2026-05-28)
246
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1042",
3
+ "version": "1.0.1044",
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)));