Skip to content

perf: make the desktop UI feel native — unstack blurs, kill idle churn, watch instead of poll - #752

Open
coreyepstein wants to merge 14 commits into
mainfrom
perf/ui-smoothness
Open

coreyepstein wants to merge 14 commits into
mainfrom
perf/ui-smoothness

Conversation

@coreyepstein

Copy link
Copy Markdown
Contributor

Why

The desktop app felt choppy and laggy. An audit across four surfaces (CSS compositing, Svelte reactivity, the native window layer, and keyboard handling) found the cost was stacked, not singular:

  1. Blur applied twice. A native NSGlassEffectView sits behind a transparent WKWebView, and the chat rail then ran a second blur(28px) backdrop-filter on top of it — a full-height per-frame GPU pass whose backdrop the native material had already blurred. apply_liquid_glass_window was also non-idempotent, so every desktop-window re-open stacked another glass view, each sampling independently.
  2. A background heartbeat that never idled. The always-visible widget window made any_window_visible permanently true, so the sessions poller's idle gating was dead code. Every 5s, forever: thousands of stats, a blocking pgrep fork on a tokio worker, and a full MissionControlSnapshot broadcast to every live webview — delta-free.
  3. Idle re-render churn. tick() allocated a new array even when empty (rewriting $state every 5s for nothing); presence-store.emitSnapshot() deep-copied every company's actor map once per presence message; each message row re-ran JSON.parse and Intl formatting on every render.
  4. Layout-animating CSS. .reply-column animated width; the skeleton shimmer animated background-position (paint-only, never composited) across 12 concurrent elements.

What changed

Compositing — dropped the redundant backdrop-filter from the chat rail, files rail and launch buttons (native glass already provides the material; --side-bg alpha raised to compensate), converted progress fills to transform: scaleX() and the shimmer to a transform sheen, added contain: layout paint to the two scrollers.

Reactivity — reference-stable returns from tick() and mergeFetchedTimeline; microtask-coalesced presence emits with a per-company snapshot cache; a precomputed renderRows derived (one parse/format per timeline change, not per render); rAF-throttled scroll; visibility-gated pollers; de-indexed list keys; batched file rendering; lazy/sized images.

Native — HUD windows excluded from the visibility gate; spawn_blocking for the snapshot walk; emit_snapshot_if_changed; idempotent glass via an identifier-tagged subview; FollowsWindowActiveState so background windows stop re-sampling; a 120ms sync-progress coalescer; lto = "thin" + codegen-units = 1.

Session freshness (architecture change) — the local poll is replaced by a notify filesystem watcher (300ms debounce) feeding a shared refresh_and_emit. Watch roots are re-resolved on the safety tick so a session directory created after startup is picked up. The expensive filesystem walk drops to 90s as a backstop; a separate 15s process-only liveness tick keeps session-exit latency unchanged. (The remote/outpost half already used a single MQTT subscription and is untouched.)

