Merge bluebird/code + Browser tabs – bulletproof persistence, skeleton-first navigation, bounded memory#3221
Merge bluebird/code + Browser tabs – bulletproof persistence, skeleton-first navigation, bounded memory#3221adamleithp wants to merge 14 commits into
Conversation
|
✨ Submitted to Merge by @adamleithp. It will be added to the merge queue once all branch protection rules pass and there are no merge conflicts with the target branch. See more details here. |
|
fe25ae3 to
9a4f1c8
Compare
|
React Doctor found 1 issue in 1 file · 1 warning. 1 warning
Reviewed by React Doctor for commit |
8efdc80 to
ac6cf06
Compare
Migrating tabs out of bluebird surfaced three persistence bugs, all traced to writes computed from stale renderer state: - Apply every tab mutation's returned snapshot to the renderer mirror synchronously, plus optimistic activate/replace application, so a navigation racing the IPC round-trip can never target the previous tab (nav clicks opened duplicate tabs; tab switches overwrote other tabs' contents). - Guard decideTabNavigation against dead history tags (windowTabIds) and validate setActiveTab through a new setWindowActiveTab shared primitive so a closed tab's id replayed via back/forward can't persist a dangling activeTabId. - Replace the incomplete inline blank-tab check in __root.tsx with useActiveTabIsBlank (it missed channelId/appView). Adds appView as a tab identity dimension (migration 0017) so top-level pages (home, inbox, agents, skills, mcp-servers, command-center) get proper tab dedup and in-place navigation, plus 29 new browser-tabs tests including a regression suite for each reported bug. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
Tab switches looked ignored for up to a second: route loaders are all synchronous, so the router committed navigation in one main-thread block with the OLD route frozen on screen through the destination's expensive mount (task detail's chat thread measurement effects, terminal focus, canvas grid — profiled at 400-800ms of layout/render work). Give heavy routes a single-frame `yieldToPaint()` loader (double rAF — one guaranteed painted frame) plus a per-route-kind `pendingComponent` skeleton (task detail, canvas, channel pages, top-level app pages). The click now commits the URL, moves the tab highlight, and paints the skeleton before the heavy mount runs behind it. Router config: `defaultPendingMinMs: 0` so skeletons aren't held for the 500ms default minimum, and `defaultPreloadStaleTime: 0` so a hover preload can't satisfy the loader and skip the pending paint (which would silently reintroduce the frozen-screen path for sidebar links). Parent layouts (/code/inbox, /code/agents) only pend while entering — child navigations keep the match in `success` and reload in the background, so no skeleton flash on inner navigation. Verified over CDP screencast against the live app: skeleton frame paints mid-switch, destination replaces it after mount. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
Every task visited in a browser tab parked its xterm on unmount — terminal buffers, DOM subtree, and addons kept alive forever in the hidden parking container. Measured over CDP: xterm count grew monotonically with each distinct task visited (~3MB heap per task, GC-stable), the dominant tab-driven heap growth on the way to the 1.5GB budget. Keep at most 4 parked terminals; evict the least-recently-parked by destroying it. Safe because detach already serializes scrollback into the terminal store (restored via initialState on recreate), the shell session keeps running server-side and reattaches by id, and a parked terminal receives no live output anyway — the data subscription lives in the mounted Terminal component. Re-measured: xterm count plateaus at the cap across 12 task visits; residual growth drops to ~1MB/task (React Query's 5-minute gc window plus small per-task view stores). Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
…time Two more unbounded per-visit retainers behind tab-driven heap growth: - freeformChatStore kept a thread (canvas source + up to 50 whole-document version copies) for every canvas ever visited, for the app's lifetime. Cap at the 8 most-recently-used threads; eviction only runs from the mount-time seeding paths and skips threads with a save in flight. An evicted thread reseeds from the saved record on the next visit — every committed edit autosaves, so only an in-progress version-browse position is lost. - File-content queries (staleTime: Infinity) held full file bodies and base64 blobs under the default 5-minute gcTime, keeping every file viewed across every task resident simultaneously. Bound them to 30s after unmount; re-opens re-read from local disk. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
Fixes from a review pass over the tab persistence + performance work: - freeformChatStore: eviction now skips (rather than aborts on) threads with a save in flight, so one slow autosave can't block the cap for every thread behind it; threadOrder moved into store state (visible to devtools/tests, HMR can't desync it from threads); revert() guards against evicted/never-seeded threads — patch() would materialize an empty thread and persist() would save code:"" over the real record. - BrowserTabStrip: activeTabId validates the history-state tab id against the live tab list before preferring it (a dead id from back/forward blanked the strip highlight and pointed Cmd+W at a nonexistent tab); optimistic activate/replace writes derive from the store's current snapshot instead of the effect closure's, so they can't regress the mirror past a mutation onSuccess; local primaryWindow duplicate replaced with the @posthog/shared export. - Route skeletons: swapped @radix-ui/themes Skeleton for @posthog/quill (quill-first rule); ChannelSkeleton/AppPageSkeleton share one ListPageSkeleton silhouette; new withRouteSkeleton(skeleton) factory replaces the pendingComponent + loader boilerplate copy-pasted across 12 route files. - Sidebar merge regressions: adopt the legacy Code sidebar width (sidebar-storage) into channels-sidebar the first time the unified store persists, instead of resetting users to the 240px default; restore the title-bar-left width transition with drag-time suppression (it snapped while the sidebar animated); re-fire the legacy enter_space/leave_space analytics from the Channels toggle so space-adoption dashboards stay continuous. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
The migration meta files were hand-built during a rebase conflict resolution (jq output + manual edit) and didn't match Biome's formatter, failing the quality check. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
Adding a new AppView tab target now fails to compile in goToTab until its canonical route is wired, instead of silently falling through with no navigation. Closes the one drift gap in the appView tab-identity surface (APP_VIEW_META is already Record-forced, and shared/schema/DB treat appView as an opaque string). Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
Tab switches went sluggish (up to ~2s of frozen UI) once parked terminals started being torn down on switch. CPU profile pinned it: ~3.9s in `get offsetWidth`, from xterm's renderRows reading offsetWidth per row inside the WebGL-addon / term dispose path. The parking container used visibility:hidden (+ 0x0), which still participates in layout — so every per-row offsetWidth read during dispose forced a full-document reflow. Switch it to display:none: a display:none subtree has no layout box, so those reads return 0 with zero reflow. Parked terminals render nothing and refit on re-attach, so losing the layout box while parked is harmless. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
…king The LRU eviction (cap parked terminals, destroy least-recently-used) made term.dispose() run on tab switches, which froze the app when leaving a tab whose terminal was on screen. The display:none parking change was a follow-on perf fix for that same dispose path. Both are reverted to main's terminal behavior — verified the freeze is gone. Terminal memory bounding is dropped from this PR; if we revisit it, it needs a teardown path that doesn't synchronously re-render/serialize on switch. Generated-By: PostHog Code Task-Id: 0bf0f3b8-5bf9-4755-b8cf-e9f0b2a7de5c
New tabs and the last-tab-close landing were routing through the /website index, which redirects to channels[0] — so a fresh tab (or an emptied strip) opened a random first channel. Land on a single flag-aware default instead, keyed off the channels toggle (not the current route, which lags a flip): - channels on -> the private #me channel (provisioned lazily) - channels off -> the Code new-task screen Unified new-tab (handleNewTab) and close-landing (applyCloseResult) through one landOnDefault() helper that never touches the channels index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0e413d4 to
15e6e46
Compare
|
@k11kirky pushed a rebase + a few fixes on this branch: Rebased onto
New-tab / last-tab-close default (
Typecheck + biome green across |
k11kirky
left a comment
There was a problem hiding this comment.
Re-reviewed against the latest 2 commits (dd65e61b, 15e6e46f). The new landOnDefault() fix (default new tab / last-tab-close to #me/new-task instead of the /website index) is a real, well-targeted fix for a genuine bug, and I traced the resulting decideTabNavigation flow end-to-end — it correctly fills the blank tab. Two notes on it plus my earlier findings are inline below. Nothing else material changed; the ChannelsSidebar.tsx/SidebarNavSection.tsx diff is just the merge-conflict resolution re-homing the pre-existing "Activity" nav item under the new unified nav (correctly gated on channelsEnabled).
| // this toggle is its successor. Keep firing the legacy | ||
| // enter/leave events so space-adoption dashboards stay continuous. | ||
| track(ANALYTICS_EVENTS.CHANNEL_ACTION, { | ||
| action_type: checked ? "enter_space" : "leave_space", |
There was a problem hiding this comment.
The merged Channels toggle re-fires legacy enter_space/leave_space analytics with only surface: "nav", silently dropping the title_bar and header_button surface values the old Exit button and BluebirdButton used to send (both removed by this PR). Any dashboard/breakdown by surface will see title_bar/header_button stop appearing entirely after this ships — a shape-change in historical analytics data, not quite the "stay continuous" the comment above claims.
There was a problem hiding this comment.
Good catch. Leaving this as a follow-up — whether to keep re-firing the legacy enter_space/leave_space events at all, and whether to preserve the old title_bar/header_button surface values, is an analytics data-shape decision I don't want to make silently in this PR. Will raise it separately.
|
|
||
| const log = logger.scope("sidebar-menu"); | ||
|
|
||
| function isEditableTarget(target: EventTarget | null): boolean { |
There was a problem hiding this comment.
isEditableTarget is restored verbatim from the deleted MainSidebar.tsx as a fresh local copy — at least an 8th independent copy of the same editable-target check in this codebase (AgentBuilderDockLayout.tsx, HomeView.tsx, useInboxReportListSelection.ts, useAutoFocusOnTyping.ts, PlanApprovalSelector.tsx, useActionSelectorState.ts, SpaceSwitcher.tsx), with no shared helper in @posthog/shared or @posthog/ui. Pre-existing duplication, not introduced here, but this was a natural point to extract one.
There was a problem hiding this comment.
Agreed it's the natural extraction point, but since it's pre-existing duplication spanning ~8 files and out of scope for this PR, I'll do the shared-helper extraction as a separate follow-up.
…s, drop dead code - browserTabsStore.setSnapshot bails on value-equal snapshots so a mutation's authoritative echo doesn't re-render every subscriber and re-run the nav effect when an optimistic write already set the same value. - Extract applyOptimistic() for the nav effect's activate/replace optimistic mirror writes instead of hand-inlining the same read-transform-set twice. - Dedupe concurrent #me provisioning in landOnDefault via a shared in-flight promise — a double-fired Cmd+T can't create two "me" folders. - Delete the dead ProjectSwitcher "button" trigger variant (no callers left). - Remove the now-unreachable FeedbackModal "leaving" mode + its test case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrating browser tabs out of bluebird into the merged Code app surfaced persistence bugs and sluggish tab switching. This branch fixes them, with regression tests and live CDP profiling behind each change.
(fix) Tab persistence — nav clicks opened duplicate tabs, switches overwrote other tabs
setTabTarget/openOrFocus) from the renderer mirror, which lagged the server by an IPC round-trip. A navigation landing in that window wrote to the previous active tab (tab contents "copied over") or saw no active tab and opened a duplicate. Every mutation now applies its returned snapshot to the mirror synchronously, and activate/replace also apply optimistically before the round-trip.activeTabId, after which every nav looked like "no active tab" and opened new tabs. Guarded at three layers:decideTabNavigationvalidates tags against live tab ids, a newsetWindowActiveTabshared primitive rejects unknown/foreign tab ids, and the service refuses to persist them.__root.tsx(missedchannelId/appView) is replaced with the shared hook.(perf) Tab switching — click to first paint went from ~570ms of frozen UI to a next-frame skeleton
yieldToPaint()single-frame loader + per-route-kindpendingComponentskeletons (task detail, canvas, channel pages, app pages), applied via awithRouteSkeleton()factory. The click now commits the URL, moves the tab highlight, and paints a skeleton before the heavy mount runs behind it.defaultPendingMinMs: 0(no artificial skeleton hold) anddefaultPreloadStaleTime: 0(hover-preload can't satisfy the loader and silently reintroduce the freeze).(perf) Memory — bounded per-visit caches
revert()now guards against evicted threads — previously it could persist an empty canvas over real content.staleTime: Infinity) bounded to 30s gcTime after unmount instead of the 5-minute default.display:noneparking tweak) froze the app when leaving a tab whose terminal was on screen —term.dispose()on switch synchronously re-rendered/serialized. Reverted to main's terminal behavior; a future attempt needs a teardown path that doesn't do synchronous work on switch.(migration)
browser_tabs.app_viewcolumn (migration 0018) backing the new tab identity dimension.sidebar-storage) into the unifiedchannels-sidebarstore, so users' resized width survives the merge instead of resetting to 240px.(ui) Sidebar-merge regressions restored
enter_space/leave_spaceanalytics re-fired from the Channels toggle (its merged-world successor) so space-adoption dashboards stay continuous.@posthog/quillprimitives per the design-system rule.Verification
Created with PostHog Code