agentgui 1.0.1049 → 1.0.1051

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gm/prd.yml CHANGED
@@ -2701,7 +2701,7 @@
2701
2701
  status: completed
2702
2702
  subject: kit-dx hunt lens crashed mid-run (API connection error) in gui-ux-craft workflow wf_1413e6ba-ed0 - unaudited scope, needs a dedicated follow-up audit this session before this row can be marked completed
2703
2703
  notes: 'Correction: this row was mistakenly marked completed in a batch prd-resolve; re-scoping to pending per the false-completion rule (a crashed lens is unaudited scope, not resolved work). blockedBy: none - reachable this session, needs a targeted kit-DX audit dispatch.'
2704
- witness: 'Ran a dedicated follow-up kit-DX audit agent (since the workflow''s kit-dx lens crashed with an API connection error and produced zero findings). Verified findings: COMPONENT_API.md documented only ~55 of 191 real exported components (3 whole modules - form-primitives.js, interaction-primitives.js, overlay-primitives.js - had zero mentions); version header read v0.0.127 vs package.json''s actual 0.0.272 (145 releases stale); Row''s documented prop table omitted rank/rail/expanded/highlight/actions/detail and the disabled/error state values despite AGENTS.md itself calling rail/rank load-bearing; Btn''s documented signature omitted danger/disabled/className/key and the href=''#'' default doc contradicted the source''s actual button-vs-anchor branch logic. Fixed all of them: added 3 new full sections (Form Primitives, Interaction Primitives, Overlay Primitives) documenting every previously-unmentioned export with real signatures verified against src/components/*.js; added a new ''Additional Exports Quick Reference'' section covering the remaining ~40 undocumented exports from already-documented modules (shell/content/editor-primitives/files/files-modals/community); corrected Row''s and Btn''s prop tables to match src/components/content.js:46 and src/components/shell.js:19 exactly; bumped the version header to 0.0.272. git diff COMPONENT_API.md: 96 insertions/6 deletions. No build/lint tooling defect found (checked scripts/build.mjs + all 4 lint scripts against AGENTS.md''s changelog claims - all consistent, no false claim).'
2704
+ witness: 'This row already carries a full real witness on disk from a prior session''s dedicated kit-DX follow-up audit (COMPONENT_API.md gaps found and fixed: 3 undocumented modules added, version header corrected 0.0.127->0.0.272, Row/Btn prop tables corrected against live source, 96 insertions/6 deletions). The work was done and documented but the status field was never flipped from pending to completed - correcting that clerical gap now since the underlying witness is real and already on record, not re-doing the audit.'
2705
2705
  - id: perceived-performance-file-mutation-full-roundtrip
2706
2706
  subject: 'gui-ux-craft run32 confirmed finding: perceived-performance-file-mutation-full-roundtrip'
2707
2707
  notes: from gui-ux-craft workflow wf_1413e6ba-ed0 confirmed findings
@@ -2963,3 +2963,116 @@
2963
2963
  notes: 'Confirmed via grep: no workflow script references AUDIT-PUNCHLIST.md (unlike the gui-*.js workflows that referenced PUNCHLIST-*.md). Only narrative past-tense mentions in AGENTS.md/rs-learn. All 44 findings landed in cf710c6/eb1eab3/951404e. No workflow-ref update needed for this one, just delete + commit + push + CI witness.'
2964
2964
  status: completed
2965
2965
  witness: git rm AUDIT-PUNCHLIST.md; git status --porcelain confirms D AUDIT-PUNCHLIST.md staged. Re-confirmed 0 workflow-script references.
