perf+polish: chat re-render isolation, unified chat/file/diff headers#1245
Merged
Conversation
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.
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Performance
useDraftautosave into a newChatComposersubcomponent and exposed an imperative handle for send/clear.SessionViewLoadedkeeps a stable tree on every keystroke.MultiTextInputisReact.memoso the JSX diff is skipped when the parent rerenders without changed props.AgentInputwas already memoized but every render passed it freshly-allocatedconnectionStatus/usageData/onSend/onAbort/onFileViewerPress/autocompleteSuggestions/autocompletePrefixes— memo always saw a "new" prop set. Now:autocompletePrefixeshoisted to module scope,useMemofor the primitive-derived objects,useCallbackfor 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 onlyChatComposer → AgentInput → MultiTextInput.SessionViewLoaded,ChatList,ChatHeaderView, and the file/diff sidebars stay stable.MultiTextInput(both native and web variants) now acceptsdefaultValueand reads the live text through an imperativegetText()handle; React state updates derived from typing wrap inReact.startTransition. Deletion no longer batches into 3–4 character chunks; fast typists see each character at native input latency.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 / notransitionProperty); native keeps the smooth animation. Chat reflows once instead of 60 times. Defense-in-depthcontain: '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.useNewSessionDraftzustand store, so eachinputflip re-ranuseAllMachines(),useSessions(), the agent-input setting hook, and every memo-derived picker item. Now the parent narrows to auseShallowselector that excludesinput, a separate primitive selector (s.input.trim().length > 0) feeds the auto-collapse effect, andMultiTextInputis wrapped in a memoizedPromptInputchild that owns theinputsubscription.handleSendreads the live prompt viauseNewSessionDraft.getState()on demand instead of closing over it, so the callback no longer depends oninput.renderItemcallback) on every pathname change to bake in aselectedflag. That defeated FlatList virtualization and forced every visible row to re-render on each nav. Selection is derived once viauseMemofrom pathname;renderItemcomputesselectedper row inline; FlatList getsextraDataso it re-evaluates correctly;SessionItem's existingReact.memoensures only the previously- and newly-selected rows actually re-render. Also removes a rules-of-hooks violation whereuseMemowas called inside a ternary (selectable ? useMemo(...) : data).maintainVisibleContentPositionwas set withminIndexForVisible: 0andautoscrollToTopThreshold: 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 redundantonContentSizeChangewas issuing its ownscrollToOffset(0)that fought with MVCP. Now:minIndexForVisible: 1anchors on the second-newest (stable across tokens),autoscrollToTopThreshold: 50for 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.React.memo; combined with the stablerenderItemcallback inChatList, only items whose props actually changed re-render. Also scopedrenderToHardwareTextureAndroidto native (no-op on web, was set unconditionally).setShowScrollButtonno longer fires on every scroll frame.onScrollruns at 60Hz; the previous code called the setter on every frame even when the value didn't change, re-rendering the wholeChatListInternal. Guarded with a ref so the setter only fires on threshold crossings.applyGitStatusFilesin storage does a structural-equality short-circuit (viafast-deep-equal): whengitStatusSync.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)AllFilesDiffViewpreviously did reset+spinner+refetch on every change; now it keyed-reconciles via aMap<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
/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 newparseLocalCommandMessage()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.limit: 5, so even when the metadata exposed 80+ commands the user only ever saw five. Arrow-key navigation also walkedselectedIndexpast the visible window without scrolling the inner ScrollView, so anything past row 5 was unreachable by keyboard. Bumped both limits to 50, replacedFloatingOverlayinAgentInputAutocompletewith an inline ScrollView whose ref drives scroll-into-view onselectedIndexchange (reads the underlying div's scroll offsets on web, falls back toscrollToon native), and raisedmaxHeightfrom 240→320 so ~8 rows are visible at once.Image attachments
pasteImages.web.tsalready exposedgetImagesFromDrop()but nothing ever called it — only the paste path was wired inAgentInput. The composer now attachesdragover(withpreventDefault, required fordropto fire) anddroplisteners alongside the existing paste effect. Both gate ondataTransfer.types.includes('Files')so non-file drags elsewhere in the app keep working. Drop events run files throughgetImagesFromDrop→fileToAttachmentPreviewand forward the result through the sameprops.onAddImagescallback the paste path uses.Header consolidation + browser-style back/forward
AllFilesDiffViewandFileViewPanelno longer render their own top bars; they publish right-slot controls intoChatHeaderView, which gains optionalextraPathSegment+rightSlotprops. Diff overlay contributes file count + Unified|Split toggle; file view contributes Edit|Preview + Save (Save disabled until changes are detected, same gating as before).SidebarNavigatorback/forward arrows via a new zustand store (sessionOverlayNav).canBack/canForwardfall 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.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
AllFilesDiffViewwas runningcat -- "<path>"viasessionBashto read untracked files for the diff view. On Windows + PowerShell,catis an alias forGet-Contentthat doesn't accept the--separator, so .md / .ps1 / any new files surfaced ascommand failed: cat -- "..."errors instead of content. Swapped to the nativesessionReadFileRPC (already used byFileViewPanelfor the standalone file view) which reads via the CLI's Nodefs— no shell, fully cross-platform, faster too. Base64 content decoded withatob+TextDecoder, same pattern asFileViewPanel.AllFilesDiffView, files silently appear/disappear in the list without resetting the diff. But the per-section Y-offset cache (fileOffsets, populated by each section'sonLayout) 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-retrytryScrollloop waits for freshonLayoutvalues before scrolling, so clicks always land on the section the file currently occupies.File overlay + files sidebar polish
CodeEditorand 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.surfaceSelectedfill the left sessions sidebar uses for the active chat row.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.Hygiene
.supervibe/artifacts that snuck in and add the directory to.gitignoreso it stays local.Changes (happy-cli)
Reconnect / lifecycle reliability
metadata.claudeSessionIdisn't loaded by the timecreateSessionScanneris built, so it inits withsessionId: nulland pre-marks nothing. Seconds later theSessionStart:resumehook firesonNewSessionwith the resume id, the firstsync.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 atreatExistingAsProcessedflag toonNewSessionand pass it from the remote-mode hook handler; the offline-reconnect path inrunClaude.tsand the local launcher keep the existing forward-everything semantics.pendingRequestsmap dies but the server still holdsagentState.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 callspermissionHandler.reset(reason)right after constructing the handler; the existingreset()already moves stalerequeststocompletedRequestswith statuscanceled, so the UI updates as soon as the new session attaches.Skill prompt hiding
Skilltool, the SDK feeds the skill's prompt body back into the conversation as a synthetic "user" message — context for the model, not chat content. PreviouslymapClaudeLogMessageToSessionEnvelopesiterated the content blocks and emitted each text chunk as anagenttext 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.isSyntheticasisMetainsdkToLogConverter. The SDK uses two different field names for the same signal:isSyntheticon the in-memorySDKUserMessagestream andisMetaonly after the message is persisted to JSONL on disk. Our SDK pipeline runs against the in-memory shape, soisMetawas never set on the log message and the mapper's skip didn't fire. Forward either flag asisMetawhen building the RawJSONLines payload so both the SDK pipeline and the on-disk JSONL scanner end up with a consistent signal.Notes
toolGroupsection (tool call grouping summaries) across all 10 languages.sessionOverlayNavis a new zustand store local tosources/-session/; no schema or persistence implications.isMeta, agent-staterequests/completedRequests) are used consistently across both pipelines.