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
Open
coreyepstein wants to merge 14 commits into
coreyepstein wants to merge 14 commits into
Conversation
…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>
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.
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:
NSGlassEffectViewsits behind a transparentWKWebView, and the chat rail then ran a secondblur(28px)backdrop-filteron top of it — a full-height per-frame GPU pass whose backdrop the native material had already blurred.apply_liquid_glass_windowwas also non-idempotent, so every desktop-window re-open stacked another glass view, each sampling independently.widgetwindow madeany_window_visiblepermanently true, so the sessions poller's idle gating was dead code. Every 5s, forever: thousands ofstats, a blockingpgrepfork on a tokio worker, and a fullMissionControlSnapshotbroadcast to every live webview — delta-free.tick()allocated a new array even when empty (rewriting$stateevery 5s for nothing);presence-store.emitSnapshot()deep-copied every company's actor map once per presence message; each message row re-ranJSON.parseandIntlformatting on every render..reply-columnanimatedwidth; the skeleton shimmer animatedbackground-position(paint-only, never composited) across 12 concurrent elements.What changed
Compositing — dropped the redundant
backdrop-filterfrom the chat rail, files rail and launch buttons (native glass already provides the material;--side-bgalpha raised to compensate), converted progress fills totransform: scaleX()and the shimmer to a transform sheen, addedcontain: layout paintto the two scrollers.Reactivity — reference-stable returns from
tick()andmergeFetchedTimeline; microtask-coalesced presence emits with a per-company snapshot cache; a precomputedrenderRowsderived (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_blockingfor the snapshot walk;emit_snapshot_if_changed; idempotent glass via an identifier-tagged subview;FollowsWindowActiveStateso 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
notifyfilesystem watcher (300ms debounce) feeding a sharedrefresh_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–4main views,⌘/cheat sheet. Mirrored in a native View menu. Matches on bothevent.codeandevent.keyso 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.
Diagnostics —
pnpm perfmeasures scroll frame times, load, interaction latency and idle cost locally, with machine/power context and noise-banded baseline comparison.pnpm perf:lintholds 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:
applyWindowOpacitydispatched a partial appearance payload, sonormalizeColorTheme(undefined)resolved to"system"and deleteddata-force-theme. A user on forced Light dropped to system/dark on every launch and every slider drag.visibility: hiddenon the resting quick-react bar removed it from the tab order;:focus-withincould then never fire on rows with no other focusable descendant. Nowbox-shadow: noneat rest instead, which keeps the rasterization win.~/.claude/projectsfell 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
packages/uitestspackages/coretestshq-sync-menubar(Rust)hq-desktop-coresessionssvelte-checkapps/syncbuildpnpm perf:lintThe release bundle was built, ad-hoc signed, launched and exercised by the owner, who confirmed the app feels materially smoother.
Honest limitations
src-tauri/andcrates/— are not reflected in its numbers. Those are covered by tests and code review, not frame measurement.docs/performance-before-after.mdrecords 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 onmain(mqttpullinghttpsinto the browser graph). Pre-existing, unrelated, not fixed here.🤖 Generated with Claude Code