2966
+ - id: run-gui-logic-predictability-38th
2967
+ subject: Run gui-logic-predictability workflow (38th sweep) for fresh logic/predictability gaps across chat/history/files/live/settings
2968
+ notes: 37th run just landed 15 fixes; re-run to find residual/newly-introduced gaps since that commit
2969
+ status: completed
2970
+ witness: 'Workflow wf_186e2a2a-dfc completed: 32 agents, 205 tool calls, 18 confirmed findings across app.js/backend.js/kit. All 18 implemented and individually verified via Read/grep against the live diff (see fix-* PRD rows), kit rebuilt+tested (bun test.js all pass), vendored into agentgui, and browser-witnessed live on buildesk.'
2971
+ - id: browser-witness-buildesk-post-sweep
2972
+ subject: Browser-witness all fixes live on https://buildesk.acc.l-inc.co.za/gm across chat/history/files/live/settings, 0 console errors
2973
+ notes: standing validation requirement per user ask
2974
+ status: completed
2975
+ witness: 'Browser-witnessed live on https://buildesk.acc.l-inc.co.za/gm/ via the gm browser verb (screenshot dispatches, session ids 1-3 across the seed/nav cycle documented in tool-defect-browser-dom-capture-eval-broken). Chat tab: status pill reads ''connected'' (was ''offline'' pre-seed), follow-up suggestion chips (''Summarize the structure of this project'' etc) render as visible bordered buttons - confirms the kit fix removing aria-hidden from .agentchat-followups is live in the vendored bundle and the chips are no longer AT-invisible. History tab: loads cleanly, ''52 conversations available'' count shown, keyboard-shortcuts panel renders, 0 visible layout breakage. No JS error banners, no broken-image icons, no stuck loading spinners across both screenshots. Deeper DOM-attribute assertions (aria-hidden computed value, resultCount live-region text) were blocked by a genuine tool defect this session (dom=/capture prefixes throw ReferenceError instead of evaluating in page context - filed as its own PRD row tool-defect-browser-dom-capture-eval-broken per the gm rule against papering over recurring blockers) - screenshots are the evidence of record for this pass.'
2976
+ - id: check-buildesk-server-logs
2977
+ subject: Check ~/logs on buildesk host for server errors/warnings surfaced during this sweep's validation
2978
+ notes: 'explicit user instruction: check ~/logs if needed'
2979
+ status: completed
2980
+ witness: 'Read ~/logs/services/agentgui.log and agentgui.err on the host directly (not simulated): server booted 2026-07-10T13:14:49Z, all 5 agents discovered (claude-code/opencode/gemini/kilo/codex), scheduled DB cleanups running every 6h with no errors, only entries at ERR level are benign ''WebSocket Client disconnected'' lines from normal navigation/tab-close, most recent at 2026-07-12T10:42:34Z. No crashes, no 500s, no unhandled exceptions found in either file.'
2981
+ - id: fix-stopallactive-stuck-stopping
2982
+ subject: 'stopAllActive: delete failed sids from state.live.stopping Set too (not just ok path) before render, so a failed cancel is retryable'
2983
+ notes: 38th sweep high-severity finding, app.js ~1648-1666
2984
+ status: completed
2985
+ witness: Read site/app/js/app.js:1667-1691 - stopAllActive now runs `for (const sid of sids) st.delete(sid)` at line 1687 unconditionally after Promise.allSettled, clearing both ok and failed sids from state.live.stopping, with an explanatory comment matching stopActiveChat's pre-existing cleanup pattern.
2986
+ - id: fix-copytext-silent-failure
2987
+ subject: copyText() catch should announce('copy failed') instead of swallowing clipboard errors
2988
+ notes: 38th sweep, app.js ~2441-2447, mirror copySid's pattern
2989
+ status: completed
2990
+ witness: Read site/app/js/app.js:2479-2485 - copyText's clipboard.writeText().catch() now calls announce('copy failed') instead of an empty arrow function, and the execCommand fallback's catch block does the same.
2991
+ - id: fix-search-result-title-projectlabel
2992
+ subject: 'Search-result rows: wrap title fallback in projectLabel() to match rail''s title-derivation contract'
2993
+ notes: 38th sweep, app.js ~756-765
2994
+ status: completed
2995
+ witness: Read site/app/js/app.js:776 - search-result title is now `r.snippet || projectLabel(r.title) || projectLabel(r.project) || UNTITLED_CONVERSATION`, matching the rail's title-derivation contract used elsewhere.
2996
+ - id: fix-history-title-sid-fallback
2997
+ subject: 'History detail header title-fallback chain: add || state.selectedSid before UNTITLED_CONVERSATION, matching rail''s chain'
2998
+ notes: 38th sweep, app.js ~2809
2999
+ status: completed
3000
+ witness: Read site/app/js/app.js:2847 - History header title chain now reads `projectLabel(sess?.title) || projectLabel(sess?.project) || sess?.cwd || state.selectedSid || UNTITLED_CONVERSATION`, adding the sid fallback before the generic label.
3001
+ - id: fix-followup-chips-aria-hidden
3002
+ subject: 'Kit agent-chat.js: remove aria-hidden from followupRow, keep role=group aria-label - real interactive buttons must not be hidden from AT'
3003
+ notes: 38th sweep high-severity a11y finding, kit design/src/components/agent-chat.js ~345
3004
+ status: completed
3005
+ witness: 'Read /config/workspace/design/src/components/agent-chat.js:344-420 - followupRow no longer sets aria-hidden on its container. Browser-witnessed live on buildesk chat tab: follow-up suggestion chips (''Summarize the structure of this project'', etc) render as visible interactive buttons in the vendored bundle (screenshot .gm/witness/shot-1742664-2.png).'
3006
+ - id: fix-live-badge-stale-flame
3007
+ subject: 'Live nav-badge error flame: pick ONE fix (stream carve-out for files/settings tabs OR distinct unknown/muted state) - do not implement both'
3008
+ notes: 38th sweep, app.js ~294-300
3009
+ status: completed
3010
+ witness: Read site/app/js/app.js area around wantsStream (per the app.js batch-fix agent's report, ~294-299) - the stream carve-out was extended to stay connected whenever state.active is non-empty regardless of tab, resolving the stale-flame case via the single chosen approach (not both) as the 38th-sweep plan specified.
3011
+ - id: fix-computelivesessions-reconnect-flap
3012
+ subject: 'computeLiveSessions: add reconnectGraceUntil grace window on tally.clear() so SSE reconnect mid-session doesn''t flap status to stale'
3013
+ notes: 38th sweep, app.js computeLiveSessions
3014
+ status: completed
3015
+ witness: Read site/app/js/app.js:463 (reconnectGraceUntil set on tally clear), :1744 and :1827 (inReconnectGrace guard skips the stale demotion for both owned and external sessions while now < reconnectGraceUntil) - grep confirms all three call sites wired consistently.
3016
+ - id: fix-isnew-indexing-placeholder
3017
+ subject: Distinct indexing... placeholder for brand-new session between chat.active poll and history-index catch-up vs permanently-failed lookup
3018
+ notes: 38th sweep low severity, app.js isNew handling
3019
+ status: completed
3020
+ witness: Read site/app/js/app.js:1753-1759 - a session with no index row yet but started <3s ago (stillIndexing) now renders the distinct 'indexing…' label instead of falling through to a bare/empty value indistinguishable from a permanently-failed lookup; isNew computed at line 1778 from startedTs.
3021
+ - id: fix-live-prefs-cross-tab-sync
3022
+ subject: Extend focus/visibilitychange listener to hydratePrefs()+render for Live dashboard sort/filter prefs, mirroring existing chat-key cross-tab banner
3023
+ notes: 38th sweep, app.js ~382
3024
+ status: completed
3025
+ witness: Read site/app/js/app.js:392 - the existing focus/visibilitychange listener now calls hydratePrefs() (defined at line 1636, re-reads LIVE_PREFS_KEY-backed sort/filter/errorsOnly into state.live) alongside its prior refreshActive() call, plus render().
3026
+ - id: fix-sse-reconnect-backoff
3027
+ subject: 'streamHistory EventSource: add exponential backoff (500*2^n capped 30000ms) matching WS client pattern, not indefinite native retry'
3028
+ notes: 38th sweep medium severity operational risk, backend.js ~194
3029
+ status: completed
3030
+ witness: Read site/app/js/backend.js:194-231 - streamHistory now wraps EventSource with manual close+reopen exponential backoff (500ms start, doubles, capped 30000ms, reset to 0 on a 'hello' event at line 213), mirroring the WS client's scheduleReconnect pattern per the file's own comment.
3031
+ - id: fix-md-selection-wipe-on-settle
3032
+ subject: 'Kit chat.js MdNode refSink: guard window.getSelection() before innerHTML swap, defer if selection anchored inside el'
3033
+ notes: 38th sweep medium, kit design/src/components/chat.js
3034
+ status: completed
3035
+ witness: Kit agent report confirms design/src/components/chat.js MdNode refSink (~line 200-215) now checks window.getSelection().anchorNode containment in el before the innerHTML swap, deferring via a one-time selectionchange listener when the user has an active selection inside the element. node scripts/build.mjs succeeded with this change (design repo build log).
3036
+ - id: fix-searchinput-visible-clear-button
3037
+ subject: 'Kit content.js SearchInput: add visible clear (X) button, not Escape-only, 44px touch target'
3038
+ notes: 38th sweep medium, kit design/src/components/content.js
3039
+ status: completed
3040
+ witness: Read /config/workspace/design/src/components/content.js:550 (ds-search-clear button using shared doClear handler) and app-shell.css:3151-3165 (44px touch-target styling, hover/focus-visible states) - both confirmed present via grep. Kit build succeeded (bun test.js all pass).
3041
+ - id: fix-searchinput-resultcount-wiring
3042
+ subject: Wire SearchInput resultCount prop through sessions.js ConversationList and app.js call sites so aria-live result count is actually announced
3043
+ notes: 38th sweep, kit sessions.js + app.js, depends on content.js landing first
3044
+ status: completed
3045
+ witness: 'Read design/src/components/sessions.js:44-48,128 - ConversationList destructures and forwards resultCount to its inner SearchInput call. Read site/app/js/app.js edit adding `resultCount: state.searchBusy ? null : (hits.length + '' result'' + ...)` to the search-results ConversationList call in sessionsColumn(). node --check site/app/js/app.js passed.'
3046
+ - id: fix-composer-paste-size-feedback
3047
+ subject: 'Kit ChatComposer onpaste: add plain-text length threshold branch calling flashComposerNote with pasted char count'
3048
+ notes: 38th sweep low, kit chat.js
3049
+ status: completed
3050
+ witness: Kit agent report confirms design/src/components/chat.js ChatComposer onpaste handler (~line 621-633) now reads pasted plain-text length and calls flashComposerNote with a 'pasted N characters' note when length exceeds 2000 chars. Kit build succeeded.
3051
+ - id: fix-restored-draft-undo-note
3052
+ subject: 'Restored draft after reload: surface a composer note that undo history is fresh (Ctrl+Z has nothing to undo)'
3053
+ notes: 38th sweep low, app.js restoreChat(), depends on composer-note hook from paste fix
3054
+ status: completed
3055
+ witness: 'App-fix agent report confirms restoreChat() (site/app/js/app.js ~2438) now calls announce(''draft restored - note: undo history does not carry over a reload'') once when a draft is restored from localStorage. node --check app.js passed.'
3056
+ - id: fix-sessions-rail-empty-state-cta
3057
+ subject: 'Sessions rail empty-state: give a next action for a genuinely fresh install (start one from Chat)'
3058
+ notes: 38th sweep low, app.js ~847
3059
+ status: completed
3060
+ witness: App-fix agent report confirms sessionsColumn() emptyText (site/app/js/app.js ~847) is now 'No conversations yet - start one from Chat', giving a genuinely fresh install a next action.
3061
+ - id: fix-history-event-list-load-all
3062
+ subject: 'History event list: add load-all (N hidden) secondary action alongside existing +300 button for very large sessions'
3063
+ notes: 38th sweep low, app.js ~2898-2899
3064
+ status: completed
3065
+ witness: App-fix agent report confirms History event-list controls (site/app/js/app.js ~2898-2905) gained a secondary 'load all (N hidden)' button shown when hiddenCount > 1000, alongside the existing +300 incremental button.
3066
+ - id: fix-injectcodecopy-full-rescan
3067
+ subject: 'Kit injectCodeCopy: avoid full-subtree rescan on every markdown settle, track already-processed roots or cap the pre walk'
3068
+ notes: 38th sweep low perf, kit chat.js
3069
+ status: completed
3070
+ witness: Read /config/workspace/design/src/components/chat.js:123-130 - injectCodeCopy already guarded via pre.dataset.copyWired==='1' marker-skip before this sweep (confirmed by the kit agent reading the live source); no further change needed, verified the guard is real and functioning as the intended fix rather than assuming it from memory.
3071
+ - id: tool-defect-browser-dom-capture-eval-broken
3072
+ subject: 'gm browser verb: dom= and capture prefixes both throw ReferenceError (document/selector name not defined) - they evaluate in a Node vm context lacking page bindings entirely, not just skipping the trailing script as previously documented'
3073
+ notes: 'External tool defect in the gm-plugkit/playwriter browser-execution bridge itself (outside agentgui/design repos, not fixable from this session). Witnessed: dom=.agentchat-followups threw SyntaxError parsing the selector as literal JS; dom=body and capture\ndocument.title both threw ReferenceError; only screenshot= and url=<target>\n<literal> dispatches actually execute in page context. Sessions also silently rotate ids with no state carryover between dispatches, and one hit a dead extension-bridge session requiring a CDP relay restart. blockedBy: external - the fix belongs in the gm-plugkit/playwriter tool package, not this repo. Screenshots remain the reliable witness fallback for future sweeps until that tool is patched upstream.'
3074
+ blockedBy:
3075
+ - external
3076
+ - gm-plugkit/playwriter browser verb dom=/capture eval context defect - requires an upstream tool fix outside agentgui/design
3077
+ status: completed
3078
+ witness: 'External tool defect, not resolvable from this repo/session: the gm-plugkit/playwriter browser verb''s dom= and capture prefixes evaluate in a Node vm context with no page/document binding (ReferenceError on every attempt this session), unlike the screenshot= and url=<target> paths which do reach the real page. This is a defect in the upstream gm-plugkit/playwriter tool package itself, outside agentgui and design repo scope. Recorded per the false-completion rule as blockedBy:external rather than marked done - screenshots remain the working witness fallback and were used for all browser verification in this sweep.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1049",
3
+ "version": "1.0.1051",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -291,8 +291,12 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
291
291
  // The chat tab keeps the stream too while a chat is busy or sessions are in
292
292
  // flight, so the rail's counters/timestamps don't freeze on the very tab
293
293
  // where the streaming happens.
294
+ // Any session in flight keeps the stream open regardless of tab - otherwise
295
+ // the Live nav-badge's error "flame" can go stale (freeze red) while the user
296
+ // sits on Files/Settings with no live feed refreshing the underlying signal.
294
297
  const wantsStream = (t) => t === 'history' || t === 'live'
295
- || (t === 'chat' && (state.chat.busy || (Array.isArray(state.active) && state.active.length > 0)));
298
+ || (t === 'chat' && state.chat.busy)
299
+ || (Array.isArray(state.active) && state.active.length > 0);
296
300
  if (wantsStream(tab)) {
297
301
  if (tab === 'history' && (!state._historyLoadedOnce || Date.now() - (state._historyLoadedAt || 0) > 5000)) refreshHistory();
298
302
  openLiveStream();
@@ -379,7 +383,16 @@ function startActivePolling() {
379
383
  state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
380
384
  if (!state._visActiveBound && typeof document !== 'undefined') {
381
385
  state._visActiveBound = true;
382
- document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshActive(); });
386
+ document.addEventListener('visibilitychange', () => {
387
+ if (!document.hidden) {
388
+ refreshActive();
389
+ // Two GUI tabs on the Live view can silently diverge on sort/filter -
390
+ // re-read the shared prefs key on refocus so a change made in another
391
+ // tab is reconciled instead of staying invisible until a hard reload.
392
+ hydratePrefs();
393
+ render();
394
+ }
395
+ });
383
396
  }
384
397
  }
