Skip to content

perf+polish: chat re-render isolation, unified chat/file/diff headers#1245

Merged
bra1nDump merged 33 commits into
slopus:mainfrom
Scoteezy:feat/app-polish
May 15, 2026
Merged

perf+polish: chat re-render isolation, unified chat/file/diff headers#1245
bra1nDump merged 33 commits into
slopus:mainfrom
Scoteezy:feat/app-polish

Conversation

@Scoteezy
Copy link
Copy Markdown
Contributor

@Scoteezy Scoteezy commented May 8, 2026

Summary

App + CLI follow-up to #1205 — perf work to kill typing-induced re-renders across the chat and new-session screens, header consolidation so the file viewer / diff viewer publish their controls into the chat header instead of stacking a second top bar, small polish on the file overlay + files sidebar, plus a batch of bugs that surfaced once the perf changes landed: reconnect was replaying chat history as new user messages, permission prompts could orphan the chat after a process restart, drag-and-drop file attachments never worked, and Skill/local slash commands were leaking SDK internals (raw <command-name> tags, 17k-character skill prompt bodies) into the chat.

Changes (happy-app)

Tool call grouping

  • Consecutive tool calls collapse into a single container. When an agent runs many tool calls between text responses (common with background agents), consecutive tool calls are now grouped into a collapsible summary row showing e.g. "Edited 4 files, ran 2 commands". Tap/click to expand and see individual tool cards inside. Groups with running tools default to expanded and auto-collapse when all tools complete. User-sent file/image attachments are never grouped. Agent events (mode switches, "aborted by user") stay visible as standalone messages. Hidden tools (ToolSearch, thinking) are excluded entirely. Summary text auto-categorizes tools into edit/read/terminal/search/web/task buckets. i18n support for all 10 languages.