Keyboard shortcuts (new) — one capture-phase registry replaces ~15 ad-hoc listeners, resolving the pre-existing three-way ⌘1–5 conflict. ⌘⇧] / ⌘⇧[ next/previous conversation, ⌘N, ⌘F, ⌘⇧F, ⌘1–4 main views, ⌘/ cheat sheet. Mirrored in a native View menu. Matches on both event.code and event.key so non-US layouts work.

Bug fix — the Window opacity slider wrote a CSS variable nothing read. It now drives the real transparency factor through the host's appearance seam.

Diagnosticspnpm perf measures scroll frame times, load, interaction latency and idle cost locally, with machine/power context and noise-banded baseline comparison. pnpm perf:lint holds the static budgets. Neither gates CI.

Review findings fixed before merge

An adversarial review found five regressions that rode in with the perf work. All fixed, each with a regression test verified to fail without the fix:

  • Theme reset on every launchapplyWindowOpacity dispatched a partial appearance payload, so normalizeColorTheme(undefined) resolved to "system" and deleted data-force-theme. A user on forced Light dropped to system/dark on every launch and every slider drag.
  • Reactions/reply keyboard-unreachablevisibility: hidden on the resting quick-react bar removed it from the tab order; :focus-within could then never fire on rows with no other focusable descendant. Now box-shadow: none at rest instead, which keeps the rasterization win.
  • Team switch didn't scroll to bottom — effects flushed in declaration order and read a stale non-reactive flag. Also fixed a same-team regression where the reset fired on every poll and yanked a scrolled-up reader.
  • Duplicate optimistic message — temp rows were only cleared when the newest event id changed, but synthetic ids can never reconcile against the server echo. Now removed by positive reconciliation.
  • Watcher blind on fresh installs — watch roots were resolved once. A missing ~/.claude/projects fell back to a non-recursive ancestor watch, and the later directory-create event was filtered out as non-.jsonl, leaving the watcher permanently blind until restart.

Verification

Suite Result
packages/ui tests 2546 passed
packages/core tests 119 passed
hq-sync-menubar (Rust) 1084 passed
hq-desktop-core sessions 112 passed
svelte-check 0 errors
apps/sync build
pnpm perf:lint 39 passed

The release bundle was built, ad-hoc signed, launched and exercised by the owner, who confirmed the app feels materially smoother.

Honest limitations

  • The perf harness cannot measure the native wins. It drives the static shell in headless Chromium with Tauri IPC mocked, so the glass, widget-gating and watcher changes — two of the twelve commits, almost entirely src-tauri/ and crates/ — are not reflected in its numbers. Those are covered by tests and code review, not frame measurement.
  • A before/after comparison was attempted and is not trustworthy. Load average was 20–33 across the runs and the power state changed between passes (battery → AC). docs/performance-before-after.md records this plainly rather than presenting the numbers as a result. The two load-independent findings do stand: total JS grew +11,308 B (+0.46%) from the shortcuts registry and cheat sheet, and idle main-thread busy is 0.0 ms on both trees — because the idle churn this branch fixes lives behind the mocked IPC boundary.
  • apps/work's build is broken on main (mqtt pulling https into the browser graph). Pre-existing, unrelated, not fixed here.

🤖 Generated with Claude Code

coreyepstein and others added 14 commits September 8, 2026 11:49
…and progress fills

- FilesModeSidebar / SetupChannelIntro launch button: remove CSS
  backdrop-filter; the native glass view behind the transparent window
  already blurs, so the extra blur was pure compositor cost.
- ChannelSkeleton: shimmer is now a translateX sheen on a pseudo-element
  (was a background-position animation repainting 12 blocks per frame).
- ProjectRow / ProjectDetailView / HomePage progress bars: fill ratio is
  passed as --fill and rendered with scaleX + transform transition
  instead of animating width; tracks already clip with border-radius.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slider only wrote --hq-window-opacity, which nothing reads; the real
surface alphas come from --hq-window-transparency-factor written by the
host's installAppearancePreferences. applyWindowOpacity now dispatches
hq:appearance-request (transparency = 100 - opacity) so the host applies
and persists it, and falls back to writing the factor/alpha vars itself
(same formulas as the host) when no host marker is present. The settings
pane seeds the slider from data-window-transparency when the host is
installed, skips the stale local re-apply on mount, and follows
hq:appearance-change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- agent-thinking tick() returns the same array when nothing changed;
  ReplyPanel only assigns on identity change
- agency-store: per-field fingerprints, 15s poll, pause while hidden
- sessions-store: pause poll while document is hidden
- presence-store: microtask-coalesced snapshot emits; per-company
  snapshot maps reused when unchanged
- live-messages: mergeFetchedTimeline returns existing array when the
  catch-up page is content-identical
- ChannelConversation: rAF-throttled scroll handler, renderRows derived
  precomputes system model / work event / group / day / time labels once
  per timeline change with hoisted Intl formatters, narrower reset effect
  keyed on newest event id, visibility:hidden quick-react toolbar at rest,
  contain: layout paint on the scroller
- AgencyChatPanel: stick-to-bottom gated autoscroll, stable keys
- stable non-index keys in ComposerPendingAttachments / InstalledPacksPanel
- ChannelFilesTab batched rendering with Show more; lazy/async images
- vite: safari16 target, shared hq-shared chunk for both entries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ync progress, idempotent glass

- sessions poller: exclude always-on HUD windows (widget, dm-banner) from the
  visibility check so hidden/idle gating actually engages; run the snapshot
  (thousands of stats + pgrep) on spawn_blocking; skip the sessions:updated
  emit when the snapshot is unchanged; interactive cadence 5s -> 15s
- glass: tag the inserted NSGlassEffectView / NSVisualEffectView with an
  identifier and return early on re-apply so reveal paths no longer stack
  materials; FollowsWindowActiveState for glass fallback, drift-detail and
  dm-banner vibrancy (popover untouched)
- sync: coalesce per-file EVENT_SYNC_PROGRESS to one emit per 120ms, keyed
  per HQ folder, flushing before any non-progress event and at runner exit so
  the final progress always reaches the UI
- release profile: lto = thin, codegen-units = 1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tive View menu

- packages/ui common/keyboard-shortcuts.ts: single capture-phase registry
  ("Mod+Shift+]" grammar, exact modifier match, editable-target skip,
  bracket matching via event.code, formatShortcut, listShortcuts,
  runShortcut) with unit tests; go-chord now imports isEditableTarget.
- DesktopApp: existing ⌘K / ⌘, / ⌘1–4 bindings move to the registry; new
  ⌘⇧] / ⌘⇧[ next/previous conversation (display-order rows from the
  sidebar), ⌘N new chat, ⌘F search messages, ⌘⇧F jump to conversation,
  ⌘/ cheat sheet (Escape closes it only while open). Palette rows derive
  shortcut labels from the registry and gain "Show all companies" /
  "Show only {company}" scope rows plus New chat / Keyboard shortcuts.
- ChatSidebar: drop the ⌘0/⌘1–5 company-scope hotkeys (they shadowed the
  shell's view switches and zoom-reset); ⌘P (Personal) stays via the
  registry. Emits display-order rows (`ondisplayrows`) and imperative
  entry points (`onactions`). sidebar-model gains flattenGrouped /
  stepConversation (tested); scopeFromHotkey removed.
- ShortcutCheatSheet.svelte: grouped modal in the command-palette idiom.
- Tauri: View menu (Next/Previous Conversation, New Chat, Keyboard
  Shortcuts) with accelerators emits `shortcut:invoke` {id} to desktop-alt;
  HqWorkWorkShell forwards it to runShortcut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, no thread-width transition

- DesktopApp commitTimeline: skip the assignment (and wake fan-out) when the
  merged timeline is the same array or the same messages in the same order
  — pairs with mergeFetchedTimeline returning `existing` when unchanged, so
  the 8s safety poll no longer re-renders an identical thread.
- avatarByUid: memoised on input identity (roster keys + per-roster
  arrays, contacts, self, overrides) so the map keeps its identity when
  nothing changed.
- conversationApi: plain const (only closes over the fixed adapter).
- .reply-column: drop the width transition (full-pane relayout per frame).
- ChatSidebar: remove the rail's own backdrop-filter (it stacked a 28px
  blur on the native window glass); --side-bg alpha raised 0.18→0.30
  light / 0.12→0.20 dark to keep contrast. .chat-scroll gets
  contain: layout paint; portal.ts comment updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds two local tools and no new CI gate.

`pnpm perf` measures what a user feels — cold load, scroll frame times,
interaction latency and idle cost — by driving apps/sync's design harness
(the real @hq/ui shell against mocked Tauri IPC) in a production build via
Playwright. Runs N times, discards the warm-up, reports median/p95/stdev,
records machine + power context, writes a timestamped run under
perf-results/ (gitignored) and compares against the committed baseline at
scripts/fixtures/perf-baseline.json. A regression is only declared when the
median is >25% worse AND outside 2 sigma AND past an absolute floor.

`pnpm perf:lint` keeps the cheap static guards from the perf pass: no CSS
backdrop-filter on always-on chrome (the native glass already blurs it), no
layout-animating transitions, no non-composited keyframes, poll-interval
floors plus visibility gating, broadcast-emit ceiling, native glass
idempotency, and the release build profile. Detectors are pure functions
over file content with passing and failing inline fixtures; every
repo-level assertion was mutation-tested and observed to fail.

Baseline recorded on an Apple M5 Max (18 cores) on AC power.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The timing metrics could not be measured trustworthily: the machine was at
load average 20-33 on 18 cores throughout, and the power state changed from
battery to AC between the before and after passes. Those numbers are recorded
as "not trustworthy" rather than reported as results.

Two findings do survive: bundle total JS grew 11,308 bytes (+0.46%), which is
deterministic and is a regression on that axis; and idle main-thread busy is
0.0ms on BOTH trees, because the idle churn this batch fixed lives in Rust
behind the mocked Tauri IPC boundary and is invisible to the harness.

The harness needed no modification to run against the base commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ic send dedupe

- ChannelConversation: `visibility: hidden` on the resting `.dm-quick-react`
  removed its buttons from the tab order, so react/reply were unreachable for
  keyboard and screen-reader users on any row with no other focusable
  descendant. Rest state is now `opacity: 0` + `box-shadow: none`, which avoids
  the shadow raster without breaking focus.
- AgencyChatPanel: fold the scroll and stickiness effects into one that reads
  `selected` before `messages`, keyed on team identity by VALUE, so a team
  switch always lands at the bottom (even when the message list is unchanged)
  while a reader scrolled up within the same team is left alone.
- ChannelConversation: optimistic rows are now removed on positive
  reconciliation with the server echo (author + body + attachment count + close
  timestamp, or the persisted eventId when `onsend` returns it) instead of on
  newest-id identity, so an echo that does not sort last no longer leaves the
  user's own message on screen twice.
- agency-store: add a request-generation guard so a slow `listChat` from a
  superseded team selection cannot write the wrong team's messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s seen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d reachability

CRITICAL — the opacity slider silently reset the user's light/dark theme on
every launch. `applyWindowOpacity` dispatched a PARTIAL appearance request
(`{windowTransparency}` only). The desktop host's request listener applies
`detail` RAW — only `requestAppearancePreferenceChange` merges with the current
preference — so `normalizeColorTheme(undefined)` resolved to "system", deleting
`data-force-theme` and resetting the native window theme. DesktopApp's onMount
then fired that request three lines after `applyColorTheme(readStoredTheme())`,
so a user on forced Light dropped to system/dark on EVERY launch. Now the
detail carries the current `colorTheme` (read from `data-force-theme`, the one
value both sides write), and the onMount call site is guarded with
`hasAppearanceHost()` exactly as PrototypeSettingsPanes already does.

Also:
- keyboard-shortcuts: match on `event.code` OR `event.key`. Preferring `code`
  unconditionally fired ⌘⇧] from the PHYSICAL US-`]` position — which is `+` on
  a German layout — and left the key actually labelled `]` dead.