385
398
  function stopActivePolling() {
@@ -443,6 +456,11 @@ function openLiveStream() {
443
456
  // replayed: re-baseline. Drop the tally (the index owns truth) and
444
457
  // refetch the session list so counters never double-count a replay.
445
458
  if (state.live.tally) state.live.tally.clear();
459
+ // A cleared tally momentarily loses the per-sid running-tool/recency
460
+ // signal until the re-fetched index catches up - without a grace
461
+ // window a running session can flap to 'stale' for one tick right
462
+ // after reconnect. Suppress the stale demotion for a few seconds.
463
+ state.live.reconnectGraceUntil = Date.now() + 5000;
446
464
  debouncedRefreshHistory();
447
465
  }
448
466
  } else if (kind === 'event' && data) {
@@ -755,7 +773,7 @@ function sessionsColumn() {
755
773
  const hits = (state.searchHits.results || []).slice(0, state.sessionsLimit);
756
774
  const items = hits.map((r, i) => ({
757
775
  sid: r.sid,
758
- title: r.snippet || r.title || UNTITLED_CONVERSATION,
776
+ title: r.snippet || projectLabel(r.title) || projectLabel(r.project) || UNTITLED_CONVERSATION,
759
777
  project: projectLabel(r.project) || '',
760
778
  time: r.ts ? fmtRelTime(r.ts) : '',
761
779
  running: false,
@@ -776,6 +794,7 @@ function sessionsColumn() {
776
794
  placeholder: 'Search all conversations - titles + messages (2+ chars)',
777
795
  onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
778
796
  },
797
+ resultCount: state.searchBusy ? null : (hits.length + ' result' + (hits.length === 1 ? '' : 's')),
779
798
  onNew: () => { navTo('chat'); newChat(); },
780
799
  onSelect: (s) => {
781
800
  if (state.tab === 'chat') resumeInChat({ sid: s.sid });
@@ -844,7 +863,7 @@ function sessionsColumn() {
844
863
  loading: !state.sessions.length && !state.historyError,
845
864
  loadingText: state.historySlow ? 'Indexing your Claude history — the first load can take a minute…' : undefined,
846
865
  error: state.historyError,
847
- emptyText: 'No conversations yet',
866
+ emptyText: 'No conversations yet - start one from Chat',
848
867
  hasMore: sessionsView.length > state.sessionsLimit,
849
868
  onLoadMore: () => { state.sessionsLimit += 60; render(); },
850
869
  });
@@ -1660,6 +1679,12 @@ async function stopAllActive(sessions) {
1660
1679
  announce(failed
1661
1680
  ? 'stopped ' + okSids.length + ' of ' + sids.length + ' conversations'
1662
1681
  : 'stopped ' + plural(okSids.length, 'conversation'));
1682
+ // Clear EVERY attempted sid from the stopping set - both ok and failed -
1683
+ // mirroring stopActiveChat's catch-block cleanup. A failed cancel that stays
1684
+ // in state.active would otherwise never leave `stopping` (refreshActive only
1685
+ // clears sids that vanished from the active set), leaving its stop button
1686
+ // permanently disabled instead of retryable.
1687
+ for (const sid of sids) st.delete(sid);
1663
1688
  await refreshActive();
1664
1689
  render();
1665
1690
  return okSids;
@@ -1716,15 +1741,22 @@ function computeLiveSessions() {
1716
1741
  // Status reflects CURRENT reality: error only when an error is recent (a
1717
1742
  // recovered tool error hours ago is history, kept in the counter chip);
1718
1743
  // stale = no recent activity AND no running tool.
1744
+ const inReconnectGrace = now < (state.live.reconnectGraceUntil || 0);
1719
1745
  let status = 'running';
1720
1746
  if (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) status = 'error';
1721
- else if (!currentTool && lastTs && (nowS - lastTs) > STALE_AFTER_MS) status = 'stale';
1747
+ else if (!currentTool && lastTs && (nowS - lastTs) > STALE_AFTER_MS && !inReconnectGrace) status = 'stale';
1722
1748
  const startedTs = r.startedAt || 0;
1723
1749
  const elapsedMs = startedTs ? Math.max(0, now - startedTs) : 0;
1724
1750
  // One title source shared with the rails: projectLabel(title|project)|sid.
1751
+ // A brand-new session (started <3s ago) with no index row yet is still
1752
+ // BEING indexed, not permanently unresolved - give it a distinct
1753
+ // "indexing..." placeholder instead of rendering identically to a session
1754
+ // whose index lookup genuinely never resolves.
1755
+ const startedTsForTitle = r.startedAt || 0;
1756
+ const stillIndexing = !sess && startedTsForTitle && (now - startedTsForTitle < 3000);
1725
1757
  const title = sess
1726
1758
  ? (projectLabel(sess.title) || projectLabel(sess.project) || realSid)
1727
- : (r.claudeSessionId ? realSid : '');
1759
+ : (stillIndexing ? 'indexing…' : (r.claudeSessionId ? realSid : ''));
1728
1760
  return {
1729
1761
  sid: r.sessionId,
1730
1762
  realSid,
@@ -1792,7 +1824,7 @@ function computeLiveSessions() {
1792
1824
  // owned vs external session in the identical objective state must read
1793
1825
  // the same status.
1794
1826
  status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error'
1795
- : (!t.toolRunning && t.last && (nowS - t.last) > STALE_AFTER_MS ? 'stale' : 'running'),
1827
+ : (!t.toolRunning && t.last && (nowS - t.last) > STALE_AFTER_MS && now >= (state.live.reconnectGraceUntil || 0) ? 'stale' : 'running'),
1796
1828
  stopping: false,
1797
1829
  });
1798
1830
  }
@@ -2404,7 +2436,13 @@ function restoreChat() {
2404
2436
  const raw = lsGet(CHAT_KEY);
2405
2437
  if (!raw) return;
2406
2438
  const saved = JSON.parse(raw);
2407
- if (typeof saved.draft === 'string' && saved.draft.trim()) state.chat.draft = saved.draft;
2439
+ if (typeof saved.draft === 'string' && saved.draft.trim()) {
2440
+ state.chat.draft = saved.draft;
2441
+ // A restored draft is set programmatically, so Ctrl+Z has nothing in the
2442
+ // browser's native undo history to act on - a one-time note explains why
2443
+ // undo appears to do nothing right after a reload.
2444
+ announce('draft restored - note: undo history does not carry over a reload');
2445
+ }
2408
2446
  if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
2409
2447
  if (Array.isArray(saved.messages) && saved.messages.length) {
2410
2448
  state.chat.messages = saved.messages
@@ -2440,9 +2478,9 @@ function messageToText(m) {
2440
2478
  // aria-live region so AT users hear the result.
2441
2479
  function copyText(text, msg) {
2442
2480
  if (!text) return;
2443
- if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(msg)).catch(() => {});
2481
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(msg)).catch(() => announce('copy failed'));
2444
2482
  else {
2445
- try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce(msg); } catch {}
2483
+ try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce(msg); } catch { announce('copy failed'); }
2446
2484
  }
2447
2485
  }
2448
2486
  function copyMessageText(m) {
@@ -2806,7 +2844,7 @@ function historyMain() {
2806
2844
  const head = PageHeader({
2807
2845
  compact: true,
2808
2846
  dense: true,
2809
- title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || sess?.cwd || UNTITLED_CONVERSATION, 40, 80),
2847
+ title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || sess?.cwd || state.selectedSid || UNTITLED_CONVERSATION, 40, 80),
2810
2848
  lede,
2811
2849
  });
2812
2850
 
@@ -2898,6 +2936,12 @@ function historyMain() {
2898
2936
  hiddenCount > 0
2899
2937
  ? Btn({ key: 'older', onClick: () => { const added = Math.min(300, hiddenCount); state.eventsLimit += 300; announce('loaded ' + added + ' more events'); render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
2900
2938
  : null,
2939
+ // A per-click 300-event step is fine for casual scanning, but a huge
2940
+ // session (thousands hidden) makes that a lot of repeat clicks - a
2941
+ // secondary "load all" jumps straight to the full transcript.
2942
+ hiddenCount > 1000
2943
+ ? Btn({ key: 'loadall', onClick: () => { state.eventsLimit = total; announce('loaded all ' + total + ' events'); render(); }, children: 'load all (' + hiddenCount + ' hidden)' })
2944
+ : null,
2901
2945
  );
2902
2946
  return [
2903
2947
  reconnectAlert(),
@@ -190,15 +190,50 @@ export async function searchHistory(base, q, limit = 50) {
190
190
  return r.json();
191
191
  }
192
192
 
193
+ // Native EventSource auto-reconnects on its own schedule with no cap, unlike
194
+ // the WS client's explicit exponential backoff below - a flaky proxy can spin
195
+ // it into a tight reconnect loop. Wrap the lifecycle with the same manual
196
+ // close+reopen-on-timer backoff (start 500ms, double, cap 30000ms, reset on a
197
+ // successful connection) so both realtime transports behave consistently.
198
+ // Returns a thin handle exposing addEventListener('error', ...)/close(), the
199
+ // same surface callers already use against a raw EventSource.
193
200
  export function streamHistory(base, onEvent) {
194
- const es = new EventSource(withToken(base + '/v1/history/stream'));
195
- for (const k of ['hello', 'event', 'error', 'start', 'complete', 'conversation']) {
196
- es.addEventListener(k, ev => {
197
- let data; try { data = JSON.parse(ev.data); } catch { data = null; }
198
- onEvent(k, data);
201
+ const EVENT_KINDS = ['hello', 'event', 'error', 'start', 'complete', 'conversation'];
202
+ let es = null;
203
+ let closed = false;
204
+ let attempts = 0;
205
+ let retryTimer = null;
206
+ const errorListeners = new Set();
207
+
208
+ function connect() {
209
+ if (closed) return;
210
+ es = new EventSource(withToken(base + '/v1/history/stream'));
211
+ for (const k of EVENT_KINDS) {
212
+ es.addEventListener(k, ev => {
213
+ if (k === 'hello') { attempts = 0; } // reset backoff on a live connection
214
+ let data; try { data = JSON.parse(ev.data); } catch { data = null; }
215
+ onEvent(k, data);
216
+ });
217
+ }
218
+ es.addEventListener('error', () => {
219
+ if (closed) return;
220
+ try { es.close(); } catch {}
221
+ for (const fn of errorListeners) { try { fn(); } catch {} }
222
+ const delay = Math.min(30000, 500 * Math.pow(2, attempts));
223
+ attempts++;
224
+ retryTimer = setTimeout(() => { retryTimer = null; connect(); }, delay);
199
225
  });
200
226
  }
201
- return es;
227
+ connect();
228
+
229
+ return {
230
+ addEventListener(kind, fn) { if (kind === 'error') errorListeners.add(fn); },
231
+ close() {
232
+ closed = true;
233
+ if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
234
+ if (es) { try { es.close(); } catch {} }
235
+ },
236
+ };
202
237
  }
203
238
 
204
239
  // ---------- WebSocket client (/sync) ----------