Repository navigation
perf(solo): subscribe to Solo navigation only for a chat that can be filed - #3335
Conversation
…filed Co-authored-by: BL4B-00 <887685ae8076d98fbe7787900d8d1df35ace3e444fc8bf9e40b59fb349547c33> Signed-off-by: Kalvin C <[email protected]>
… the New menu Co-authored-by: BL4B-00 <887685ae8076d98fbe7787900d8d1df35ace3e444fc8bf9e40b59fb349547c33> Signed-off-by: Kalvin C <[email protected]>
… or snapshot changes Co-authored-by: BL4B-00 <887685ae8076d98fbe7787900d8d1df35ace3e444fc8bf9e40b59fb349547c33> Signed-off-by: Kalvin C <[email protected]>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Reviewed at e360cab. The cached tuple is safe under the SoloRegionSource.workspace contract, which says a workspace change shows through read's next revision. Each input it holds stays the same object until it really changes. The binding is frozen once per bind, the source is the registered entry, and the snapshot is replaced only on a newer revision. A chat moving into, out of or between regions changes source, so it re-renders. Hook order stays the same whether or not there is a channelId. I found no other consumer in ChannelWorkspace or NewMenu that relied on the extra re-renders.
Nonblocking nit: const noSubscription = () => () => {} now exists in three places (solo-navigation-react.ts, NewMenu.tsx, chat-workspace.ts). It could be exported once from solo-navigation-react.ts.
…#3334) ## Problem #3219 added `previewLinks`, which `MessageRow` runs for every mounted Markdown row whenever a link-preview registry exists. The browser and desktop builds always create that registry (`src/features/conversation/service.tsx:212-217`), even when it has no providers. `previewLinks` called `prepareMarkdown(content)` first and checked for `https://` only afterwards (`src/features/messages/preview-links.ts:41-42`). So every link-free, non-plain Markdown row paid for preparation, which for non-trivial Markdown means a `fromMarkdown` parse via `scanMarkdown`, just to return `[]`. The row's own body preparation is cached across remounts in `MessageMarkdown.tsx` (`preparedBodies`). This call is not cached, so the cost recurs on every row mount: on channel open, on revisits and while scrolling through history. ## Change `src/features/messages/preview-links.ts`: test the raw content for `https://` before preparing it. If the test fails, return `[]` without preparing. ## No behavior change - Before, the function returned `[]` when `prepared.kind === "plain"` **or** the raw `content` lacked `https://`. After, it checks the same two conditions in the opposite order. - `prepareMarkdown` is pure and immutable (`markdown-preparation.ts:117`). Skipping it when the result would be discarded has no other effect. - The `https://` test still runs on the raw `content`, as before, not on the prepared content. - Every input that passes both checks takes the unchanged path. - The existing `preview-links.test.ts` cases pass unchanged: bare, labeled and reference links, angle links, code, images, spoilers and exclusions. ## Evidence **Linux only, on a single machine.** The Blox API returned 502 for the whole session, so no macOS run and no multi-box spread were possible. The macOS half of the usual evidence is missing. Method: - Interleaved A/B of main `b9c2edbb` vs main plus this change, using `pnpm perf` cases and the sweep's probes. - Chromium production build at 4× CPU, on one 11-vCPU Linux box. - 6 rounds × 3 samples, n=18 per variant. Order alternated each round. - The table shows medians. The range is the min..max of the per-round median deltas. p is a two-sided Mann–Whitney test. | scenario | metric | main | this PR | Δ (per-round range) | p | |---|---|---|---|---|---| | scroll-paging-fast-markdown | script | 7119 ms | 6755 ms | −5.1% (−6.8..−3.2) | 9e-6 | | scroll-paging-fast-markdown | LoAF blocking | 2916 ms | 2635 ms | −9.6% (−19.1..−6.9) | 5e-5 | | scroll-paging-fast-markdown | task | 13466 ms | 13053 ms | −3.1% (−4.8..−2.9) | 4e-4 | | channel-open-cold-markdown | script | 685 ms | 633 ms | −7.6% (−16.7..+3.5) | 0.002 | | channel-open-cold-markdown | first rows | 934 ms | 890 ms | −4.7% (−15.9..+1.9) | 0.005 | | hover across rows (control) | script | 81 ms | 83 ms | +1.6% | 0.4 (noise) | **Bisect: #3219 introduced the cost.** Method: interleaved A/B of #3219's parent `2a514ec6` vs #3219 `741f3a84`, 6 rounds × 2 samples (n=12 per variant), same box and method as above. | scenario | metric | #3219 parent | #3219 | Δ (per-round range) | p | |---|---|---|---|---|---| | channel-open-cold-markdown | script | 596 ms | 664 ms | +11.4% (+2.0..+15.2) | 0.007 | | channel-open-cold-markdown | first rows | 835 ms | 916 ms | +9.6% (+0.8..+14.2) | 0.009 | | channel-open-cold-markdown | LoAF blocking | 557 ms | 651 ms | +16.8% (+0.9..+22.0) | 0.008 | | scroll-paging-fast-markdown | script | 6765 ms | 7191 ms | +6.3% (−0.9..+11.7) | 0.001 | | scroll-paging-fast-markdown | LoAF blocking | 2686 ms | 3178 ms | +18.3% (−5.2..+34.6) | 0.003 | | warm switch, Markdown corpus | script | 172 ms | 172 ms | +0.1% | 0.6 (noise) | **Replication.** I re-ran on main `8697bd29`, 6 rounds × 2 samples (n=12 per variant), in a 4-way sweep of main, this PR, #3335 and both. The first run's warm Markdown switch result (−7.5%, p=0.02) did not repeat: it measured −0.7% (p=0.3). It has been dropped from the table above. These did repeat: | scenario | metric | Δ this PR vs main | p | |---|---|---|---| | channel-open-cold-markdown | script | −4.9% | 4e-4 | | channel-open-cold-markdown | first rows | −4.4% | 0.002 | | scroll-paging-fast-markdown | script | −4.2% | 0.001 | | scroll-paging-fast-markdown | LoAF blocking | −11.3% | 7e-4 | **Stacked with #3335.** Main `8697bd29` vs both PRs, n=12 per variant. The two changes touch separate paths and their wins add: | scenario | metric | Δ | p | |---|---|---|---| | channel-open-cold-markdown | script | −6.6% | 5e-4 | | channel-open-cold-markdown | first rows | −5.0% | 0.008 | | scroll-paging-fast-markdown | script | −4.5% | 0.001 | | scroll-paging-fast-markdown | LoAF blocking | −12.3% | 0.007 | Ten other configs are unchanged (all p ≥ 0.1): warm switch, 5k-member switch, typing, mention typing, 5k members filter, idle, hover, thread open, cold start and Markdown switch. ## Tests Checks run: - `vitest run src/features/messages`: 103 files, 3049 tests pass. - `tsc --noEmit`: pass. - `biome check --error-on-warnings` on the changed file: pass. - `scripts/check-staged-names.mjs`, the icon check and `git diff --check`: pass. The commit and push hooks could not start, because Hermit's pinned Node 24.18.0 download returned 502. I ran the pre-commit jobs above by hand with system Node v26.13 and committed with `LEFTHOOK=0`. I did not run the pre-push `check-push.mjs` lanes. Hosted CI is the gate for those. **Independent review:** one agent review, 9/10, no blockers and no changes recommended. **Still needed:** - A macOS A/B. - Human testing. Signed-off-by: Kalvin C <[email protected]>
* origin/main: feat(agents): let a plugin give one agent standing instructions in one conversation (block#3432) Keep Solo draft roles in source-owned JSON (block#3463) fix(media): save Download Image from the native menu on macOS (block#3352) fix(bestie): show the Bestie's profile picture in navigation (block#3423) Expose local agent lifecycle evidence (block#3430) fix(macos): recover when WebKit abandons its GPU process at launch (block#3342) Preserve JSON context in Solo drafts (block#3429) fix(messages): qualify namesake mentions so each renders as a chip (block#3206) perf(app): hand a page its request without rendering the shell again (block#3007) perf(solo): subscribe to Solo navigation only for a chat that can be filed (block#3335) perf(messages): skip markdown preparation for link-free preview scans (block#3334) feat(messages): open message actions from a right-click on the row (block#3316) fix(agents): read hosted agent records on foreground capacity (block#3431) Signed-off-by: Laura Knight <[email protected]> # Conflicts: # docs/plugin-authoring-guide.md
Problem
#2788 added
useRegionWorkspace(queries, solo ? currentId : undefined)toChannelWorkspace(src/bundled/channels/ChannelsPage.tsx:795). The hook always subscribed to the Solo navigation store throughuseSoloNavigation, and that store publishes a new snapshot on every channel-list change (solo-navigation.ts:297,refresh). An incoming message in any channel changes the list, so every arrival re-rendered the whole channel workspace (timeline shell, composer, header and panes), even in an ordinary channel. In an ordinary channel the hook's result is alwaysundefined.In a benchmark where 40 messages arrive in a background channel, script time doubled at #2788 and has stayed there since.
Change
src/features/solo/chat-workspace.ts:useRegionWorkspacesubscribes to Solo navigation only when it is given achannelId. Without one it reads nothing and returnsundefined, as it already did.No behavior change
channelId: the old code returnedundefinedon every path (if (!channelId || …) return undefined). The new code returns the same value without subscribing.channelId: the reader, snapshot and lookup are unchanged. Every Solo chat inChannelsPageand everyWorkspaceControlcaller still re-renders on each navigation publish, as before.useSoloNavigationfell back to an empty snapshot with no binding, which returnsundefined. The new code'sundefinedsnapshot gives the same result.ChannelWorkspace, through its roster subscription. That render passes thechannelId, and the hook subscribes from then on.chat-workspace.test.tsfollows the hook from no chat to a chat and back. It checks that there is no subscription and no publish-driven render without a chat, that a chat gets re-rendered and resolves its workspace, and that the listener is released when the chat goes away. The test fails on main.Evidence
Linux only, on a single machine. The Blox API returned 502 for the whole session, so no macOS run and no multi-box spread were possible.
Method:
bl4b-ingest: Alpha is open, and 40 peer messages arrive in Beta 50 ms apart through the fixture's production relay publish path (app.append). The measurement runs from the first message until the timeline settles.-mentionadds one unread top-level mention of the viewer.Bisect. Each commit got 2 interleaved rounds × 3 samples on
bl4b-ingest. Script was 496–520 ms at every commit through02d5e2b6. It was 945–1000 ms at #27883b9d1354and every later commit to99fe700c.This PR. Main
8697bd29vs this change, 6 interleaved rounds × 3 samples (n=18 per variant):For reference,
desktop-v0.7.17measured 499 ms of ingest script on the same probe (n=18).Controls. The first run showed warm switch at +4.4% script (p=0.1) and typing LoAF at +3 ms (p=0.04), both borderline. A dedicated re-run, 6 rounds × 3 samples, found both to be noise:
Tests
Checks run:
vitest run src/features/solo src/bundled/channels src/features/channel-navigation: 98 files, 1776 tests pass.tsc --noEmit: pass.biome check --error-on-warningson the changed files: pass.scripts/check-staged-names.mjs, the icon check andgit diff --check: pass.The commit and push hooks could not start, because Hermit's pinned Node 24.18.0 download returned 502. I ran the pre-commit jobs above by hand with system Node v26.13 and committed with
LEFTHOOK=0. I did not run the pre-pushcheck-push.mjslanes. Hosted CI is the gate for those.Independent review: one agent review, 9/10, no blockers. Its optional test extension (chat → no chat) is included.
Still needed:
Second commit: the New menu has the same subscription
#2788 also made the always-mounted
ReadyNewMenu(src/app/shell/NewMenu.tsx) subscribe to the full Solo navigation snapshot. It used that snapshot only to derive one boolean,composing: whether the first region source composes workspaces. Every channel-list refresh, so every incoming message, re-rendered the menu.Change:
NewMenunow selects that boolean throughuseSyncExternalStore, with the same first-source rule.No behavior change:
PrimarySidebar.test.tsxlifecycle case covers false → true → false with the realNewMenu.Evidence (Linux only). Method: this PR's first commit vs both commits, 6 interleaved rounds × 3 samples (n=18), same box and method as above.
That brings ingest script to within about 6% of
desktop-v0.7.17, which measured 499 ms.Checks:
vitest run src/app/shell: 22 files, 447 tests pass.tsc --noEmit: pass.biome check: pass.CI note: the failure in browser shard chromium 7/9 was
thread-unread.spec.mjs:421, in its hover background check. It passed 4/4 locally with--repeat-each=4on both main8697bd29and this branch. I read it as a flake.Final sweep: this PR stacked with #3334
Method: main
8697bd29vs F2 + F2b + #3334, 6 interleaved rounds × 2 samples (n=12 per variant), 14 configs, same box and method as above. Linux only.Ingest script is back at the
desktop-v0.7.17level (499 ms).Warm switch, 5k-member switch, typing, mention typing, 5k members filter, idle, hover, thread open and cold start are all unchanged (p ≥ 0.1).
Third commit: a Solo chat re-renders only when its region's workspace inputs change
With a Solo chat open, the first commit still subscribes, as intended. But a channel-list refresh rebuilds every region object, so each incoming message elsewhere still re-rendered
ChannelWorkspace.Change:
useRegionWorkspaceselects a cached tuple of the binding, the chat's owning source, that region's accepted snapshot and itsreason. It callssource.workspace()during render, as before.No behavior change:
solo-navigation.ts:312,324–333,359–373,378–383;solo-navigation-model.ts:54–72).SoloRegionSource.workspacesays a change "shows throughread's next revision" (solo-navigation-contract.ts:48–50). A new revision therefore re-renders.read()keeps the old snapshot and setsreason: "failed".reasonis part of the tuple, so that also re-renders and still reaches the accessor's existingcatch.id; a failed read re-renders. It fails on the previous commit.Evidence (Linux only). Method: this PR's first two commits vs all three, 6 interleaved rounds × 3 samples (n=18), same box and method as above.
bl4b-ingest-solohas a 120-row Solo chat open while 40 peer messages arrive in Beta.Bisect for this case. Commits got 2 rounds × 3 samples:
desktop-v0.7.17: 827 ms.8697bd29: 952 ms.Checks:
vitest run src/features/solo src/bundled/channels src/features/channel-navigation src/app/shell: 120 files, 2223 tests pass.tsc --noEmit: pass.biome check: pass.reasonwas not compared. It is fixed in this commit. The re-review scored 9/10 with no blockers.