From 19004f4ec5c24742c87257d0f1bdba521e0c7231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20=C5=BDab=C4=8D=C3=ADk?= <35886301+dominikzabcik@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:02:54 +0100 Subject: [PATCH 1/2] perf(web): opening a thread from a draft only renders its sidebar row --- apps/web/src/components/Sidebar.tsx | 5 ++- .../components/sidebar/SidebarThreadRow.tsx | 7 ++-- apps/web/test/appScenarios.renders.tsx | 42 ++++++++++++++----- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b4351..2ae8b5cf4d58f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1556,6 +1556,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve })(); }; + const readOpensPullRequestsInRightPanel = useStableCallback( + () => readRouteThreadKey(router) !== null, + ); const handleThreadClick = (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { if (isSidebarNestedLinkClick(event.target)) return; const isMac = isMacPlatform(navigator.platform); @@ -3414,7 +3417,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeRowThreadKey === threadKey ? revealActiveRow : undefined } isRouteThread={routeThreadKey === threadKey} - openPullRequestsInRightPanel={routeThreadRef !== null} + readOpensPullRequestsInRightPanel={readOpensPullRequestsInRightPanel} jumpLabel={ showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null } diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 3a50e76d06bda..4a7e0fd3f3d3a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -556,7 +556,8 @@ type SidebarThreadRowProps = { // Unlike isActive, never true for the ancestor standing in for an open // subagent: drafts and PR clicks care about which composer is on screen. isRouteThread: boolean; - openPullRequestsInRightPanel: boolean; + // Read at click time; a prop would re-render every row on entering or leaving a thread route. + readOpensPullRequestsInRightPanel: () => boolean; jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; @@ -648,7 +649,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onUnsettle, onUnsnooze, onUnpin, - openPullRequestsInRightPanel, + readOpensPullRequestsInRightPanel, renamingTitle, thread, variant, @@ -947,7 +948,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const handlePrClick = (event: ReactMouseEvent, targetUrl?: string) => { const url = targetUrl ?? pr?.url ?? currentLinkedPr?.url; if (!url) return; - const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel; + const showInRightPanel = targetUrl !== undefined || readOpensPullRequestsInRightPanel(); const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined); if (openedInRightPanel && showInRightPanel && !props.isRouteThread) { onThreadActivate(threadRef); diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index b3dad1041d7fe..8b3c8c473559f 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -3,7 +3,7 @@ // chat and settings routes, the sidebar and the right panel, through the same stores and // atoms the app reads. See renderScenarios.renders.tsx for the counting rules. import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -14,6 +14,7 @@ import { commands, page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { AppRoot } from "~/AppRoot"; +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter, type AppRouter } from "~/router"; @@ -21,6 +22,7 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; import { vcsEnvironment } from "~/state/vcs"; import { ENVIRONMENT_ID, + PROJECT_ID, assistantItem, emitTerminalMetadata, emitThreadEvent, @@ -120,6 +122,16 @@ async function openThread(threadId: ThreadId) { return app; } +const goToThread = (router: AppRouter, threadId: ThreadId) => async () => { + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId }, + }), + ); + await expect.element(lastAnswer(threadId)).toBeVisible(); +}; + async function measure(action: () => Promise): Promise { await quiet(); startCounting(); @@ -269,21 +281,29 @@ it("app-switch-thread", async () => { using _clock = frozenClock(); const { screen, router } = await openThread(A); await using _app = screen; - const go = (threadId: ThreadId) => async () => { - await act(() => - router.navigate({ - to: "/$environmentId/$threadId", - params: { environmentId: ENVIRONMENT_ID, threadId }, - }), - ); - await expect.element(lastAnswer(threadId)).toBeVisible(); - }; - const windows = [await measure(go(B)), await measure(go(A))]; + const windows = [await measure(goToThread(router, B)), await measure(goToThread(router, A))]; // Only the rows losing and gaining the active highlight change. for (const window of windows) expect(window.components.SidebarThreadRow).toBe(2); await report("app-switch-thread", windows); }); +// A row reads whether a thread is open only when its PR badge is clicked, so opening a thread +// from a draft changes only the row that gains the highlight. +it("app-open-thread-from-draft", async () => { + using _clock = frozenClock(); + const drafts = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-render"); + drafts.setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId); + using _draft = { [Symbol.dispose]: () => drafts.clearDraftThread(draftId) }; + const { screen, router } = await mountApp(`/draft/${draftId}`); + await using _app = screen; + // The sidebar lists the project once the shell snapshot has arrived. + await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible(); + const counted = await measure(goToThread(router, A)); + expect(counted.components.SidebarThreadRow).toBe(1); + await report("app-open-thread-from-draft", [counted], { commits: false }); +}); + // Through the diff.toggle shortcut, the same handler as the header's diff button. const toggleDiff = (to: "open" | "close") => async () => { await userEvent.keyboard("{Control>}d{/Control}"); From baea1e115e3bf75ce2d51fa93dbda16cfe2533be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 16:28:19 +0000 Subject: [PATCH 2/2] perf(web): sidebar rows keep a stable rename handler across navigation startThreadRename writes a ref since #35174, so React Compiler no longer memoizes it and every sidebar row re-rendered on each Sidebar render, including every thread switch. Pin it with useStableCallback. The PR badge callback now closes over routeThreadRef so it keeps the old boolean's meaning, including a promoted draft still redirecting. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4cbeceb4e4fa6..4c539afa78fc3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1524,12 +1524,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } }; - const startThreadRename = (threadRef: ScopedThreadRef, title: string) => { + // The ref write stops the compiler memoizing this, and every row receives it. + const startThreadRename = useStableCallback((threadRef: ScopedThreadRef, title: string) => { if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; setRenamingThreadRef(threadRef); setRenamingTitle(title); renamingOriginalTitleRef.current = title; - }; + }); const cancelThreadRename = () => setRenamingThreadRef(null); const commitThreadRename = (threadRef: ScopedThreadRef, title: string) => { const originalTitle = renamingOriginalTitleRef.current; @@ -1559,9 +1560,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve })(); }; - const readOpensPullRequestsInRightPanel = useStableCallback( - () => readRouteThreadKey(router) !== null, - ); + const readOpensPullRequestsInRightPanel = useStableCallback(() => routeThreadRef !== null); const handleThreadClick = (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { if (isSidebarNestedLinkClick(event.target)) return; const isMac = isMacPlatform(navigator.platform);