- keyboard-shortcuts: skip keydown while `event.isComposing` (or keyCode 229),
  so shortcuts no longer steal keystrokes from an IME mid-composition.
- `help.shortcuts` gets `allowInInput`, so ⌘/ opens the cheat sheet from the
  composer. Escape's `return false` decline behaviour is unchanged and covered.
- ShortcutCheatSheet: add a Tab focus trap, mark background content inert +
  aria-hidden while open, and restore focus on close to the first still-
  connected of opener → stable selector → shell fallback (policy
  indigo-app-wide-modal-focus-return-survives-trigger-unmount). DesktopApp's
  shell root is the focusable fallback.
- Opacity slider floor widened 50 → 35, derived from
  DEFAULT_WINDOW_TRANSPARENCY (65). The old floor could not express the app's
  own default, so a fresh install seeded at 50 and the first drag jumped the
  window.
- updater.rs: macOS View-menu accelerators are app-wide, so emitting only to
  `desktop-alt` ran ⌘N / ⌘/ / ⌘⇧[ ] in a window the user was not looking at —
  or nowhere when desktop-alt was closed. Target the focused shell window,
  falling back to desktop-alt.
- FilesModeSidebar: pay for the dropped `backdrop-filter` with the same alpha
  compensation the chat rail got, so the panel does not wash out at max
  transparency in light mode.
- sidebar-model: drop the orphaned `scopeFromHotkey` JSDoc left above
  `flattenGrouped`.

Every fix has a regression test, each observed failing against the unfixed
code, including a new test that the command-palette scope rows (the replacement
for the deleted `scopeFromHotkey`) cannot silently vanish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reconciles the perf pass with #749 (chat previews/history/read tracking),
#750 (integrated sessions, project sharing, provider onboarding) and #751
(decision-card state). Every conflict was resolved as a semantic merge —
both behaviours kept, restructured onto this branch's conventions.

Conflicts:

- sessions/codex.rs — main switched to `scan_codex_sessions_with_hq` (HQ
  root resolution); ours extracted a sync `scan_local_codex_sessions` so
  `collect_snapshot_blocking` can call it off-runtime. Kept the sync
  extraction and moved main's `resolve_hq_folder()` + `_with_hq` call
  inside it, so the new reader runs on the blocking thread, not the
  tokio runtime.
- main.rs — union: our `setup_sessions_watcher` plus main's
  `setup_project_watch` and `install_stdio_process_registrar`.
- HqWorkWorkShell.svelte — union of the `@hq/ui` imports (our `common`
  for `shortcut:invoke` → `runShortcut`, main's ConversationRow /
  RowExtrasResolver types).