Performance

  • Chat input no longer re-renders the whole session view. Lifted the live message + useDraft autosave into a new ChatComposer subcomponent and exposed an imperative handle for send/clear. SessionViewLoaded keeps a stable tree on every keystroke. MultiTextInput is React.memo so the JSX diff is skipped when the parent rerenders without changed props. AgentInput was already memoized but every render passed it freshly-allocated connectionStatus / usageData / onSend / onAbort / onFileViewerPress / autocompleteSuggestions / autocompletePrefixes — memo always saw a "new" prop set. Now: autocompletePrefixes hoisted to module scope, useMemo for the primitive-derived objects, useCallback for the handlers reading the live message + selectedImages through refs so the callbacks don't change on every keystroke. Net effect: typing in chat re-renders only ChatComposer → AgentInput → MultiTextInput. SessionViewLoaded, ChatList, ChatHeaderView, and the file/diff sidebars stay stable.
  • Uncontrolled textarea on top of the memo pass. Even with the prop pipeline cleaned, typing felt chunky on a busy main thread because every keystroke round-tripped through React state before reaching the DOM. MultiTextInput (both native and web variants) now accepts defaultValue and reads the live text through an imperative getText() handle; React state updates derived from typing wrap in React.startTransition. Deletion no longer batches into 3–4 character chunks; fast typists see each character at native input latency.
  • Zen-mode toggle on web (~15fps → snap). The right diff-sidebar's width animation went through Reanimated withTiming(250) which on web becomes a JS-driven layout animation that re-flows the chat flex-row sibling on every frame, forcing the FlatList tree to re-measure 60 times per toggle. The left navigation drawer used a CSS width transition with the same effect. Both now snap on web (duration 0 / no transitionProperty); native keeps the smooth animation. Chat reflows once instead of 60 times. Defense-in-depth contain: 'layout style paint' added on the chat flex-1 wrapper (web only) so the subtree stays layout-isolated even if a width animation comes back later.
  • New-session screen: typing no longer churns the whole config UI. The screen subscribed to the entire useNewSessionDraft zustand store, so each input flip re-ran useAllMachines(), useSessions(), the agent-input setting hook, and every memo-derived picker item. Now the parent narrows to a useShallow selector that excludes input, a separate primitive selector (s.input.trim().length > 0) feeds the auto-collapse effect, and MultiTextInput is wrapped in a memoized PromptInput child that owns the input subscription. handleSend reads the live prompt via useNewSessionDraft.getState() on demand instead of closing over it, so the callback no longer depends on input.
  • Sidebar session list: stop cloning data on every navigation. The sidebar rebuilt the full session-list data array (and renderItem callback) on every pathname change to bake in a selected flag. That defeated FlatList virtualization and forced every visible row to re-render on each nav. Selection is derived once via useMemo from pathname; renderItem computes selected per row inline; FlatList gets extraData so it re-evaluates correctly; SessionItem's existing React.memo ensures only the previously- and newly-selected rows actually re-render. Also removes a rules-of-hooks violation where useMemo was called inside a ternary (selectable ? useMemo(...) : data).
  • Chat list scroll no longer drifts during streaming. maintainVisibleContentPosition was set with minIndexForVisible: 0 and autoscrollToTopThreshold: 10. In the inverted FlatList, index 0 is the newest message — anchoring on it meant every agent token destabilised the anchor, dragging the user's viewport up while reading older messages. Plus a redundant onContentSizeChange was issuing its own scrollToOffset(0) that fought with MVCP. Now: minIndexForVisible: 1 anchors on the second-newest (stable across tokens), autoscrollToTopThreshold: 50 for the inverted-list stick-to-bottom behaviour, and the manual scroll callback is gone. Viewport stays put when scrolled up; sticks to the bottom only when the user is already near it.
  • MessageView memoised. Previously a plain function — every parent re-render walked every visible message item. Now React.memo; combined with the stable renderItem callback in ChatList, only items whose props actually changed re-render. Also scoped renderToHardwareTextureAndroid to native (no-op on web, was set unconditionally).
  • setShowScrollButton no longer fires on every scroll frame. onScroll runs at 60Hz; the previous code called the setter on every frame even when the value didn't change, re-rendering the whole ChatListInternal. Guarded with a ref so the setter only fires on threshold crossings.
  • Changes page no longer flashes a global spinner on every agent message. Two-level fix. (1) applyGitStatusFiles in storage does a structural-equality short-circuit (via fast-deep-equal): when gitStatusSync.invalidate() fires for every mutable-tool message but the file list is identical, the store doesn't update and subscribers don't re-render. (2) AllFilesDiffView previously did reset+spinner+refetch on every change; now it keyed-reconciles via a Map<fullPath, FileDiffResult> — only files whose signature (status + isStaged + linesAdded + linesRemoved) actually changed get re-fetched, the rest stay rendered. Scroll position inside the diff overlay survives streaming; the global spinner appears only on the very first mount.

Slash commands + skills UX

  • Strip Claude SDK local-command XML out of the chat. When the user runs /foo, the SDK injects synthetic "user" messages whose content is raw tags like <local-command-caveat>…</local-command-caveat> and <command-message>foo</command-message><command-name>/foo</command-name>. MarkdownView rendered them as literal HTML text. A new parseLocalCommandMessage() helper runs every user-text payload through three branches before the bubble renders: caveat-only payloads hide entirely; command-message + command-name payloads collapse into a compact monospace chip showing /foo; mixed content keeps the surrounding text and drops the tags.
  • Autocomplete dropdown reaches everything. Slash-command and file-mention lookups were both capped at limit: 5, so even when the metadata exposed 80+ commands the user only ever saw five. Arrow-key navigation also walked selectedIndex past the visible window without scrolling the inner ScrollView, so anything past row 5 was unreachable by keyboard. Bumped both limits to 50, replaced FloatingOverlay in AgentInputAutocomplete with an inline ScrollView whose ref drives scroll-into-view on selectedIndex change (reads the underlying div's scroll offsets on web, falls back to scrollTo on native), and raised maxHeight from 240→320 so ~8 rows are visible at once.

Image attachments

  • Drag-and-drop now works on web. pasteImages.web.ts already exposed getImagesFromDrop() but nothing ever called it — only the paste path was wired in AgentInput. The composer now attaches dragover (with preventDefault, required for drop to fire) and drop listeners alongside the existing paste effect. Both gate on dataTransfer.types.includes('Files') so non-file drags elsewhere in the app keep working. Drop events run files through getImagesFromDropfileToAttachmentPreview and forward the result through the same props.onAddImages callback the paste path uses.

Header consolidation + browser-style back/forward

  • AllFilesDiffView and FileViewPanel no longer render their own top bars; they publish right-slot controls into ChatHeaderView, which gains optional extraPathSegment + rightSlot props. Diff overlay contributes file count + Unified|Split toggle; file view contributes Edit|Preview + Save (Save disabled until changes are detected, same gating as before).
  • The close X is gone. Overlay open/close + diff file traversal is driven by a browser-style intra-session history stack bridged to the global SidebarNavigator back/forward arrows via a new zustand store (sessionOverlayNav). canBack / canForward fall through to the router when no overlay history is present, so navigation feels uniform whether you're flipping between sessions or between files within a session.
  • Scroll-to-file in the diff overlay now uses requestAnimationFrame + retry instead of a fixed 50ms timer, so back/forward navigation visibly scrolls between previously-selected files even when layout settles late.

Diff overlay reliability

  • Untracked-file content reads work on Windows. AllFilesDiffView was running cat -- "<path>" via sessionBash to read untracked files for the diff view. On Windows + PowerShell, cat is an alias for Get-Content that doesn't accept the -- separator, so .md / .ps1 / any new files surfaced as command failed: cat -- "..." errors instead of content. Swapped to the native sessionReadFile RPC (already used by FileViewPanel for the standalone file view) which reads via the CLI's Node fs — no shell, fully cross-platform, faster too. Base64 content decoded with atob + TextDecoder, same pattern as FileViewPanel.
  • Sidebar-click after silent diff updates lands on the correct file. With the new keyed reconciliation in AllFilesDiffView, files silently appear/disappear in the list without resetting the diff. But the per-section Y-offset cache (fileOffsets, populated by each section's onLayout) became stale when insertions pushed sections downward: clicking a file in the right sidebar took the user to where an old file used to be. Cache is now cleared on every results-list change; the existing RAF-retry tryScroll loop waits for fresh onLayout values before scrolling, so clicks always land on the section the file currently occupies.

File overlay + files sidebar polish

  • File overlay inherits the chat surface end-to-end. CodeEditor and the markdown preview no longer paint their own dark/light backgrounds, so the overlay no longer shows a nested darker box on top of the chat. The first pass went too far and made every layer transparent — letting the chat (and the empty-messages placeholder) bleed through behind the file — so the surface fill is back on the overlay wrapper while the editor + preview themselves stay transparent. Net result: same visual treatment as the chat, no nested box.
  • Files sidebar selection matches chat-list selection. Dropped the blue accent border on selected rows in the files sidebar; highlight is now the same surfaceSelected fill the left sessions sidebar uses for the active chat row.
  • Files sidebar: vertical scrollbar visible. Removed showsVerticalScrollIndicator={false} on both the changes and all-files scroll views — falls back to the platform default so users can see when there's more content below, matching the FlatList in the left sessions sidebar.
  • Active-sessions section header style. Match the inactive-session row style: folder icon + last path segment instead of the full home-relative path. Keeps the existing section-header text size and color.

Hygiene

  • Untrack the .supervibe/ artifacts that snuck in and add the directory to .gitignore so it stays local.

Changes (happy-cli)

Reconnect / lifecycle reliability

  • Don't replay JSONL history when the scanner learns the session id late. On happy-reconnect, metadata.claudeSessionId isn't loaded by the time createSessionScanner is built, so it inits with sessionId: null and pre-marks nothing. Seconds later the SessionStart:resume hook fires onNewSession with the resume id, the first sync.invalidate() walks the full JSONL, and every historical user message gets forwarded to the server — they all re-appear in the chat from the user's account. Added a treatExistingAsProcessed flag to onNewSession and pass it from the remote-mode hook handler; the offline-reconnect path in runClaude.ts and the local launcher keep the existing forward-everything semantics.
  • Clear orphaned permission requests on session resume. If the CLI process is killed while a tool prompt is open (browser close, daemon-stop, crash) the in-memory pendingRequests map dies but the server still holds agentState.requests[id]. The app keeps showing the spinner + "Permission required" banner forever, and clicking Yes/No is a no-op — the response RPC lands on a fresh CLI whose pendingRequests has never heard of the id, and agent state is never updated. Each backend (Claude remote, ACP, Codex) now calls permissionHandler.reset(reason) right after constructing the handler; the existing reset() already moves stale requests to completedRequests with status canceled, so the UI updates as soon as the new session attaches.

Skill prompt hiding

  • Drop SDK-injected synthetic user messages in the envelope mapper. When Claude invokes the Skill tool, the SDK feeds the skill's prompt body back into the conversation as a synthetic "user" message — context for the model, not chat content. Previously mapClaudeLogMessageToSessionEnvelopes iterated the content blocks and emitted each text chunk as an agent text envelope, producing 10–20k-character walls of raw skill instructions in the chat right after the user's request. The mapper now short-circuits when the message carries the meta flag.
  • Propagate SDK isSynthetic as isMeta in sdkToLogConverter. The SDK uses two different field names for the same signal: isSynthetic on the in-memory SDKUserMessage stream and isMeta only after the message is persisted to JSONL on disk. Our SDK pipeline runs against the in-memory shape, so isMeta was never set on the log message and the mapper's skip didn't fire. Forward either flag as isMeta when building the RawJSONLines payload so both the SDK pipeline and the on-disk JSONL scanner end up with a consistent signal.

Notes

  • App + CLI changes; no server or wire changes.
  • New translation keys added for toolGroup section (tool call grouping summaries) across all 10 languages.
  • sessionOverlayNav is a new zustand store local to sources/-session/; no schema or persistence implications.
  • The CLI fixes are all behavioral, no new RPCs or message types — existing fields (isMeta, agent-state requests / completedRequests) are used consistently across both pipelines.

Scoteezy and others added 12 commits May 7, 2026 14:17
Match the inactive-session row style: folder icon + last path
segment instead of the full home-relative path. Keep the existing
section-header text size and color.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
The sidebar rebuilt the entire session-list data array (and
renderItem callback) on every pathname change to bake in a
`selected` flag. That defeated FlatList virtualization and
forced every visible row to re-render on each navigation.

Now selection is derived once via useMemo from pathname; renderItem
computes `selected` per row inline, FlatList gets `extraData` so it
knows when to re-evaluate, and SessionItem's React.memo ensures only
the previously- and newly-selected rows actually re-render.

Also removes a rules-of-hooks violation where useMemo was called
inside a ternary (`selectable ? useMemo(...) : data`).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
AgentInput is wrapped in React.memo, but every render of SessionView
passed it freshly-allocated objects, arrays, and lambdas — connectionStatus,
onSend, onAbort, onFileViewerPress, autocompletePrefixes, autocompleteSuggestions,
usageData. Each keystroke recreated all of them, so memo always saw a "new"
prop set and re-rendered the entire input subtree.

Now:
- Hoist the autocomplete-prefix array to module scope.
- useMemo for connectionStatus / usageData with primitive deps.
- useCallback for onSend / onAbort / onFileViewerPress / autocompleteSuggestions,
  reading the live message + selectedImages through refs so the callbacks
  themselves do not change on every keystroke.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
The new-session screen subscribed to the entire useNewSessionDraft
zustand store via the unfiltered hook. Every keystroke flipped the
`input` field, which forced the parent to re-render — and with it
useAllMachines(), useSessions(), useSetting('agentInputEnterToSend'),
all the useMemo-derived picker items, and the rest of the config UI.

Now:
- The parent narrows its subscription to a useShallow selector that
  excludes `input`. Setters are stable refs, so shallow comparison is
  always satisfied during typing.
- A separate primitive selector (`s.input.trim().length > 0`) feeds
  the auto-collapse effect; it only flips once, so it doesn't churn.
- The MultiTextInput is wrapped in a memoized PromptInput child that
  owns the `input` subscription. Typing now only re-renders the input
  subtree.
- handleSend reads the live prompt via useNewSessionDraft.getState()
  on demand instead of closing over it, so the callback no longer has
  to depend on `input` in its useCallback deps.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Two related perf fixes for noticeable jank on the web build.

Zen-mode (was ~15fps on web):

- The right diff-sidebar's width animation went through Reanimated's
  withTiming(250ms). On web this became a JS-driven layout animation
  that re-flowed the chat flex-row sibling on every frame, forcing
  the FlatList tree to re-measure 60 times per toggle.
- The left navigation drawer used a CSS width transition with the
  same effect.

Both now snap on web (duration 0 / no transitionProperty); native
keeps the smooth animation. Chat reflows once instead of 60 times.

Also adds a defense-in-depth `contain: 'layout style paint'` on the
chat flex-1 wrapper (web only) so the subtree stays layout-isolated
from the row even if a width animation comes back later.

Chat-input typing:

- Lift the chat message state and useDraft autosave into a new
  ChatComposer subcomponent. SessionViewLoaded no longer re-renders
  on every keystroke — it only holds an imperative handle to read /
  clear the live message on send.
- Wrap MultiTextInput in React.memo so its JSX diff is skipped when
  the parent re-renders without changed props.

Net effect: typing in chat re-renders only ChatComposer → AgentInput
→ MultiTextInput. SessionViewLoaded, ChatList, ChatHeaderView, and
the file/diff sidebars stay stable.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
- AllFilesDiffView and FileViewPanel no longer render their own top bars;
  they publish right-slot controls (file count + Unified|Split toggle for
  diff; Edit|Preview + Save for file view) into ChatHeaderView, which gains
  optional extraPathSegment + rightSlot props.
- The close X is gone. Overlay open/close + diff file traversal is driven
  by a browser-style intra-session history stack bridged to the global
  SidebarNavigator back/forward arrows via a new zustand store
  (sessionOverlayNav). canBack/canForward fall through to router when no
  overlay history is present.
- Scroll-to-file in the diff overlay now uses rAF + retry instead of a
  fixed 50ms timer, so back/forward navigation visibly scrolls between
  previously selected files.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
- Drop the explicit surface background on the file-view overlay so it
  inherits the chat surface, matching the visual treatment of the chat.
- Remove the blue accent border on selected rows in the files sidebar;
  highlight is now the same surfaceSelected fill used by the chat list
  in the left sidebar.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
- CodeEditor and markdown preview no longer paint their own dark/light
  surfaces, so the file overlay inherits the chat surface end-to-end
  instead of showing a nested darker box.
- Untrack the .supervibe/ artifacts that snuck in and add it to
  .gitignore so it stays local.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
…through

Previous commit went too far — making every layer transparent let the
chat (and the empty-messages placeholder) show through behind the file.
Keep the editor + preview transparent so there's no nested box, but put
the surface fill back on the overlay wrapper.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
…able

Drop the showsVerticalScrollIndicator={false} on both the changes and
all-files scroll views. Falls back to the platform default — same as
the FlatList in the left sessions sidebar — so users can see when there
is more content below.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
# Conflicts:
#	packages/happy-app/sources/components/FilesSidebar.tsx
#	packages/happy-app/sources/components/SidebarNavigator.tsx
chphch added a commit to chphch/happy that referenced this pull request May 11, 2026
 perf commits

PR slopus#1245 was based on upstream main *after* slopus#1067 (image upload) merged, so
its handleSend + AgentInput integration referenced expImageUpload, useImagePicker,
selectedImages, and a SendMessageOptions.attachments field. None of that
infrastructure has been cherry-picked into chphch/happy:custom yet — image
upload work lives only as untracked files in the main worktree.

Drop the dangling references so the perf commits actually type-check on this
branch. Behavior reverts to text-only send, which is what custom did before
the cherry-picks anyway.
bra1nDump and others added 17 commits May 10, 2026 23:21
Links rendered in markdown and settings were not clickable in the Tauri
desktop app — window.open() and Linking.openURL() are no-ops in the
webview. Added tauri-plugin-opener and a shared openExternalUrl() utility
that routes through the plugin on Tauri, window.open on regular web, and
Linking.openURL on native.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Typing into the chat composer felt laggy and deletion came in chunks
instead of char-by-char. Two compounding causes:

  1. `MultiTextInput` was fully controlled — every keystroke round-tripped
     through React state and was force-applied back into the native /
     DOM input. When the JS thread was busy, the platform batched
     several queued key events into one DOM update, which is why fast
     backspace deleted in groups.
  2. `AgentInput`'s body re-ran ~1000 lines of JSX on every keystroke
     (because `props.value` flipped). Hot-path handlers like
     `handleKeyPress` / `handleSendPress` had `props.value` and the
     whole `props` object in their deps, so they recreated each
     keystroke and defeated `MultiTextInput`'s own React.memo.

Fix:

  * `MultiTextInput` (.tsx + .web.tsx) now supports both controlled
    (`value`) and uncontrolled (`defaultValue`) modes and exposes
    `getText()` on its imperative handle. Existing callers
    (machine, new-session, SearchableListSelector, dev pages) keep
    their controlled semantics.
  * `AgentInput` switches to uncontrolled: prop is `initialValue`,
    `hasText` / `inputState` are local state updated via
    `React.startTransition`, and live text is read via the input
    handle on key/send. Hot handlers no longer depend on `props`.
  * Extracted `AgentInputStatusRow` and `AgentInputContextChips` as
    module-scoped `React.memo` components so the connection-status
    block and machine/path chips skip reconciliation on the (now
    rare) `AgentInput` body re-renders.
  * `ChatComposer` (SessionView) holds a `MultiTextInputHandle` ref,
    seeds the textarea with the persisted draft synchronously, mirrors
    keystrokes into a low-priority `message` state for `useDraft`, and
    does get/clear imperatively on send.
  * `useDraft` seeds `lastSavedValue` with the initial value so a
    pre-hydrated draft doesn't trip an unnecessary autosave.

Result: keystrokes paint immediately, deletion is char-by-char, and
React reconciliation of the rest of the input runs at idle priority.
In remote mode the scanner serves only as a safety net for user prompts
typed in a parallel `claude --resume` terminal — every other source
(SDK pipeline + app channel) already delivers to the server before the
JSONL is written. But on happy-reconnect, metadata.claudeSessionId
isn't loaded by the time the scanner is constructed, so it inits with
sessionId=null and pre-marks nothing. Seconds later the SessionStart
hook fires onNewSession with the resume session id, and the first
sync.invalidate() forwards every historical user message to the
server, where the chat shows them all again from the user's account.

Add `treatExistingAsProcessed` to onNewSession and pass it from the
remote-mode hook handler. The offline-reconnect path in runClaude.ts
and the local launcher keep the existing forward-everything semantics.
The web composer already intercepts pasted images and routes them through
`props.onAddImages`. The drag-and-drop counterpart `getImagesFromDrop`
existed in pasteImages.web.ts but was never wired up, so dropping a file
onto the chat did nothing.

Attach `dragover` (with preventDefault, required for `drop` to fire) and
`drop` listeners alongside the paste effect. Both gates on
`dataTransfer.types.includes('Files')` so non-file drags elsewhere in the
app keep working. Drop events run files through `getImagesFromDrop` →
`fileToAttachmentPreview` and forward the result to `props.onAddImages`,
matching the paste path.
If the CLI process is killed while a tool prompt is open (e.g. the user
closes the browser tab or stops the session from the app), the in-memory
pendingRequests map dies with the process but the server still holds
`agentState.requests[id]`. The app keeps showing the spinner + the
"Permission required" banner forever, and clicking Yes/No is a no-op —
the response RPC lands on a fresh CLI whose pendingRequests has never
heard of the id, and agent state is never updated.

Call permissionHandler.reset(reason) right after constructing the
handler in each backend (Claude remote, ACP, Codex). reset() already
moves agentState.requests → completedRequests with status='canceled';
parametrize the reason so the audit trail names the actual cause
instead of the previous hardcoded "Session switched to local mode" /
"Session reset" string.
When a user runs a /foo slash command, the Claude Agent SDK injects
synthetic user messages whose content is raw XML-like tags:
  <local-command-caveat>…</local-command-caveat>
  <command-message>foo</command-message><command-name>/foo</command-name>

Rendered straight through MarkdownView they appear as literal HTML
text in the chat, which looks broken.

Add a parseLocalCommandMessage() helper and route every user-text
message through it before the bubble renders:
- caveat-only payloads are hidden entirely (the caveat is meta for
  Claude, not user-visible content)
- command-message + command-name payloads collapse into a compact
  monospace chip showing /foo
- mixed content keeps the surrounding text but drops the tags
Two things were truncating the slash-command / file-mention dropdown:
- `searchCommands` / `searchFiles` were both called with `limit: 5`, so
  even when 80+ commands were available the user only ever saw five.
- Arrow-key navigation incremented `selectedIndex` past the visible
  window without scrolling the ScrollView, so anything past the
  fifth item was unreachable by keyboard.

Bump both query limits to 50 and replace the FloatingOverlay wrapper
in AgentInputAutocomplete with an inline ScrollView whose ref we can
drive — on selectedIndex change we read the underlying div's scroll
offsets (web) or fall back to scrollTo (native) to keep the selected
row inside the visible bounds. Container max height bumps to 320 to
fit ~8 rows comfortably; beyond that the user just keeps arrowing.
When Claude invokes the Skill tool, the SDK feeds the skill's prompt
body back into the conversation as a synthetic 'user' message with
isMeta=true. That message is meant for the model, not the human — but
mapClaudeLogMessageToSessionEnvelopes was iterating its content blocks
and emitting each text chunk as an `agent` text envelope. The result
in the chat was a 17 000+ character wall of raw skill instructions
appearing right after the user's "give me an implementation plan"
prompt, with no indication of what produced it.

Drop isMeta=true user messages before the per-block fan-out. happy-app
already had a matching skip on its side for the agent-role output path
but it never fires here because the mapper rewrites the role into
'agent' envelopes well before that check runs.
…dden

The previous commit (1744ff0) skipped isMeta=true user messages in the
envelope mapper, but in practice the wall of text still landed in the
chat. Reason: the Claude Agent SDK uses two different field names for
the same "context injection" signal — `isSynthetic` on the in-memory
SDKUserMessage stream and `isMeta` only after the message is persisted
to JSONL on disk. Our SDK pipeline runs `sdkToLogConverter` against the
in-memory shape, so `isMeta` was never set on the log message and the
mapper saw a regular user message with array content.

Forward either flag as `isMeta` when building the RawJSONLines payload
so the mapper's existing skip fires for both paths (SDK pipeline and
the on-disk JSONL scanner).
…iability

Chat scroll
- Fix viewport drift while streaming: `maintainVisibleContentPosition`
  on the inverted FlatList used `minIndexForVisible: 0` and
  `autoscrollToTopThreshold: 10`. Anchoring on the newest slot (index 0)
  destabilised the anchor every agent token; the misnamed threshold
  combined with a redundant `onContentSizeChange` `scrollToOffset(0)`
  was a second auto-scroll fighting MVCP. Now `minIndexForVisible: 1`
  + `autoscrollToTopThreshold: 50` and no manual scroll callback.
  Viewport stays put when scrolled up; sticks to bottom only when the
  user is already near it.
- Memoise `MessageView` (was a plain function so every parent re-render
  walked every visible row). Scope `renderToHardwareTextureAndroid` to
  native (no-op on web).
- Guard `setShowScrollButton` with a ref so it only fires on threshold
  crossings instead of on every 60Hz scroll frame.

Changes page (AllFilesDiffView)
- `applyGitStatusFiles` short-circuits on structural-equality (via
  `fast-deep-equal`): `gitStatusSync.invalidate()` fires for every
  mutable-tool message, but when the file list is identical the store
  doesn't update and subscribers don't re-render.
- Replace bulk reset+spinner+refetch with a keyed `Map<fullPath,
  FileDiffResult>` reconciliation. Per-file signature
  (`status` + `isStaged` + `linesAdded` + `linesRemoved`) decides
  whether to re-fetch; otherwise carry the prior diff over. Global
  spinner only on the very first mount; scroll position inside the
  diff overlay survives streaming.
- Read untracked files via the native `sessionReadFile` RPC instead of
  `cat -- "<path>"`. On Windows + PowerShell, `cat` is aliased to
  `Get-Content` which doesn't accept `--`, so .md / .ps1 / any new
  files surfaced as `command failed: cat -- "..."` errors. Native RPC
  reads via the CLI's Node `fs` — no shell, cross-platform, faster.
- Clear `fileOffsets` Y-position cache on results-list change. With the
  new silent reconciliation, insertions shift sections below them
  downward; the cache became stale and right-sidebar clicks took the
  user to where an old file used to be. The existing RAF-retry
  tryScroll loop waits for fresh `onLayout` values before scrolling.
Consecutive tool calls between text messages are now grouped into a
single collapsible container showing a summary like "Edited 4 files,
ran 2 commands". Tap to expand and see individual tool cards.

- Groups with running tools default to expanded, auto-collapse on completion
- User-sent file/image attachments are never grouped
- Agent events (mode switches, aborted) stay visible as standalone items
- Hidden tools (ToolSearch, etc.) and thinking messages excluded from groups
- Summary auto-categorizes tools into edit/read/terminal/search/web/task
- i18n support for all 10 languages

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Adds a 'Group Tool Calls' switch under Settings -> Features ->
Interface, letting users disable the consecutive-tool-call grouping
introduced in 266c007.

- New groupToolCalls setting (defaults to true, preserving current
  grouped behavior)
- useGroupedMessages takes an enabled flag; when off, every message
  passes through standalone (pre-grouping behavior, MessageView still
  renders null for hidden tools/thinking)
- ChatList reads the setting reactively so toggling applies without
  re-entering the chat
- i18n for all 10 languages

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Slash commands with arguments rendered as a duplicate-looking raw
message: parseLocalCommandMessage only stripped <command-message>/
<command-name>, so a non-empty <command-args> tag survived and the
message fell through to plain text instead of collapsing to a chip
(and the 'command ran' chip never showed).

- parseLocalCommandMessage now strips/parses <command-args> too, so
  the command-run chip path triggers for commands with arguments
- command-run renders only the /command chip (no args echo)
- isUserSlashCommandEcho() hides the user's own optimistic slash
  command echo so it isn't shown twice; gated to Claude flavor only
  (Codex/Gemini don't reliably emit the <command-*> wrapper)
- parseLocalCommandMessage.spec.ts: 14 cases covering caveat,
  command-run, command-args, mixed text, and echo detection

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
bra1nDump and others added 4 commits May 15, 2026 02:48
Safari's default subpixel AA aliases badly under the 0.75 body zoom —
bold renders "chopped", regular text faint. Chrome re-rasterizes and is
unaffected. Force -webkit-font-smoothing: antialiased + grayscale, and
font-synthesis: none to stop Safari faux-bolding IBMPlexSans-SemiBold.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Tool-call grouping (266c007) shipped defaulting on; flip the default so
new installs get the familiar ungrouped chat. The Settings -> Features ->
Interface toggle still lets anyone turn it back on.

Note: only affects accounts with no synced settings blob yet. Existing
users whose settings have ever synced keep their stored value, since the
persisted blob is fully materialized (every default baked in on first
write) - a read-time default change can't reach them.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Tighten the maintain skill's "Comment voice" section:
- Casual but factual, not warm-for-its-own-sake. Mandatory plain
  thanks ending in "!", but ban flattery / performed emotion with a
  non-exhaustive list of banned phrases ("amazing work", "exactly
  right", etc.) - state what someone did, don't rate it.
- Spam (vendor pitches, ads, link-farming): silent close, zero
  engagement, no comment. New CLOSE_SPAM triage action.
- Credit contributors by @mention factually, not by how impressive.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Bump the composer max height to 480 on web/desktop where vertical
space is plentiful; native mobile keeps its compact caps (chat 120,
new-session 240) so the input doesn't dominate a phone screen.
Gated on Platform.OS === 'web', matching the file's existing
COMPOSER_INPUT_VERTICAL_PADDING pattern. On web this raises maxRows
in the autosizing textarea so it grows further before scrolling.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@bra1nDump bra1nDump merged commit 1189f7d into slopus:main May 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants