agentgui 1.0.959 → 1.0.960

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.
@@ -0,0 +1,405 @@
1
+ # PUNCHLIST-LOGIC.md
2
+
3
+ agentgui GUI logic+predictability sweep (workflow gui-logic-predictability.js, run wf_1756e13f-b08): 78 agents, 6 lenses -> 67 adversarially-confirmed findings (kept-typography guard). Audits whether every element is presented and operates in the most logical, predictable way: interaction state machines, cross-surface consistency, information architecture, realtime truth, input ergonomics, scale robustness.
4
+
5
+ ## Confirmed findings (67)
6
+
7
+ 1. [high | kit+app | logic-interaction-state-machines] File-mutation dialog errors render BEHIND the modal overlay - 409/403 from rename/delete/mkdir is invisible at the point of action
8
+ where: app.js:783-812 (fileDialog) + kit files-modals.js PromptDialog/ConfirmDialog
9
+ fix: fileDialog builds the error as a SIBLING of PromptDialog/ConfirmDialog (h('div',{key:'fdlg'}, err, PromptDialog(...))); the kit Modal is a fixed .ds-modal-backdrop overlay, so the role=alert <p> sits in page flow behind it and is outside the focus trap. Kit: add error and busy props to ConfirmDialog/PromptDialog (error rendered inside .ds-modal-body with role=alert + .field-error tone; busy disables both action buttons and the backdrop/Escape close, label already flips). App: pass d.error/d.busy into the dialog instead of the sibling div.
10
+ verdict: Confirmed in shipped source. app.js fileDialog() (lines 780-812) builds `err = h('p',{key:'fderr',role:'alert'},d.error)` and renders it as a SIBLING of PromptDialog/ConfirmDialog inside `h('div',{key:'fdlg'}, err, ...)`. The kit's dialogs (anentrypoint-design/src/components/files-modals.js, Backdro
11
+
12
+ 2. [high | kit+app | logic-interaction-state-machines] Dialog cancel/Escape/backdrop stay enabled while a delete/rename/mkdir is in flight - mid-flight close orphans the result and silently swallows the error
13
+ where: app.js:744-756 (runFileMutation) + kit files-modals.js Backdrop onClose
14
+ fix: closeFileDialog() runs unguarded while d.busy: the mutation continues, then the catch writes d.error/d.busy onto the DETACHED dialog object (state.files.dialog is already null) so the failure is lost with no feedback, and loadDir refresh only happens on the success path. App: closeFileDialog returns early when state.files.dialog?.busy (or surfaces a 'still working' note); on a detached-dialog error, announce() the failure instead of dropping it. Kit: the busy prop from the previous fix disables onClose paths.
15
+ verdict: Verified in shipped source. C:\dev\agentgui\site\app\js\app.js:743 `closeFileDialog() { state.files.dialog = null; render(); }` is unconditional - no `d.busy` check. runFileMutation (lines 744-756) captures `const d = state.files.dialog` before awaiting; if the user closes mid-flight (cancel button,
16
+
17
+ 3. [high | kit+app | logic-interaction-state-machines] Per-session stop (dashboard card, running panel, history) has no in-flight state - button stays enabled, re-fires freely, and the card reads 'running' for up to 3s after the click
18
+ where: app.js:335-338 (stopActiveChat), app.js:2033 (runningPanel stop), kit sessions.js:167 (SessionCard stop Btn)
19
+ fix: stopActiveChat fires cancelChat and waits for the 3s active poll to remove the row; nothing disables the button or changes the card, so users double-click and perceive a dead control. Kit: SessionCard accepts session.stopping -> stop Btn disabled with label 'stopping…' and the status word flips; SessionDashboard threads it. App: keep a state.live.stopping Set<sid>, add the sid before cancelChat, drop it when the sid leaves state.active (refreshActive), guard stopActiveChat re-entry per sid, and call refreshActive() immediately after cancel resolves.
20
+ verdict: Verified in shipped source. App C:\dev\agentgui\site\app\js\app.js:335-338 — stopActiveChat is `try { await B.cancelChat(...) } catch {}; refreshActive();` with no per-sid stopping Set, no re-entry guard, and no UI state change; runningPanel (line 2033) and the dashboard onStop (line 1165) both call
21
+
22
+ 4. [medium | kit+app | logic-interaction-state-machines] stop-selected / stop-all execute fire-and-forget: selection cleared and confirm dropped BEFORE any cancel resolves, with no busy indication and no failure feedback
23
+ where: app.js:1158-1170 (onStopSelected/onStopAll) + app.js:1057-1061 (stopAllActive)
24
+ fix: onStopSelected clears state.live.selected and the confirming flag synchronously, then calls stopAllActive unawaited; stopAllActive swallows every per-sid failure (.catch(()=>{})), so a partially-failed bulk stop looks identical to success and the cards stay 'running' until the poll. App: mark all targeted sids in the stopping Set (previous fix), await Promise.allSettled, announce 'stopped N of M' and surface failures via a dashboard banner; only clear selection for sids that actually stopped. Kit: while any sid is stopping, the bulk button renders disabled 'stopping N…'.
25
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Lines 1158-1163: onStopSelected synchronously sets confirmingStopSelected=false, calls stopAllActive(...) UNAWAITED, then immediately clears state.live.selected = new Set() — selection and confirm are gone before any cancel resolves. Lines 1166-1170: o
26
+
27
+ 5. [medium | app | logic-interaction-state-machines] Concurrent upload batches clobber each other: a second drop mid-upload replaces state.files.uploads, hiding the first batch's progress/errors while both loops keep uploading and double-refresh the dir
28
+ where: app.js:759-778 (uploadFiles)
29
+ fix: uploadFiles unconditionally does state.files.uploads = items and runs a sequential loop with no re-entry guard; two invocations interleave (DropZone drop + toolbar picker are both live the whole time), the orphaned batch keeps render()-ing rows that are no longer displayed, and each finishing batch fires its own loadDir. Guard with a state.files.uploading flag: while true, APPEND the new FileList's items to the existing uploads array and let the single running loop drain a shared queue; one loadDir + one announce when the queue empties.
30
+ verdict: Verified against C:\dev\agentgui\site\app\js\app.js lines 759-778. uploadFiles unconditionally does `state.files.uploads = items; render()` (line 763) with no in-flight guard, and both entry points (toolbar picker onchange line 983 and DropZone onDrop line 999) remain live during an upload. A second
31
+
32
+ 6. [medium | kit+app | logic-interaction-state-machines] Upload name-collision (409) is a dead end: no overwrite/replace affordance, no retry, and error rows have no dismiss - they persist until the next successful batch
33
+ where: app.js:127-133 backend.uploadFile (overwrite never passed) + app.js:766-777 + kit files.js:281-287 (UploadProgress)
34
+ fix: The server supports ?overwrite=1 and B.uploadFile takes an overwrite arg, but uploadFiles never passes it, so every collision errors 'already exists' with zero recovery; UploadProgress renders status text only - no per-row actions. Kit: UploadProgress accepts per-item actions ({label,onClick}) and an onDismiss; render 'replace' on 409 rows and 'dismiss' on any error row (44px targets, aria-labels). App: wire replace -> B.uploadFile(...,true) for that item, dismiss -> splice the row (clear uploads when empty).
35
+ verdict: Verified in shipped source. (1) backend.js:127 `uploadFile(base, dirPath, file, overwrite)` does support the overwrite arg (appends `&overwrite=1`, matching the server's PUT /api/upload-file per AGENTS.md 8th-run notes), but app.js:766 calls `B.uploadFile(state.backend, dir, it._file)` with no fourt
36
+
37
+ 7. [medium | app | logic-interaction-state-machines] Files filter and show-more cap silently carry across directories - 'Filter files in this directory' typed in dir A keeps hiding entries in dir B
38
+ where: app.js:654-677 (loadDir never resets f.filter / f.shown)
39
+ fix: loadDir sets path/segments/entries/roots but leaves state.files.filter and state.files.shown from the previous directory, so navigating into a folder can show 'No files match "x"' for a query the user typed somewhere else, and the show-more expansion from a 1000-entry dir leaks into the next. Reset filter='' and shown=null whenever the resolved j.path differs from the previous state.files.path (preserve them on the in-place refresh after a mutation, where path is unchanged).
40
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. loadDir (lines 654-677) sets state.files.path/segments/entries/roots but never touches state.files.filter or state.files.shown. The filter input's placeholder is literally 'Filter files in this directory' (line 943) and filtering is applied client-side
41
+
42
+ 8. [medium | app | logic-interaction-state-machines] Pressing 'n' (or the rail New chat) twice destroys the transcript - the destructive confirm step is the SAME control as the arm step and never auto-resets
43
+ where: app.js:1463-1473 (newChat) + app.js:2738 (keydown 'n') + app.js:532 (rail action)
44
+ fix: newChat() both arms (confirmingNewChat=true) and confirms (second invocation proceeds), so a double-tap of the n key or a double-click of the rail action wipes the chat with the banner visible for one frame; unlike stop-all there is no 4s auto-reset, so the armed banner also lingers indefinitely. Make the second newChat() invocation a no-op while confirmingNewChat is set (re-render only); confirmation happens exclusively via the banner's explicit 'clear' button, and add the same 4s auto-reset timer used by armStopAll.
45
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. newChat() (lines 1463-1473) arms on first call (`state.confirmingNewChat = true; render(); return;`) but on any subsequent call while armed falls through to the destructive branch (abort, wipe state.chat, lsRemove(CHAT_KEY)). The keydown handler (line
46
+
47
+ 9. [medium | app | logic-interaction-state-machines] Suggestion/followup chips silently overwrite and send over a typed draft
48
+ where: app.js:1366,1377 (onSuggestionClick / onFollowupClick set state.chat.draft = t then sendChat())
49
+ fix: Clicking a followup chip while a half-typed message sits in the composer destroys the user's text with no warning (sendChat clears draft after sending the chip text). Refactor sendChat to accept an explicit text argument (sendChat(text ?? state.chat.draft)); chip clicks pass the chip text and leave state.chat.draft untouched, so the typed draft survives the chip-initiated turn.
50
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Lines 1366 and 1377 both do `state.chat.draft = t; sendChat();` for suggestion/followup chips, and sendChat() (line 1651) reads `state.chat.draft` as the message text then unconditionally clears it (`state.chat.draft = ''`, line 1658). So any half-type
51
+
52
+ 10. [medium | app | logic-interaction-state-machines] Stale edit-and-resend confirm: sending a new message leaves the armed 'Edit this message?' banner live, so a later 'continue' truncates turns the user never agreed to discard
53
+ where: app.js:1626-1649 (armEditAndResend/confirmEditAndResend) + sendChat (never clears confirmingEdit)
54
+ fix: state.chat.confirmingEdit holds {idx,text} but only newChat resets it; sendChat and retryLastTurn leave it armed, so after another exchange the banner still offers 'continue' against a frozen idx and slices off the NEW turns too. Clear state.chat.confirmingEdit at the top of sendChat and retryLastTurn (the conversation it referred to has moved on), matching how navTo clears confirmingNewChat.
55
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. armEditAndResend (line 1626-1632) sets state.chat.confirmingEdit = {idx,text}; it is cleared only by confirmEditAndResend (1636), cancelEditAndResend (1647), and the full chat resets at lines 1470/2299 (newChat/restore). sendChat (1651+) and retryLastT
56
+
57
+ 11. [medium | app | logic-interaction-state-machines] Events buffered before stop still apply side effects after cancel: the aborted turn can set resumeSid and accrue totalCost while writing text into the popped message shell
58
+ where: backend.js:404-411 (queue drains after finish) + app.js:1475-1486 (cancelChat pops shell) + app.js:1677-1708
59
+ fix: streamChat's loop drains the remaining queue after abort (while(!done || queue.length)), so sendChat keeps processing 'session'/'result'/'text' events for a turn the user stopped - resumeSid is captured from a cancelled spawn and totalCost accumulates, while text lands in the assistant message cancelChat already popped. In sendChat's loop, break immediately when ctrl.signal.aborted (before applying any event), so a stopped turn applies no further state.
60
+ verdict: Verified in shipped source. backend.js streamChat (line 404) loops `while (!done || queue.length > 0)` and finish() (line 351, invoked by the abort handler at 397) only sets done=true — any events already queued before the abort are still yielded and drained. app.js sendChat (1667-1711) has no `ctrl
61
+
62
+ 12. [medium | kit | logic-interaction-state-machines] Modal close never restores focus to the invoker - after rename/delete/mkdir/preview close, keyboard focus falls to <body>
63
+ where: kit files-modals.js:11-78 (Backdrop focus trap has no focus restore)
64
+ fix: Backdrop steals focus into the modal on mount but its teardown only removes the keydown listener; on close (confirm, cancel, Escape, backdrop click) document.activeElement becomes body, so keyboard/AT users lose their place in the FileGrid every mutation. In Backdrop, record document.activeElement at mount and call .focus() on it inside _dsModalTeardown (guarded by isConnected); the app needs no change since rows/buttons are real focusable elements.
65
+ verdict: Verified in c:\dev\anentrypoint-design\src\components\files-modals.js. Backdrop (lines 11-78) steals focus on mount ("const preferred = modal.querySelector('[autofocus]') || firstFocusable; if (preferred) preferred.focus();" line 57-60), but its teardown is solely "el._dsModalTeardown = () => el.rem
66
+
67
+ 13. [low | app | logic-interaction-state-machines] copy-sid toast timers overlap: a second copy click truncates its own feedback when the first 2.5s timeout fires
68
+ where: app.js:1945-1948 (setCopyToast)
69
+ fix: setCopyToast schedules a new setTimeout per call without clearing the previous one, so rapid copies flash the label back to 'copy sid' early. Keep the timer in a module let, clearTimeout before re-arming - the same pattern armStopAll already uses.
70
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js:1945-1948. `setCopyToast(msg)` sets `copyToast = msg; render(); setTimeout(() => { copyToast = null; render(); }, 2500)` with no clearTimeout and no stored timer handle. A second copy click within 2.5s arms a second timer while the first is still pendin
71
+
72
+ 14. [low | kit+app | logic-interaction-state-machines] Rename/mkdir confirm with an unchanged or empty value is a silent no-op - the dialog stays open with no explanation
73
+ where: app.js:789,809 (PromptDialog onConfirm guards: if (v && v !== d.file.name) / if (v))
74
+ fix: Pressing Enter or 'rename' with the same name (or 'create' with a blank name) does nothing visible: no error, no close, button doesn't react. Using the new dialog error prop (gap 1), set a validation message ('enter a different name' / 'enter a folder name') instead of silently returning, so every confirm press produces visible feedback.
75
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js fileDialog(): line 789 `onConfirm: (v) => { if (v && v !== d.file.name) runFileMutation(...) }` and line 809 `onConfirm: (v) => { if (v) runFileMutation(...) }` silently return on empty/unchanged input — no error set, no render, dialog stays open with z
76
+
77
+ 15. [low | app | logic-interaction-state-machines] Backend-change confirm is not bound to the value it confirmed: editing the URL after arming still executes on the next save without re-confirming
78
+ where: app.js:2153-2171 (saveBackend / confirmingBackend)
79
+ fix: confirmingBackend is a bare boolean; arm with draft A, retype draft B, press save - it proceeds against B under A's confirmation, and the warning text lingers even when the draft becomes invalid or equal to the current backend. Store the confirmed value (state.confirmingBackend = draft) and require equality on the second press (re-arm otherwise); clear it in the TextField onInput when the draft changes.
80
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. saveBackend() (lines 2153-2160) arms a bare boolean: `if (state.chat.messages.length && !state.confirmingBackend) { state.confirmingBackend = true; render(); return; }` — it stores no record of which draft was armed. The TextField onInput (line 2222) o
81
+
82
+ 16. [high | app | predictability-cross-surface-consistency] Live dashboard 'activity' sort does not sort by recency - it only compares presence/absence of a lastActivity string, so 10 sessions with activity stay in arbitrary order
83
+ where: c:\dev\agentgui\site\app\js\app.js:1131 (sessions.sort, sortKey==='activity': (a.lastActivity?0:1)-(b.lastActivity?0:1))
84
+ fix: Keep the raw epoch on the derived session object (lastTs is already computed at app.js:1088) and sort by (b.lastTs||0)-(a.lastTs||0); same for 'elapsed' sort - keep startedAt epoch instead of parseInt on the 'Ns' display string, and 'errors' sort should use the errors count not a boolean
85
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Line 1129 is exactly `if (sortKey === 'activity') return (a.lastActivity ? 0 : 1) - (b.lastActivity ? 0 : 1);` — lastActivity is a formatted relative-time string (line 1113), so the comparator only distinguishes has-activity vs none; all sessions with
86
+
87
+ 17. [medium | app | predictability-cross-surface-consistency] History search-hit rows show the raw ccsniff project slug ('-config-workspace-agentgui') while every other surface shows the projectLabel tail ('agentgui') - the same project reads as two different strings depending on whether you searched
88
+ where: c:\dev\agentgui\site\app\js\app.js:2052 (sub: (r.project || '?') + ...) vs app.js:584/614/2065 which all wrap in projectLabel()
89
+ fix: Wrap with the existing helper: projectLabel(r.project) || '?' in the history-main search Row sub (the sessions-column search path at app.js:584 already does this - it is the canonical form)
90
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js: line 2052 (historySide search-hit Row sub) renders `(r.project || '?')` raw, while sibling session rows at line 2065 and the ConversationList/search mappings at lines 584/615 wrap in projectLabel(). The projectLabel comment (lines 1996-1998) confirms c
91
+
92
+ 18. [medium | kit+app | predictability-cross-surface-consistency] Duration renders in three incompatible formats for the same concept: live SessionCard/runningPanel show raw seconds forever ('3712s' for an hour-long run, app.js:1111 Math.round(...)+'s' and app.js:2032 elapsed+'s'), SessionMeta uses humanizeMs (app.js:1747), ContextPane uses (ms/1000).toFixed(1)+'s' (context-pane.js:86)
93
+ where: app.js:1111, app.js:2032, app.js:1747, c:\dev\anentrypoint-design\src\components\context-pane.js:86
94
+ fix: Export one fmtDuration(ms) from the kit (sessions.js or a shared util: <60s -> 'Ns', <1h -> 'Nm Ns', else 'Nh Nm'); SessionCard/SessionDashboard accept elapsedMs and format internally; app passes raw ms from startedAt/durationMs everywhere instead of pre-formatted strings
95
+ verdict: Verified in shipped source. app.js:1111 (live SessionCard data: `Math.round((now - r.startedAt)/1000) + 's'`) and app.js:2027-2032 (runningPanel: `elapsed + 's'`) render raw seconds with no rollover — an hour-long run reads '3712s'. Meanwhile app.js:1735 already has `humanizeMs` (s -> m -> h) used b
96
+
97
+ 19. [medium | kit+app | predictability-cross-surface-consistency] The same session has no shared identity between the rails and the live dashboard: ConversationList/history rows title it projectLabel(title||project)||sid, but the live SessionCard identifies it only by agent + model + cwd tail - a user cannot match the 'Running' rail row to its dashboard card
98
+ where: c:\dev\agentgui\site\app\js\app.js:1106-1117 (liveMain card derivation, no title field) vs app.js:614 (ConversationList title)
99
+ fix: Add an optional title prop to kit SessionCard (rendered as the card heading above the agent/model/cwd line); in liveMain, derive title from bySid.get(r.sessionId) via the same projectLabel(sess.title)||projectLabel(sess.project)||sid expression the rails use
100
+ verdict: Confirmed in shipped source. app.js:1106-1116 (liveMain card derivation) returns only {sid, agent, model, cwd tail, elapsed, counter, lastActivity, currentTool, status} — no title field — while the rails/history rows (app.js:614, also 1803/2065) title sessions with projectLabel(s.title)||projectLabe
101
+
102
+ 20. [medium | app | predictability-cross-surface-consistency] Changing the working directory has two different click behaviors for the same fact: clicking the composer context line opens the inline cwd editor (app.js:1372 onClick sets cwdEditing), but clicking the ContextPane cwd row navigates away to the Files tab (onSetCwd: () => navTo('files'))
103
+ where: c:\dev\agentgui\site\app\js\app.js:1372 vs app.js:514 (ContextPane onSetCwd)
104
+ fix: Make ContextPane's cwd action invoke the same inline editor (onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); }); the inline editor is canonical since it already validates via /api/stat. If browse-to-pick is wanted, it belongs as a secondary 'browse' action on the editor itself, identically reachable from both entry points
105
+ verdict: Verified in shipped source. app.js:512 wires `onSetCwd: () => { navTo('files'); }` while app.js:1372 (composerContext.onClick) and app.js:1417 (onCwdEdit) both open the inline editor (`state.cwdEditing = true; state.cwdDraft = ...`). Worse: the kit (c:\dev\anentrypoint-design\src\components\context-
106
+
107
+ 21. [low | app | predictability-cross-surface-consistency] History event rows are the only timestamps in the product rendered absolute (new Date(e.ts).toLocaleString()) - session rows, search hits, files modified, and live activity all use fmtRelTime, so the same pane mixes '2m ago' rows with '6/10/2026, 3:41:22 PM' events
108
+ where: c:\dev\agentgui\site\app\js\app.js:1936 vs fmtRelTime usage at 585/616/917/1113/2052/2067
109
+ fix: Render fmtRelTime(e.ts) in the event Row sub and move the absolute toLocaleString() into the row's title attribute (hover/AT detail) so forensic precision is preserved without breaking the one-format rule
110
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Line 1936 (history event row sub) renders `(e.ts ? new Date(e.ts).toLocaleString() : 'no time')` while session rows (616, 2067), search hits (585, 2052), files modified (917), live activity (1113), and the SessionMeta strip (1798) all use fmtRelTime -
111
+
112
+ 22. [low | kit | predictability-cross-surface-consistency] The kit ships two divergent byte formatters: files.js fmtFileSize ('B/KB/...', 0 -> em-dash, GB toFixed(1)) and chat.js fmtBytes (no B tier, GB toFixed(2), 0 -> '0.0 KB') - the same file shows a different size in the Files grid vs a chat attachment chip
113
+ where: c:\dev\anentrypoint-design\src\components\files.js:34-40 vs chat.js:14-19
114
+ fix: Keep fmtFileSize as the canonical kit formatter (but return '0 B' for zero - the em-dash should mean 'unknown/null' only, not zero), export it from files.js, and have chat.js import it; delete fmtBytes
115
+ verdict: Verified in c:\dev\anentrypoint-design\src\components\files.js:34-40 and chat.js:14-20. Two exported formatters genuinely coexist and are both live: fmtFileSize is used by FileRow (files.js:59) and FileViewer meta (files-modals.js:191); fmtBytes is used by chat attachment/file chips (chat.js:245,253
116
+
117
+ 23. [low | app | predictability-cross-surface-consistency] The same counters use two vocabularies: session rows and live cards abbreviate 'N ev · N tools · N err' while SessionMeta spells 'N events · N turns' - and SessionMeta omits the tools/errors counts the rows promise
118
+ where: c:\dev\agentgui\site\app\js\app.js:2067 and 1113 ('ev/tools/err') vs app.js:1798 ('events/turns')
119
+ fix: Standardize on the spelled words in the detail strip (SessionMeta: 'N events · N turns · N tools · N errors') and keep the abbreviated triple only in compact rows - but make the abbreviation set identical everywhere ('ev/tools/err'); document the compact-vs-detail rule in AGENTS.md so future surfaces pick deterministically
120
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Line 2067 (history session rows): sub = "... + ' ev · ' + ... + ' tools · ' + ... + ' err'". Lines 1090-1092 (live card counterBits): same 'ev'/'tools'/'err' abbreviations. Line 1798 (selected-session detail lede feeding PageHeader): "(sess.events || 0
121
+
122
+ 24. [low | kit+app | predictability-cross-surface-consistency] Connection-state vocabulary forks per transport: crumb/settings use connected/connecting/offline (+ ws 'reconnecting'), but the live dashboard SSE stream introduces a fourth word 'lost' ('live stream lost — retrying…')
123
+ where: c:\dev\anentrypoint-design\src\components\sessions.js:180 (STREAM_WORD.lost) fed by app.js:1079 (streamState 'lost')
124
+ fix: Rename the state to 'offline' end-to-end: app.js:1079 streamState = error ? 'offline' : ..., kit STREAM_WORD key offline -> 'live stream offline — retrying…' and the is-offline modifier class; one connection vocabulary across crumb, settings chip, and dashboard stream line
125
+ verdict: Verified in shipped source. Kit c:\dev\anentrypoint-design\src\components\sessions.js:180 defines STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' } and renders class 'is-' + streamState (line 202); chat.css:443 style
126
+
127
+ 25. [low | app | predictability-cross-surface-consistency] Empty/loading copy mixes a spaced ASCII hyphen and the typographic em dash for the same clause-break role: 'No live sessions - start a chat...' and 'Indexing your Claude history - the first load...' (hyphen) vs kit 'Backend offline — live sessions unavailable' (em dash)
128
+ where: app.js:1165 (emptyText), app.js:639 (loadingText) vs c:\dev\anentrypoint-design\src\components\sessions.js:195
129
+ fix: The em dash is the kept product typography (AGENTS.md glyph policy) - convert the app copy strings' spaced hyphens used as clause breaks to em dashes so prose punctuation reads identically in app-authored and kit-authored copy; this is a copy harmonization, NOT a glyph violation in either direction
130
+ verdict: Confirmed in shipped source: app.js:639, :1164, :1826 use ' - ' as clause break ('No live sessions - start a chat...', 'Indexing your Claude history - the first load can take a minute…') while kit sessions.js:180/195 use the em dash for identical empty/error-state copy ('Backend offline — live sessi
131
+
132
+ 26. [high | kit | LOGIC - information architecture + discoverability] File row actions (rename/delete/download) are invisible on touch devices wider than 480px - hover-only reveal with no coarse-pointer fallback
133
+ where: c:\dev\anentrypoint-design\app-shell.css:1171-1176 (opacity:0, revealed on :hover/:focus-within); the always-visible override at :881 lives inside a max-width:480px block, and the (hover:none),(pointer:coarse) block at :1490 covers only .ds-file-check
134
+ fix: Add `.ds-file-actions { opacity: 1; }` to the existing `@media (hover: none), (pointer: coarse)` block at app-shell.css:1490 (kit default, no app change). An iPad in landscape (coarse pointer, 1024px wide) currently has zero discoverable rename/delete/download affordance - the full CRUD manager shipped in the 8th run is effectively read-only on tablets.
135
+ verdict: Verified against c:\dev\anentrypoint-design\app-shell.css and the shipped vendored bundle. Line 1171-1176: `.ds-file-actions { opacity: 0; ... }` revealed only by `.ds-file-row:hover` / `:focus-within`. The always-visible override `.ds-file-row .ds-file-actions { opacity: 1; }` at line 881 sits insi
136
+
137
+ 27. [high | app | LOGIC - information architecture + discoverability] Idle chat status says 'resuming…' when nothing is running, and resume-vs-fresh is conveyed only via CLI jargon - the composer context line never says what send will do
138
+ where: c:\dev\agentgui\site\app\js\app.js:1356 (status ternary yields 'resuming…' while idle), :1294 (banner copy 'resuming session xxxxxxxx… via --resume'), :1370-1373 (composerContext bits = agent/model/cwd/turns, no resume bit)
139
+ fix: Idle-with-resumeSid status becomes 'ready' (it is); the resume fact moves to where the user looks before sending: append a composerContext bit 'continues session <sid8>' (or 'fresh session' after newChat) and rewrite the banner to plain language 'next message continues session <sid8>' - drop '--resume' (CLI flag = tribal knowledge). keptTypography: the existing ellipsis in 'resuming…'/sid truncation is the kept set, not a violation.
140
+ verdict: Verified against C:\dev\agentgui\site\app\js\app.js (current shipped source). Line 1354-1356: when not busy, the status ternary returns 'resuming…' whenever resumeSid is set for claude-code — i.e. a fully idle composer permanently displays a progressive-aspect activity word, indistinguishable from a
141
+
142
+ 28. [medium | app | LOGIC - information architecture + discoverability] '/' shortcut is dead on Files and Live even though both tabs have filter inputs - SHORTCUTS promises 'focus search or composer'
143
+ where: c:\dev\agentgui\site\app\js\app.js:2739-2745 (handler covers history+chat only; comment claims only settings lacks a field, but files has FileToolbar filter and live has the dashboard filter SearchInput at :1143)
144
+ fix: Extend the '/' branch: state.tab==='files' focuses the .ds-file-filter-input, state.tab==='live' focuses the dashboard filter input (both via the existing focusSearch-style query + announce). Update the SHORTCUTS desc to 'focus search / filter / composer' so the ?-overlay and keyboard panel stay truthful.
145
+ verdict: Verified in shipped source. C:\dev\agentgui\site\app\js\app.js:2739-2745: the '/' branch handles only state.tab==='history' (focusSearch) and 'chat' (focusComposer), then returns - on 'files' and 'live' the key does nothing, and the comment ("on settings there is no such field") is stale. Both tabs
146
+
147
+ 29. [medium | app | LOGIC - information architecture + discoverability] Running-chats panel on the chat tab offers only 'stop' - no discoverable path to the Live tab where those sessions are actually managed
148
+ where: c:\dev\agentgui\site\app\js\app.js:2019-2037 runningPanel() (per-row children: disc, label, stop button; title 'running · N')
149
+ fix: Add a keyed 'view all in live' Btn in the Panel title row (navTo('live')) and a per-row 'open' action (resumeInChat for own session / navTo('live') otherwise), mirroring the liveMain onOpen/onView vocabulary. The rail Live count badge alone does not connect 'this running chat' to 'that tab'.
150
+ verdict: Verified against C:\dev\agentgui\site\app\js\app.js:2019-2036: runningPanel() renders, per running session, exactly three keyed children - a live status disc, a label span (agent · model · elapsed · cwd tail), and a single Btn 'stop' (stopActiveChat). There is no navigation affordance to the Live ta
151
+
152
+ 30. [low | app | LOGIC - information architecture + discoverability] Settings page lede is backend-panel copy, and the data panel duplicates the server version - section copy lives in the wrong containers
153
+ where: c:\dev\agentgui\site\app\js\app.js:2201-2204 (PageHeader lede 'Point agentgui at any backend. Blank = same-origin…'), :2316 (preferencesPanel repeats 'server vX · build Y' already shown in serverPanel :2270)
154
+ fix: Move the backend explanation into the backend Panel as its lede; page lede becomes 'Connection, agents, appearance, keyboard, and local data.' Drop the version line from the data panel (it belongs to serverPanel); keep only the build stamp if it differs from server version.
155
+ verdict: Read C:\dev\agentgui\site\app\js\app.js. settingsMain() line 2204 sets the page-wide PageHeader lede to backend-only copy ('Point agentgui at any backend. Blank = same-origin (ccsniff in-process)...') even though the page renders six panels (backend/appearance/server/agents/keyboard/data). serverPan
156
+
157
+ 31. [low | app | LOGIC - information architecture + discoverability] Settings panels are ordered illogically: appearance splits the connection group (backend / appearance / server / agents)
158
+ where: c:\dev\agentgui\site\app\js\app.js:2207-2253 settingsMain children order: backend, appearance, serverPanel, agentsPanel, keyboardPanel, preferencesPanel
159
+ fix: Reorder to backend, serverPanel, agentsPanel, appearance, keyboardPanel, preferencesPanel(data). Pure array reorder; section= deep links (focusSettingsSection by id) are unaffected.
160
+ verdict: Read C:\dev\agentgui\site\app\js\app.js settingsMain() (lines 2197-2255): the settings-grid children are exactly Panel(id:'backend'), Panel(id:'appearance'), serverPanel(), agentsPanel(), keyboardPanel(), preferencesPanel() — appearance does sit between backend and server/agents, splitting the conne
161
+
162
+ 32. [low | app | LOGIC - information architecture + discoverability] Crumb vocabulary breaks from the rail ('configuration' vs 'Settings') and the crumb trail is always empty - on Files it shows a truncated leaf with no hierarchy
163
+ where: c:\dev\agentgui\site\app\js\app.js:469-471 (crumbLeaf='configuration'; Crumb({trail:[], leaf, …}) for every tab; files leaf is truncate(path,24,64))
164
+ fix: crumbLeaf for settings = 'settings' (match the rail item, sentence-case policy). For files, pass trail: the last 1-2 parent segments of state.files.path with the dir name as leaf (Crumb already accepts trail), so the top bar communicates location instead of a mid-path ellipsis. keptTypography: the truncation ellipsis itself is kept typography, not the issue.
165
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js: line 469 sets crumbLeaf='configuration' while the rail item at line 528 is labeled 'Settings' (vocabulary mismatch with the navigation, against the project's own crumb-dot/settings vocab-consistency precedent). Line 471 hardcodes Crumb({ trail: [], lea
166
+
167
+ 33. [low | kit+app | LOGIC - information architecture + discoverability] The composer context line (agent · model · cwd · N turns) is one big click target that only opens the cwd editor - clicking the agent or model bit unpredictably edits cwd
168
+ where: c:\dev\agentgui\site\app\js\app.js:1370-1373 (composerContext.onClick => cwdEditing); kit c:\dev\anentrypoint-design\chat.css:422 .chat-composer-context renders the whole line as the control
169
+ fix: Kit: render the cwd bit as its own inline button inside .chat-composer-context (per-bit `onClick` support on the composerContext prop, 44px tap floor under pointer:coarse) and title it 'change working directory'; the agent/model bits stay inert text (the picker above already owns them). App: pass onClick on the cwd bit only.
170
+ verdict: Verified in shipped source. Kit C:\dev\anentrypoint-design\src\components\chat.js:375-381 renders the entire context line as a single <button class="chat-composer-context"> when context.onClick is set, joining ALL bits ('agent · model · cwd · N turns') into one control with aria-label 'change target
171
+
172
+ 34. [high | app+server | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Live dashboard joins on the wrong session id: chat.active returns the ephemeral 'chat-<hex>' id (lib/ws-handlers-util.js:145) but tally, sessionsBySid, and resumeSid are all keyed by the claude/ccsniff sid - bySid.get(r.sessionId), tally.get(r.sessionId), and state.chat.resumeSid===r.sessionId (app.js:1083-1096) can NEVER match, so dashboard counters stay empty, lastTs stays 0 (stale can never derive for agentgui-spawned chats), error status never derives, currentTool and the activeSid accent never light
173
+ where: lib/ws-handlers-util.js:142-148 + site/app/js/app.js:1080-1105
174
+ fix: Server: include claudeSessionId (ctrl.claudeSessionId is already captured at ws-handlers-util.js:104) in the chat.active payload. App liveMain: compute const realSid = r.claudeSessionId || r.sessionId and use realSid for bySid/tally/resumeSid/activeSid lookups; keep the ephemeral sessionId for chat.cancel. activeSig (app.js:299) must include claudeSessionId so the rail re-renders when it arrives mid-turn
175
+ verdict: Verified against shipped source. lib/ws-handlers-util.js:82 creates the per-turn id as 'chat-' + crypto.randomBytes(8).toString('hex'); the real claude sid is only captured onto ctrl.claudeSessionId at line 104 and broadcast as streaming_session, but the chat.active payload at line 145 returns only
176
+
177
+ 35. [high | kit+app+server | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Turn that completes while the WS is down hangs busy forever: streaming_complete is a fire-and-forget broadcast with no replay, and backend.js streamChat clears the 12s grace timer unconditionally on reconnect ('open' branch, backend.js:381-384) even though the missed completion frame will never be re-sent - the async iterator never finishes, chat.busy stays true, the stop button is the only escape, and the transcript silently misses every text/tool delta dropped during the outage with no divergence indicator
178
+ where: site/app/js/backend.js:377-393
179
+ fix: On ws 'open' during an in-flight stream, instead of just clearing the grace timer, call chat.active and check the ephemeral sessionId: if absent, finish() with a 'connection dropped mid-turn - the response may be incomplete; events were not replayed' marker on the message (cur.incomplete flag rendered by kit ChatMessage as a plain-copy notice with a retry affordance). Optionally server-side: keep a short per-session ring buffer and re-emit the terminal frame to re-subscribers
180
+ verdict: Verified in shipped source. site/app/js/backend.js:379-392: onWs('open') unconditionally clears graceTimer and returns ('Reconnected in time - the open handler already re-subscribed us'), so the iterator only ends via streaming_complete/streaming_error (lines 364-369), abort, or the 12s timer firing
181
+
182
+ 36. [high | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] A session with ANY historical error is pinned to status 'error' forever: liveMain uses cumulative session error count (if (errors) status='error', app.js:1104) from the whole-session history index, so one recovered tool error hours ago keeps the card flame-toned and floated to the front for its entire life - status disagrees with current reality and 'errors' sort/errorsOnly filter inherit the lie
183
+ where: site/app/js/app.js:1102-1105,1120,1130
184
+ fix: Derive status from recency: status='error' only when the LAST event was an error or an error occurred within STALE_AFTER_MS (track t.lastErrorTs in the tally at app.js:375 and sess.lastError at :383); keep the cumulative 'N err' counter chip as history. Kit SessionCard already distinguishes status from counters - no kit change needed beyond passing both
185
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Line 1104: `if (errors) status = 'error';` where `errors` (line 1087) comes from `sess.errors` (history-index cumulative, incremented at line 383 for every isError event ever) or `t.errors` (live tally, line 375, also cumulative-only — no timestamp of
186
+
187
+ 37. [high | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] A slow tool in any session other than the in-page chat wrongly reads 'stale': currentTool is only derived when state.chat.resumeSid===sid AND state.chat.busy (app.js:1096-1101), so a second concurrent session running a 2-minute Bash tool emits no events for >45s and flips to 'idle' while it is genuinely busy - the staleness guard '!currentTool' protects exactly one session
188
+ where: site/app/js/app.js:1093-1105
189
+ fix: Track running tools per-sid from the SSE feed: in the openLiveStream event handler set t.toolRunning=true on tool_use and false on the matching tool_result (tally entry, app.js:370-377); liveMain treats t.toolRunning as currentTool-equivalent so any session with an unresolved tool_use never derives stale. Also surface the tool name from the last tool_use event so every card gets a currentTool line, not just the in-page one
190
+ verdict: Verified in site/app/js/app.js: lines 1095-1101 derive currentTool only when state.chat.resumeSid===sid && state.chat.busy, and line 1105 marks any other session stale after 45s of event silence — exactly the window a long-running Bash tool creates (tool_use sets tally.last at lines 370-377, then no
191
+
192
+ 38. [high | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Two GUI tabs clobber each other's chat state: persistChat writes transcript+draft+resumeSid+totalCost to the single localStorage key agentgui.chat on every stream event (app.js:1504), there is no 'storage' event listener anywhere in app.js, so two tabs running different conversations last-writer-win the key - a reload restores the OTHER tab's transcript, and worse, tab A's next turn can --resume tab B's resumeSid (a conversation the user never saw in that tab)
193
+ where: site/app/js/app.js:1500-1520 (persistChat/restoreChat), listener absent
194
+ fix: Namespace the persisted chat per conversation: key agentgui.chat.<resumeSid|draftId> plus a per-tab sessionStorage pointer to the active one; restore from the pointer, fall back to most-recent. Add a window 'storage' listener that, when another tab rewrote the active key, shows a plain-copy banner 'this conversation was updated in another tab - reload it' instead of silently diverging. Cap and GC old per-conversation keys in the existing local-data size panel
195
+ verdict: Confirmed in C:\dev\agentgui\site\app\js\app.js. CHAT_KEY is the single constant 'agentgui.chat' (line 1488); persistChat() (1495-1506) writes messages+draft+resumeSid+totalCost+agent+model to that one key, and it is called on every turn mutation (1620, 1642, 1662, 1683, 1717) plus debounced draft t
196
+
197
+ 39. [medium | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] SSE reconnect silently corrupts the live counters both ways: the per-sid tally and sessionsBySid increments (app.js:370-383) have NO dedup (the e.i index dedup at :360 only protects state.events for the selected session), so any replay/overlap after an EventSource auto-reconnect double-counts tools/errors, while events missed during the outage are never reconciled into the tally (only sessionsBySid heals via refreshHistory) - dashboard counters drift from history counts with no indication; state.live.eventCount also increments on hello/error frames (app.js:346-347), inflating the diagnostics number
198
+ where: site/app/js/app.js:345-398
199
+ fix: Dedupe counter increments by event index: store t.maxI per sid and skip increments when ev.i!=null && ev.i<=t.maxI (same for sess.*); on the 'hello' after a disconnect (state.live.reconnects path, app.js:350) drop the tally entries and trigger refreshHistory so counters re-baseline from the index instead of guessing; count only kind==='event' frames into live.eventCount
200
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js openLiveStream (lines 340-407). (1) Line 347 increments state.live.eventCount on EVERY frame including 'hello' and 'error' — confirmed, inflates the diagnostics number. (2) The index dedup at lines 358-364 only guards state.events for the selected sid;
201
+
202
+ 40. [medium | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Clock skew breaks staleness and relative timestamps with no clamp: t.last/sess.last take server-side ev.ts (JSONL timestamps) and compare against client Date.now() (app.js:373,1088,1105); fmtRelTime (app.js:84-91) happily renders negative spans ('-12s ago') when the server clock is ahead, and a server behind the client makes every session derive stale instantly at 45s of pure skew
203
+ where: site/app/js/app.js:84-91, 373, 1105
204
+ fix: Clamp fmtRelTime at 0 ('just now' for s<=0). Estimate skew once: on the first SSE event compute offset = Date.now() - ev.ts (events arrive near-realtime) into state.live.clockSkew and apply it in the stale comparison and fmtRelTime inputs; alternatively have /health return server now and diff it in probeBackend
205
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. fmtRelTime (lines 84-91) computes `const s = Math.round((Date.now() - ts) / 1000); if (s < 60) return s + 's ago'` with no lower clamp — a server-side ev.ts ahead of the client clock yields negative s and renders e.g. '-12s ago' (used at lines 585, 616
206
+
207
+ 41. [medium | kit+app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] External agent sessions are invisible on the dashboard despite live motion: liveMain builds cards ONLY from state.active (agentgui-spawned chats, app.js:1080), but the SSE tally captures every ccsniff session including a claude CLI run in a terminal - its events tick the tally and the conversation rail, yet the 'Live sessions' page whose lede promises 'Every in-flight agent session' shows the empty state while the stream line says connected
208
+ where: site/app/js/app.js:1071-1136
209
+ fix: Union the card list: append tally entries with recent activity (now - t.last < STALE_AFTER_MS) that are not matched by an active chat, rendered as an 'external' card (kit SessionCard gains an external/readOnly variant: no stop button - we own no process - open-in-history action instead). Order after agentgui-owned sessions
210
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. The SSE handler (lines 366-377) increments state.live.tally for EVERY data.sid on the ccsniff stream - ccsniff watches ~/.claude/projects JSONL, so a claude CLI run in a terminal ticks the tally and triggers debouncedRefreshHistory (line 388, conversat
211
+
212
+ 42. [medium | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Dashboard counters can move BACKWARDS: sess.events is incremented live from SSE (app.js:380) and then replaced wholesale when debouncedRefreshHistory refetches the index, which lags JSONL flush - a card showing '34 ev' snaps back to '30 ev' after the refresh; liveMain also prefers sess over tally (app.js:1085-1087) even when the tally is larger
213
+ where: site/app/js/app.js:378-391, 1085-1088
214
+ fix: In liveMain take Math.max(sess.events, t.events) (same for tools/errors, and Math.max for lastTs); after refreshHistory replaces sessions, re-apply any tally surplus per sid so the index lag never reads as regression
215
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. (1) SSE handler at lines 378-383 increments sess.events/tools/errors in-place on the row object held in state.sessionsBySid. (2) refreshHistory at lines 2396-2401 does `state.sessions = await B.listSessions(...)` then `state.sessionsBySid = new Map(...
216
+
217
+ 43. [medium | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Cumulative session cost over-counts on edit/retry: confirmEditAndResend truncates messages and clears resumeSid (app.js:1637-1639) but state.chat.totalCost keeps the discarded turns' spend, and every retried turn ADDS its cost on top (app.js:1707) - the ContextPane 'session cost' permanently includes turns that no longer exist in the transcript, persisted across reload via agentgui.chat
218
+ where: site/app/js/app.js:1633-1644, 1704-1707
219
+ fix: Store costUsd per assistant message (on the result event, attach to cur) and derive totalCost as the sum over current messages; truncation then self-corrects. If keeping the running scalar, at minimum label it 'total spend incl. discarded turns' - but the per-message sum is the honest fix and feeds ContextPane unchanged
220
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. confirmEditAndResend (lines 1633-1644) does `state.chat.messages = state.chat.messages.slice(0, ce.idx)` and clears resumeSid, but never touches state.chat.totalCost; the result handler (lines 1704-1707) only ever adds (`state.chat.totalCost = (state.c
221
+
222
+ 44. [medium | kit+app+server | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Remote cancellation reads as a normal completion: chat.cancel kills the proc and deletes activeChats without broadcasting (ws-handlers-util.js:151-160); the runner's exit surfaces as streaming_complete or a generic error, so when another browser tab (or the dashboard stop-all) stops a turn, the streaming tab's transcript just... ends mid-thought with no 'stopped' marker - truncated output indistinguishable from a finished answer
223
+ where: lib/ws-handlers-util.js:151-160 + site/app/js/app.js sendChat finally:1714-1720
224
+ fix: chat.cancel broadcasts {type:'streaming_error', error:'cancelled', cancelled:true} (or a dedicated streaming_cancelled) scoped to the sessionId BEFORE killing; backend.js maps it to {type:'cancelled'}; app marks the message with a plain 'stopped' note (kit ChatMessage already renders error banners - add a neutral 'stopped' tone, not error red)
225
+ verdict: Verified in shipped source. lib/ws-handlers-util.js:151-160: chat.cancel sets ctrl.aborted, kills the proc, deletes activeChats, and returns {cancelled:true} to the CALLER only - it broadcasts nothing. The fire-and-forget runner loop (lines 124-135) then resolves and broadcasts streaming_complete (l
226
+
227
+ 45. [medium | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] The 'activity' sort is a presence check, not a recency sort: (a.lastActivity?0:1)-(b.lastActivity?0:1) (app.js:1129) compares whether the FORMATTED string is non-empty, so once most cards have any activity the sort is a no-op and the order under 'activity' is whatever the input order was - unpredictable and disagrees with the control's label
228
+ where: site/app/js/app.js:1127-1131
229
+ fix: Carry the raw lastTs onto the session object (it is computed at app.js:1088 then discarded) and sort by b.lastTs - a.lastTs; same pattern fixes 'errors' sort to use the error count as a tiebreak
230
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Line 1129 in the shipped source is exactly `if (sortKey === 'activity') return (a.lastActivity ? 0 : 1) - (b.lastActivity ? 0 : 1);` where `lastActivity` is the formatted relative-time string built at line 1113 (`lastTs ? fmtRelTime(lastTs) : ''`). So
231
+
232
+ 46. [medium | kit+app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] While the same session streams on the chat tab, the history/live truth sources are frozen with no staleness indication: the SSE stream only opens on history/live tabs (navTo wantsStream, app.js:244-250), so on the chat tab the conversation rail's event counts, 'Ns ago' times, and the tally all freeze; returning to live shows up-to-45s-old lastTs that can momentarily mis-derive 'stale' for the very session that just streamed in chat - and nothing marks the rail data as paused
233
+ where: site/app/js/app.js:235-258, 340-419
234
+ fix: Keep the SSE stream open on the chat tab too whenever a chat is busy or sessions are in state.active (the rail lives on chat now, it deserves live data), or on re-entry to live seed every card's lastTs to max(lastTs, in-page last stream event time) before deriving stale; cheapest honest fallback: a 'paused - live updates resume on the Live tab' caption on the rail via the kit ConversationList caption slot
235
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. navTo (lines 244-250) opens the SSE stream only for tab==='history'||'live' and closes it when leaving both, so on the chat tab the only feeds into the rail's per-session counters and times are dead: sess.events/sess.last/tools/errors and state.live.ta
236
+
237
+ 47. [low | app | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] state.live.tally Map grows unbounded and keeps dead-session entries forever: entries are only ever added (app.js:371-376), never pruned on session end, stream close, or refreshHistory - a long-lived GUI tab accumulates every sid ever seen, and stale tally rows can resurrect wrong 'last' values if a sid is reused for an external-card union (gap above)
238
+ where: site/app/js/app.js:366-377, closeLiveStream:414-419
239
+ fix: Prune in debouncedRefreshHistory: drop tally entries whose sid exists in sessionsBySid (the index now owns them) or whose last is older than 10 minutes; cap the Map at ~200 entries LRU
240
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. The tally Map is only ever written at lines 371-376 (`state.live.tally.set(data.sid, t)` with entries created on first event per sid); grep over the whole file shows no `delete`, `clear`, or size cap anywhere — the only other references are reads in li
241
+
242
+ 48. [low | app+server | PREDICTABILITY - REALTIME TRUTH + STATE SYNC] Up to ~3.6s of running-panel vs dashboard vs reality skew is silent: refreshActive polls chat.active at 3s+jitter (app.js:314) while completions arrive instantly over the WS stream - a finished/stopped session lingers as a running card (stop button armed against a dead pid) and a just-started one is absent while its tally already ticks; nothing reconciles the WS streaming_complete the chat tab ALREADY receives into state.active
243
+ where: site/app/js/app.js:301-318 + sendChat finally:1714
244
+ fix: Call refreshActive() immediately from sendChat's start and finally blocks (the cheap fix), and have stopActiveChat optimistically remove the sid from state.active before the poll confirms; long-term, broadcast streaming_start/complete unscoped-to-subscription so every client updates active state push-driven instead of poll-driven
245
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js: refreshActive polls listActiveChats on setInterval(3000 + jitter up to 600ms) at lines 301-314 and is the sole writer of state.active; sendChat (1651-1721) sets busy/renders at start and in its finally block (1714-1720) but never calls refreshActive, s
246
+
247
+ 49. [high | kit | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Enter during IME composition sends the message mid-CJK-composition
248
+ where: c:\dev\anentrypoint-design\src\components\chat.js:387 (ChatComposer onkeydown)
249
+ fix: Guard the send branch: `if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { ... }`. Same guard belongs in content.js:324 SearchInput onSubmit and files-modals PromptDialog Enter handlers. Zero `isComposing` hits exist anywhere in the kit today, so every Enter-to-submit surface fires on the composition-commit keystroke for CJK users.
250
+ verdict: Verified against the real kit source. c:\dev\anentrypoint-design\src\components\chat.js:387 ChatComposer keydown is exactly `if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }` with no composition guard; content.js:324 SearchInput (`if (e.key === 'Enter') onSubmit(...)`), files-mo
251
+
252
+ 50. [high | kit+app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Stop button advertises 'stop generating (Esc)' but Escape never stops generation anywhere
253
+ where: c:\dev\anentrypoint-design\src\components\chat.js:395 (title) + c:\dev\agentgui\site\app\js\app.js:2720-2747 (global keydown has no busy/Escape branch; while typing Esc only blurs at line 2725)
254
+ fix: Kit: in ChatComposer onkeydown add `if (e.key === 'Escape' && busy && onCancel) { e.preventDefault(); onCancel(e); return; }` (before the blur falls through). App: in the global keydown, when not typing and `state.chat.busy && state.tab === 'chat'`, Escape calls stopChat() and announce('generation stopped'); add 'Esc - stop generation (while streaming)' to the SHORTCUTS array (app.js:424) so the ?-overlay and settings keyboard panel stay truthful.
255
+ verdict: Verified against source. Kit c:\dev\anentrypoint-design\src\components\chat.js:395 renders the cancel button with title 'stop generating (Esc)', but the composer textarea onkeydown (lines 386-389) handles only Enter and Ctrl+; — no Escape branch. App c:\dev\agentgui\site\app\js\app.js global keydown
256
+
257
+ 51. [high | kit+app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Dropping a file onto the chat/composer navigates the browser away (default drop), destroying the live session view
258
+ where: absent - no global dragover/drop preventDefault in site/app/index.html or app.js; only files.js DropZone (line 270) prevents default on the Files tab
259
+ fix: App: install window-level `dragover`/`drop` listeners that preventDefault when the target is not inside a `.ds-dropzone`. Kit: give ChatComposer an optional `onDropFiles(files)` prop with a `.chat-composer.dragover` ring (token border-accent, not hex); app wires it to insert the dropped file's confined path into the draft (or routes to the existing PUT /api/upload-file when a cwd is set, reusing UploadProgress). Keyboard path remains the existing attach button.
260
+ verdict: Verified against shipped source. Grep of C:\dev\agentgui\site\app\index.html and site\app\js\app.js shows NO window/document-level dragover/drop listeners — the only preventDefault on drag events lives in the kit DropZone (C:\dev\anentrypoint-design\src\components\files.js:268-270, ondragover/ondrop
261
+
262
+ 52. [medium | kit+app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Pasting an image into the composer is silently dropped - no feedback, no attachment path
263
+ where: absent - no onpaste handler in chat.js ChatComposer or app.js (grep 'paste|clipboard' shows only copy helpers)
264
+ fix: Kit: ChatComposer gains `onPasteFiles(files)` - in an onpaste handler, when `e.clipboardData.files.length` and no text, preventDefault and call it. App: wire it to the confined PUT /api/upload-file (cwd-targeted, 50MB cap already enforced server-side) and append the saved path to the draft; when no cwd/upload target, announce('images cannot be attached here yet') via the aria-live region instead of silence.
265
+ verdict: Verified against source. Kit C:\dev\anentrypoint-design\src\components\chat.js line 336: ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder, disabled, context }) — no onPasteFiles prop and zero 'paste'/'clipboardData' handlers anywhere in src (only a freddi
266
+
267
+ 53. [medium | kit+app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Enter-to-send vs Shift+Enter-for-newline is never communicated anywhere
268
+ where: c:\dev\anentrypoint-design\src\components\chat.js:384-396 (only the send button title says '(Enter)') + app.js:424 SHORTCUTS omits it
269
+ fix: Kit: ChatComposer renders a `.chat-composer-hint` line ('Enter to send · Shift+Enter for a new line' - middot is kept typography) that appears once the draft contains content or on focus, muted token color, hidden under 420px to save rows. App: add `{ keys: 'Enter / Shift+Enter', desc: 'send / new line (in the composer)' }` to SHORTCUTS so the ?-overlay and settings keyboard panel carry it.
270
+ verdict: Verified in source. Kit c:\dev\anentrypoint-design\src\components\chat.js:386-387 has `if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }` so Enter sends and Shift+Enter inserts a newline, but the ONLY surface of this is the send-button tooltip at line 396 (`title: 'send message (
271
+
272
+ 54. [medium | app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Escape has no semantics outside text fields: it does not close the ?-overlay, disarm confirmingEdit, or disarm confirmingStopAll/Selected
273
+ where: c:\dev\agentgui\site\app\js\app.js:2720-2747 - when not typing there is no Escape branch at all; the ?-Alert (line 484), chat.confirmingEdit banner, and live confirmingStop* arms only reset via click or the 4s timer
274
+ fix: Add a non-typing Escape branch with a priority ladder: showShortcuts -> false; state.chat.confirmingEdit -> null; state.live.confirmingStopAll/Selected -> false; open ws drawer already handled by kit shell.js:319. render() + announce what was dismissed. One ladder keeps Escape predictable across the whole shell (modals/drawers already kit-handled).
275
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. The global keydown handler (lines 2720-2747) handles Escape only inside the typing branch (line 2725, `t.blur()`); when not typing there is no Escape case at all — only g-chords, n, /, and ?. All three cited transient states exist and are only reset by
276
+
277
+ 55. [low | app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Search result count is rendered but not announced from the sessions-column search (only the history actions row is aria-live)
278
+ where: c:\dev\agentgui\site\app\js\app.js:2080 ('matches · N' plain text) vs line 1808 (history actions has role=status aria-live)
279
+ fix: When a search settles (state.searchBusy -> false), call the existing announce() helper with `(results.length || 'no') + ' matches for ' + q` so the sessions-column path (used on the chat tab too, per line 576) is announced, not just visually rendered. The middot in the visual count is kept typography - do not change it.
280
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. The match count renders only as the Panel title at line 2080 ('matches · ' + results.length), which is not a live region. The only aria-live near search is the history-actions row (line 1808) in the events column and the transient 'searching…' role=sta
281
+
282
+ 56. [medium | kit+app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Selecting text inside the actively-streaming assistant bubble is wiped every animation frame by the coalesced re-render
283
+ where: c:\dev\agentgui\site\app\js\app.js scheduleStreamRender + kit chat.js streaming text path (mid-stream `.chat-md`/preShell re-render replaces text nodes)
284
+ fix: App: in scheduleStreamRender, skip the rAF render pass when `document.getSelection()` is non-collapsed and its anchorNode lives inside `.chat-thread` (resume on selectionchange-to-collapsed or stream settle). Settled keyed messages are already diff-stable, so only the live bubble needs the guard. Kit: no structural change required, but document the contract on makeThreadAutoScroll so autoscroll also pauses while a selection is active.
285
+ verdict: Verified in shipped source. (1) C:\dev\agentgui\site\app\js\app.js lines 104-113: scheduleStreamRender() runs render() + scrollChatToBottom() on every rAF tick while text/tool/tool_result events stream (wired at lines 1686-1688), and scrollChatToBottom (lines 160-173) unconditionally sets `scrollTop
286
+
287
+ 57. [medium | app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] Modifier-key early-return blocks any app shortcut from ever using Ctrl/Cmd (no focus-composer-from-anywhere chord) and '/' is dead on files/live/settings
288
+ where: c:\dev\agentgui\site\app\js\app.js:2723 (`if (e.metaKey || e.ctrlKey || e.altKey) return;`) + 2739-2744 ('/' only history/chat)
289
+ fix: Before the modifier early-return, handle one explicit chord: Ctrl/Cmd+Shift+L (or Mod+K to the command-palette pattern) -> navTo('chat') + focusComposer() + announce, working even while typing in another field. Extend '/' on the files tab to focus the file filter input (it exists - FileGrid filter), leaving live/settings as documented no-ops. Add both rows to SHORTCUTS so overlay/settings panel stay the single source.
290
+ verdict: Verified against C:\dev\agentgui\site\app\js\app.js. Line 2723 is exactly `if (e.metaKey || e.ctrlKey || e.altKey) return;` placed before any chord handling, so no Ctrl/Cmd shortcut can ever fire, and the `typing` early-return (2724-2727) means no shortcut works while a field is focused — there is n
291
+
292
+ 58. [low | kit+app | LOGIC - input ergonomics + composer depth (benchmark: claude.ai/code / Claude Desktop composer)] cwd field validates only on save, with no inline feedback while typing/blur - an invalid path reads as accepted until the save click fails
293
+ where: c:\dev\agentgui\site\app\js\app.js CwdBar wiring (kit agent-chat.js:102 CwdBar has no error/hint prop; /api/stat probe runs inside onCwdSave only)
294
+ fix: Kit: CwdBar accepts `error`/`checking` props rendering a plain-language line under the input ('checking…' / 'folder not found or outside allowed roots') with aria-describedby. App: debounce a /api/stat probe on draft change (400ms) or at minimum on blur, set the error prop, and keep save disabled while error/checking - the existing confined /api/stat endpoint already does the realpath confinement.
295
+ verdict: Verified in source: kit agent-chat.js:102 CwdBar takes only {cwd,editing,draft,onEdit,onSave,onCancel,onClear,onDraft} — no error/checking prop and no message line in the editing branch. App app.js: the /api/stat probe lives solely in onCwdSave (1418-1445); onCwdDraft (1449) stores the draft without
296
+
297
+ 59. [high | kit+app | predictability-scale-degenerate-states] Chat thread renders the ENTIRE transcript every frame - a 500-message conversation rebuilds 500 ChatMessage vnodes per rAF tick while streaming, with no windowing or 'show earlier' cap (history got eventsLimit=300+load-older; chat got nothing)
298
+ where: C:\dev\anentrypoint-design\src\components\agent-chat.js:154 (messages.map over all rows) + C:\dev\agentgui\site\app\js\app.js:105 scheduleStreamRender
299
+ fix: Add a `shownMessages`/`onShowEarlier` prop pair to AgentChat mirroring FileGrid's cap: default render the last MESSAGE_CAP=100 turns plus a keyed 'show N earlier turns' row at the top (preserves scroll anchor); app keeps state.chat.shownMessages and resets it on newChat/loadSession. MdNode per-bubble caching already makes the windowed diff cheap.
300
+ verdict: Verified in source. C:\dev\anentrypoint-design\src\components\agent-chat.js:154 is `const rows = messages.map((m, i) => {...})` with no slice, cap, or shownMessages/onShowEarlier prop anywhere in the file. C:\dev\agentgui\site\app\js\app.js:105 scheduleStreamRender coalesces to one render per rAF, b
301
+
302
+ 60. [high | app | predictability-scale-degenerate-states] Live dashboard 'sort: last activity' does not sort by activity - the comparator compares only PRESENCE of the lastActivity string ((a.lastActivity?0:1)-(b.lastActivity?0:1)), so with 20 sessions that all have activity the option is a labeled no-op
303
+ where: C:\dev\agentgui\site\app\js\app.js:1129
304
+ fix: Carry the numeric lastTs onto the mapped session object (it is already computed at app.js:1088) and sort `(b.lastTs||0)-(a.lastTs||0)` for the activity key; same for 'elapsed' use r.startedAt numerics instead of parseInt on the 's' string.
305
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. Line 1129 reads `if (sortKey === 'activity') return (a.lastActivity ? 0 : 1) - (b.lastActivity ? 0 : 1);` where `lastActivity` is the formatted relative-time STRING (`lastTs ? fmtRelTime(lastTs) : ''`, line 1113). Among sessions that all have activity
306
+
307
+ 61. [medium | app | predictability-scale-degenerate-states] Dashboard cards re-sort under the cursor with no stable tiebreaker: the 3s chat.active poll + SSE tally bumps can flip a session running->stale/error mid-click, and equal-rank cards have NO secondary comparator, so their order rides whatever order the server returns - a 'stop' click can land on the wrong card
308
+ where: C:\dev\agentgui\site\app\js\app.js:1127-1132 + sessions.js SessionDashboard grid (keyed by sid, so DOM moves with rank)
309
+ fix: (1) Append a deterministic tiebreaker `|| String(a.sid).localeCompare(String(b.sid))` to every sort branch; (2) freeze re-ranking while the pointer is inside the grid: keep state.live.frozenOrder (array of sids) set on pointerenter of .ds-dash-grid and cleared on pointerleave, and order sessions by it when present (status/word/disc still update in place).
310
+ verdict: Verified against C:\dev\agentgui\site\app\js\app.js:1127-1132 (liveMain). All four sort branches (elapsed/activity/errors/status-rank default) return only the primary comparison with NO secondary comparator — e.g. `return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);`. Equal-rank card
311
+
312
+ 62. [medium | app | predictability-scale-degenerate-states] persistChat() stringifies the FULL transcript (including every tool args/result payload) to localStorage on every stream settle and every session event - a long session with large tool outputs blows the ~5MB quota, lsSet's catch{} silently stops persisting, and a reload silently loses the conversation with no signal to the user; the JSON.stringify itself is main-thread jank at 500 messages
313
+ where: C:\dev\agentgui\site\app\js\app.js:1495-1506 (persistChat), app.js:128 lsSet swallow
314
+ fix: Cap the persisted payload: keep the last 100 messages and truncate each tool part's result/args to ~4KB with a `truncatedForStorage:true` marker (restored cards show '(result truncated in saved copy)'); detect lsSet failure once and push a dismissible banner 'chat too large to save locally - export it from settings' instead of silent loss; debounce mid-stream persists (only the ev.type==='session' and finally-block writes need to land).
315
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js. persistChat (lines 1495-1506) maps every message to {id,role,content,time,parts} with NO cap on message count and NO truncation of tool parts — parts carry the full structured tool args/result payloads (toolPart/applyToolResult store them untruncated p
316
+
317
+ 63. [medium | kit | predictability-scale-degenerate-states] A 100k-character single streaming message is rebuilt in full every frame: appendText coalesces into ONE md part, AgentChat downgrades it to text/preShell, and renderInline's regex (or the preShell <code> text node) re-processes the whole accumulated string per rAF - O(n^2) across the turn, visibly degrading exactly when output is largest
318
+ where: C:\dev\anentrypoint-design\src\components\agent-chat.js:181-191 + chat.js renderInline/PART_RENDERERS.text; C:\dev\agentgui\site\app\js\app.js:1228 appendText
319
+ fix: In AgentChat's mid-stream downgrade, when part.text.length exceeds STREAM_TAIL_THRESHOLD (~20k) render a preShell bubble containing a 'streaming - N KB so far' head line plus only the last ~4k chars (tail window); the settled turn already renders the full markdown once via MdNode. Keeps per-frame work O(tail), not O(turn).
320
+ verdict: Verified in shipped source. App: C:\dev\agentgui\site\app\js\app.js appendText (lines 1228-1232) coalesces every text delta into ONE trailing md part (`last.text += text`), and each delta calls scheduleStreamRender (line 1686). Kit: C:\dev\anentrypoint-design\src\components\agent-chat.js mid-stream
321
+
322
+ 64. [medium | app+server | predictability-scale-degenerate-states] loadSession fetches ALL events for a session with no limit - the render window is 300 but the fetch/parse/state is unbounded, so opening a monster session (the test corpus has 69k events) downloads and JSON-parses everything before anything renders, and every SSE event for the selected sid appends to state.events forever
323
+ where: C:\dev\agentgui\site\app\js\backend.js:145 (getSessionEvents, no limit param) + app.js:2455 loadSession
324
+ fix: Thread `?limit=&before=` through ccsniff's /v1/history/sessions/:sid/events (or slice server-side in the mounted router); backend.js getSessionEvents(base,sid,{limit:900}) fetches the most-recent window and the existing 'load N older' button fetches the prior page instead of merely widening the render slice. Cap in-memory state.events at ~5000 with the same older-page path.
325
+ verdict: Verified in shipped source. backend.js:144-148 getSessionEvents fetches /v1/history/sessions/:sid/events with no query params; app.js:2479 loadSession assigns the entire array to state.events; eventsLimit (app.js:37, reset at 2455) only slices the RENDER (app.js:1865 `filteredEvents.slice(-limit)`)
326
+
327
+ 65. [low | app | predictability-scale-degenerate-states] files 'show more' growth leaks across directories: state.files.shown is reset by the filter input but NOT by loadDir, so after expanding one large dir to 600 rows, every subsequently opened large directory also renders 600 rows - the predictable 200-row cap silently varies with navigation history
328
+ where: C:\dev\agentgui\site\app\js\app.js:654-677 (loadDir never touches f.shown; cf. :943 filter resets it)
329
+ fix: Set `state.files.shown = null` in loadDir's success path (alongside entries/segments assignment) so each directory starts at the FILE_GRID_CAP default.
330
+ verdict: Verified in C:\dev\agentgui\site\app\js\app.js: loadDir (lines 654-677) assigns path/segments/entries/roots but never resets state.files.shown; line 934 onShowMore sets state.files.shown=n persistently; line 933 passes shown:f.shown into FileGrid for whatever directory is current; only the filter ha
331
+
332
+ 66. [medium | kit | predictability-scale-degenerate-states] Offline boot is sticky-degraded for the whole session: markdown.js caches a FAILED loader promise forever (_ready resolves false and is never retried), and markdown-cache.js memoizes the escaped-fallback HTML by content hash - so when the network to jsdelivr returns, both new and previously-rendered messages stay plain-escaped until a full reload, with no copy telling the user why
333
+ where: C:\dev\anentrypoint-design\src\markdown.js:13-26 + markdown-cache.js renderMarkdownCached (caches fallback output)
334
+ fix: On loader failure set `_ready = null` (retry on next ensureReady, with a 30s backoff timestamp) and do NOT insert into _renderCache when `ok===false`; MdNode's mdSrc guard means recovered messages re-render naturally when their text is next diffed - clear el.dataset.mdSrc consumers by versioning the cache key with a `_degraded` flag. Add one console-free inline note via the existing chat banner channel ('rich text unavailable offline - showing plain text').
335
+ verdict: Verified in kit source. C:\dev\anentrypoint-design\src\markdown.js:12-26: `ensureReady()` does `if (_ready) return _ready;` and assigns `_ready` to a promise whose catch path resolves `false` — the failed promise is cached for the lifetime of the page; there is no retry, so once the jsdelivr import
336
+
337
+ 67. [low | kit | predictability-scale-degenerate-states] Long-string truncation is one-sided: ConversationList row titles (.ds-session-title) and SessionCard agent/model ellipsize in CSS with NO title= attribute, so an extremely long session title or agent name is unrecoverable on hover (SessionCard cwd and the cwd bar DO carry title=, proving the convention)
338
+ where: C:\dev\anentrypoint-design\src\components\sessions.js:39 (ds-session-title, no title attr) and :156 (ds-dash-agent)
339
+ fix: Add `title: s.title || s.project || s.sid` to the ds-session-main span and `title: s.agent` / `title: s.model` to the dash head spans; same one-liner for ds-session-sub's project segment.
340
+ verdict: Verified in kit source. C:\dev\anentrypoint-design\src\components\sessions.js:38-42 renders .ds-session-title and .ds-session-sub with NO title attribute, while chat.css:347-348 gives both `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` — so a long session title/project is truncated wi
341
+
342
+ ## Synthesized plan
343
+
344
+ # Build plan — adversarially-confirmed findings (45 confirmed, ~6 phases)
345
+
346
+ Note: the "live activity sort" finding appears 3x and the "files shown leaks across dirs" 2x in the input — deduped below.
347
+
348
+ ## Phase 0 — Server (lib/, independent of kit; do first, all parallelizable)
349
+ 1. **S1 (HIGH, highest-leverage single change)** `lib/ws-handlers-util.js:145` — add `claudeSessionId` (already on ctrl at :104) to the `chat.active` payload. Unblocks the entire live-dashboard join cluster (tally/bySid/resumeSid/currentTool/activeSid — 5 downstream app findings).
350
+ 2. **S2** `lib/ws-handlers-util.js:151-160` — `chat.cancel` broadcasts `streaming_cancelled` (scoped to sessionId) BEFORE kill, and suppresses the runner's trailing `streaming_complete` via `ctrl.aborted`.
351
+ 3. **S3** events paging: wrapper route before the mounted ccsniff router slicing `/v1/history/sessions/:sid/events?limit=&before=` server-side (ccsniff itself has no limit support).
352
+ 4. **S4 (optional hardening)** short per-session ring buffer re-emitting the terminal frame to re-subscribers; the client-side `chat.active` probe (Phase 3) is the load-bearing fix.
353
+
354
+ No confinement surface is touched; do NOT re-run validate-mutations for S1-S4, but run it anyway if http-handler.js is touched at all.
355
+
356
+ ## Phase 1 — Kit (c:\dev\anentrypoint-design), each file edited ONCE with all batched changes; files are mutually independent (parallelizable)
357
+
358
+ **files-modals.js (batch of 4):** `error` + `busy` props on ConfirmDialog/PromptDialog (error inside `.ds-modal-body`, role=alert; busy disables buttons AND Backdrop/Escape close); Backdrop records `document.activeElement` at mount and refocuses it in `_dsModalTeardown` (isConnected-guarded); IME guard (`!e.isComposing && e.keyCode !== 229`) on PromptDialog Enter.
359
+
360
+ **files.js (batch of 3):** UploadProgress per-item `actions` + `onDismiss` (replace/dismiss, 44px, aria-labels); `fmtFileSize` becomes canonical exported formatter (0 -> '0 B', em-dash = unknown only).
361
+
362
+ **chat.js (batch of 7):** ChatComposer IME guard at :387; Escape+busy -> onCancel branch; `onPasteFiles`; `onDropFiles` + `.dragover` ring; `.chat-composer-hint` ('Enter to send · Shift+Enter for a new line'); per-bit `composerContext` onClick (cwd bit is its own button, agent/model inert); import fmtFileSize from files.js, delete fmtBytes (keep re-export alias for components.js).
363
+
364
+ **agent-chat.js (batch of 4):** `shownMessages`/`onShowEarlier` cap (MESSAGE_CAP=100, KEYED show-earlier row — webjsx mixed-keyed crash rule); streaming tail window (>~20k chars -> preShell 'streaming - N KB so far' + last ~4k); CwdBar `error`/`checking` props with aria-describedby; ChatMessage `incomplete` notice + neutral (non-error) 'stopped' tone.
365
+
366
+ **sessions.js (batch of 6):** SessionCard `stopping` (disabled 'stopping…'), `title` heading prop, `external/readOnly` variant (no stop, open-in-history); SessionDashboard bulk 'stopping N…' disabled state; STREAM_WORD `lost` -> `offline` (+ `.is-lost` -> `.is-offline` in chat.css); shared `fmtDuration(ms)` export (s->m->h); `title=` attrs on `.ds-session-title`/`-sub`/`.ds-dash-activity`.
367
+
368
+ **content.js (batch of 2):** SearchInput IME guard at :324; Row `tooltip` prop (HTML title attr — needed because Row's `title` prop is text content; carrier for absolute event timestamps).
369
+
370
+ **app-shell.css (1 line, HIGH):** add `.ds-file-actions { opacity: 1; }` to the existing `@media (hover:none),(pointer:coarse)` block at :1490 — file CRUD is currently undiscoverable on tablets.
371
+
372
+ **markdown.js + markdown-cache.js:** on loader failure set `_ready = null` with 30s backoff; never `_renderCache.set` degraded output; surface 'rich text unavailable offline' via the chat banner channel.
373
+
374
+ ## Phase 2 — backend.js (depends on S1/S2/S3)
375
+ - streamChat 'open' branch: instead of only clearing the 12s grace timer, call `chat.active`; if this ephemeral sessionId is absent, `finish()` with an `incomplete` marker (fixes the hang-busy-forever WS-outage bug — HIGH).
376
+ - Map `streaming_cancelled` -> `{type:'cancelled'}`; treat as terminal, ignore subsequent complete for that sid.
377
+ - `getSessionEvents(base, sid, {limit, before})`.
378
+ - `uploadFile` overwrite arg already exists — no change.
379
+
380
+ ## Phase 3 — app.js wiring (depends on Phases 0-2; the four clusters below are independent of each other and parallelizable, but each cluster's items share code and must land as one pass)
381
+
382
+ **Cluster A — Files (one pass over fileDialog/runFileMutation/uploadFiles/loadDir):** thread `d.error`/`d.busy` into the kit dialogs (delete the behind-overlay sibling err); `closeFileDialog` no-op while busy + announce() detached-dialog errors; validation copy on unchanged/empty confirm; upload re-entry guard (append to shared queue, single drain loop, one loadDir/announce); 409 -> 'replace' (`B.uploadFile(...,true)`) + dismiss wiring; loadDir resets `filter=''`/`shown=null` when resolved path changes (preserve on in-place mutation refresh).
383
+
384
+ **Cluster B — Chat lifecycle (one pass; several items touch the same functions — sequence within the cluster):** `sendChat(text)` arg so chips never clobber the draft; clear `confirmingEdit` at top of sendChat/retryLastTurn; newChat refactor — extract `doClearChat()`, newChat only arms + 4s auto-reset, banner button calls doClearChat (HIGH: double-'n' data loss); `if (ctrl.signal.aborted) break` in sendChat's event loop (no resumeSid/cost/text after stop); per-message `costUsd`, derive totalCost as sum over visible messages (composes with truncation); **persistChat rewrite as ONE change** (these two findings conflict if done separately): per-conversation key `agentgui.chat.<sid>` + sessionStorage pointer + 'storage' listener banner, AND cap last 100 messages + 4KB tool-part truncation + quota-failure banner + update the settings clear/size/export loops to the namespaced scheme; selection guard in scheduleStreamRender (`.agentchat-thread`, not `.chat-thread`; pause autoscroll too; same guard on the error-branch render); idle status 'ready' + 'continues session <sid8>' composerContext bit, drop '--resume' copy; cwd: debounced /api/stat probe -> CwdBar error/checking; ContextPane `onSetCwd` opens the inline editor (clear cwdError too); window-level dragover/drop preventDefault + onDropFiles/onPasteFiles wiring; `state.chat.shownMessages` window reset on newChat/loadSession; handle `{type:'cancelled'}` -> 'stopped' note; handle `incomplete` marker + retry affordance; loadSession uses paged fetch + 'load older' fetches prior page + cap state.events.
385
+
386
+ **Cluster C — Live/realtime (one pass over openLiveStream + liveMain; these ~10 findings ALL touch the same derivation block — do as a single rewrite, not piecemeal):** `realSid = r.claudeSessionId || r.sessionId` for all joins, keep ephemeral id for cancel, add claudeSessionId to activeSig, dedupe owned-vs-tally for external cards; carry raw `lastTs`/`errors` onto the card object — activity sort by epoch, errors sort by count, sid localeCompare tiebreaker on every branch, pointerenter frozen-order; status from recency (`t.lastErrorTs`, error only when recent/last) not cumulative; per-sid `t.toolRunning` from tool_use/tool_result so long tools never read stale; tally dedup by `t.maxI`, reconnect 'hello' re-baseline (drop tally + refreshHistory), eventCount counts only `kind==='event'`; `Math.max(sess.x, t.x)` monotonic counters + carry-forward on refreshHistory map rebuild; external-session cards from recent tally entries (kit external variant); `state.live.stopping` Set + per-sid re-entry guard + bulk `Promise.allSettled` -> 'stopped N of M' + clear selection only for stopped sids; tally prune (in-index or >10min, LRU ~200); refreshActive() at sendChat start/finally; keep SSE open on chat tab while busy/active; fmtRelTime clamp ('just now') + one-shot clock-skew offset; adopt kit fmtDuration everywhere (and switch the elapsed sort to raw ms); pass `title` to SessionCard via the same projectLabel expression the rails use; streamState 'lost' -> 'offline'.
387
+
388
+ **Cluster D — Copy/IA/keyboard (independent, low-risk, parallelizable):** projectLabel on search-hit rows (:2052); event rows fmtRelTime + absolute in Row tooltip; counter vocab (spelled in SessionMeta incl. tools/errors, one abbrev triple in rows); em-dash harmonization in the 3 app copy strings; settings reorder (backend, server, agents, appearance, keyboard, data) + page lede + drop duplicated version line; crumb leaf 'settings' + Files trail from path parents; runningPanel 'view all in live' + per-row open (keyed header child, not in title string); '/' on files/live + Mod+Shift+L chord before the modifier early-return + Enter/Shift+Enter + Esc rows in SHORTCUTS; non-typing Escape ladder (showShortcuts -> confirmingEdit -> confirmingStop*) + announce; search-settle announce(); copy-toast clearTimeout; backend confirm stores the confirmed draft value, cleared on input.
389
+
390
+ ## Phase 4 — Single build/ship step (strictly last, serial)
391
+ `node scripts/build.mjs` in the kit -> copy `dist/247420.{js,css}` into `site/app/vendor/anentrypoint-design/` -> **verify the app.js `C` destructure lists every new kit symbol** (FilePreviewPane/Icon lesson) -> `bun server.js`, witness localhost:3000/gm/ with 0 console errors across all 5 tabs (specifically: dialog error visible inside modal, stop shows 'stopping…', activity sort reorders, IME/Escape/drop behaviors, 420px) -> run `scripts/validate-mutations.mjs` -> kit `lint-tokens`/`lint-glyphs` -> push kit (rebase + rebuild if remote advanced), commit agentgui.
392
+
393
+ ## Conflicts & batching callouts
394
+ - **Conflict (must merge):** the two persistChat findings (namespacing vs cap/quota) rewrite the same function — one combined change.
395
+ - **Conflict (must merge):** ~10 live findings share liveMain's derivation block (realSid, sorts, status recency, toolRunning, monotonic counters, external cards, skew) — one rewrite, or later patches will clobber earlier ones.
396
+ - **Same-function batch:** newChat refactor + Escape ladder + SHORTCUTS edits all touch the global keydown/confirm states — Cluster B/D coordination.
397
+ - **Dedup:** activity-sort (3x) and files-shown-leak (2x) are single fixes.
398
+
399
+ ## Highest-leverage few
400
+ 1. **S1 + Cluster C realSid join** — repairs the entire live dashboard truth model (counters, stale, error, currentTool, accent) in one server line + one app pass.
401
+ 2. **backend.js WS-outage probe** — eliminates the busy-forever hang (worst predictability bug).
402
+ 3. **files-modals error/busy + focus restore** — every mutation error is currently invisible; one kit file.
403
+ 4. **app-shell.css coarse-pointer line** — one line restores all file CRUD on tablets.
404
+ 5. **persistChat rewrite** — stops cross-tab resumeSid contamination + silent quota data loss.
405
+ 6. **newChat two-step fix + IME guard** — both are user-visible data-loss bugs with tiny fixes.
package/lib/broadcast.js CHANGED
@@ -5,6 +5,7 @@ export const BROADCAST_TYPES = new Set([
5
5
  'script_started', 'script_stopped', 'script_output',
6
6
  'model_download_progress', 'stt_progress', 'tts_setup_progress', 'voice_list',
7
7
  'streaming_start', 'streaming_progress', 'streaming_complete', 'streaming_error',
8
+ 'streaming_cancelled', 'chat_active_changed',
8
9
  'tool_install_started', 'tool_install_progress', 'tool_install_complete', 'tool_install_failed',
9
10
  'tool_update_progress', 'tool_update_complete', 'tool_update_failed',
10
11
  'tool_status_update', 'tool_update_available',