- ChannelConversation.svelte — main deleted the `extraOlder` reset
  outright (server-paged history must survive a live refresh; it has a
  test for exactly that), which supersedes our newest-id gating of the
  same reset. Kept main's removal plus our positive `reconcileLocalSends`
  reconciliation of optimistic sends. In `measureStickiness`, kept our
  flip-only `$state` writes and added main's scroll-to-top history pull.
- DesktopApp.svelte — union of imports: our shortcut registry + cheat
  sheet, main's `RowExtrasResolver` and `type Component`.

Semantic coupling handled beyond the textual conflicts:

- The rAF-throttled scroll handler could deliver a TRAILING measurement
  after a failed history load resolved, silently re-requesting a page the
  host had just failed to serve — and re-requesting on every subsequent
  burst. The automatic pull is now gated on `earlierError`; the reader
  retries explicitly through the button, which clears the flag.
- The shortcut registry resolves "Mod" per platform rather than accepting
  meta-or-ctrl the way the shell's former ad-hoc `onKey` did, so #750's
  new palette test pressed a modifier that no longer matches off macOS.
  Test now presses the platform's real Mod key (same helper as
  keyboard-shortcuts.test.ts); all of its assertions are unchanged.
- #750 added 8 broadcast `app.emit(...)` sites (161 vs the ratcheting
  ceiling of 153). Every consumer of `agent-session:event|phase|
  needs-you`, `agent-session:project-created` and
  `project-session:sharing-status` lives in the desktop-alt window, so
  they are now `emit_to(desktop_alt::WINDOW_LABEL, ...)` per this
  branch's convention. The ceiling was NOT raised.
- Verified `mergeFetchedTimeline`'s content equality is a key-union deep
  compare, so #749's new message fields are covered without change; and
  that `snapshot_changed` rides a derived `PartialEq`, so #750's new
  session fields cannot be deduped away.

Verification (all counts up or equal vs pre-merge):
  packages/ui tests   2612 passed (was 2546)
  packages/core tests  121 passed (was 119)
  packages/ui typecheck   0 errors, 927 files
  apps/sync build         ok
  pnpm perf:lint         39 passed
  cargo check             ok
  hq-sync-menubar       1217 passed (was 1084)
  hq-desktop-core sessions 126 passed (was 112)
Both failures were contract tests pinning behaviour this branch changed on
purpose. Re-pointed at the new intent rather than weakened.

tauri-conf.spec.ts: glass.rs moved from NSVisualEffectState::Active to
FollowsWindowActiveState so background windows stop re-sampling their backdrop
every frame. Now asserts the new state AND forbids Active in glass.rs, so a
revert fails. Scoped to glass.rs, leaving the popover's legitimate Active in
hq_platform::window_effects untouched.

desktop-011: the reduced-motion block in packages/ui/src/shell/DesktopApp.svelte
guarded only the .reply-column width transition, removed here because animating
a flex column's width relayouts the pane and its sibling every frame. Asserting
the guard string there now only proves the slow transition came back, so the
check is re-pointed at the two properties that still hold: the width transition
must not return, and the shell's remaining animation (ChannelSkeleton's
transform shimmer) must keep its reduced-motion escape hatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant