From 902834f11add6c02b786be45024f2a13d1731635 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:24:34 -0700 Subject: [PATCH] perf(web): right panel tabs no longer all re-render on unrelated chat updates Each tab is now its own component that takes only its own values and stable handler forwarders, so the compiled tab subtree is reused unless that tab's surface, title or state changed. The dnd-kit sortable state stays keyed on the sortable fields only. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/RightPanelTabs.tsx | 375 ++++++++++++--------- 1 file changed, 222 insertions(+), 153 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 10291f7b0c61..c44de7b9bcbd 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -38,7 +38,6 @@ import { } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { - type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -55,6 +54,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; import { AndroidIcon, AppleIcon } from "~/components/Icons"; @@ -598,30 +598,37 @@ function sameOrigin(left: string, right: string): boolean { } } +function previewFaviconOf( + surface: RightPanelSurface, + sessions: Readonly>, + desktopByTabId: Readonly>, +): { url: string | null; capturedUrl: string | null } { + const snapshot = + surface.kind === "preview" && surface.resourceId ? sessions[surface.resourceId] : null; + const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; + const capturedUrl = favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; + return { url, capturedUrl }; +} + function SurfaceIcon({ surface, - sessions, - desktopByTabId, + previewUrl, + previewCapturedUrl, theme, environmentId, - pullRequestStatusSeeds, + pullRequestStatusSeed, }: { surface: RightPanelSurface; - sessions: Readonly>; - desktopByTabId: Readonly>; + previewUrl: string | null; + previewCapturedUrl: string | null; theme: "light" | "dark"; environmentId: EnvironmentId | null; - pullRequestStatusSeeds: Readonly> | undefined; + pullRequestStatusSeed: PullRequestTabStatusSeed | undefined; }) { switch (surface.kind) { - case "preview": { - const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; - const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; - const capturedUrl = - favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; - return ; - } + case "preview": + return ; case "diff": return ; case "files": @@ -642,7 +649,7 @@ function SurfaceIcon({ ); case "pull-requests": @@ -783,24 +790,168 @@ function SortableTabList(props: { ); } -function SortableTab({ - surfaceId, - disabled, - className, - ...props -}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { +interface RightPanelTabHandlers { + onActivate: (surface: RightPanelSurface) => void; + onClose: (surface: RightPanelSurface) => void; + onAuxClick: (event: ReactMouseEvent, surface: RightPanelSurface) => void; + onContextMenu: (event: ReactMouseEvent, surface: RightPanelSurface) => void; + onStartRename: (surfaceId: string) => void; + onFinishRename: (surfaceId: string, title: string) => void; +} + +function handleTabMouseDown(event: ReactMouseEvent) { + if (event.button !== 1) return; + event.preventDefault(); +} + +// One tab owns its sortable state and receives only its own values, so the +// compiled subtree is reused unless this tab's surface, title or state changed. +// The handlers must be stable: the tab list re-renders on every parent render. +function RightPanelTab( + props: RightPanelTabHandlers & { + surface: RightPanelSurface; + dragDisabled: boolean; + active: boolean; + pending: boolean; + title: string; + audio: TabAudioState; + audioRuntimeTabId: string | null; + previewUrl: string | null; + previewCapturedUrl: string | null; + theme: "light" | "dark"; + environmentId: EnvironmentId | null; + pullRequestStatusSeed: PullRequestTabStatusSeed | undefined; + renaming: boolean; + canRename: boolean; + ownsDesktopTitleBar: boolean; + }, +) { + const { + surface, + title, + audio, + audioRuntimeTabId, + onActivate, + onClose, + onAuxClick, + onContextMenu, + onStartRename, + onFinishRename, + } = props; const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: surfaceId, - disabled, + id: surface.id, + disabled: props.dragDisabled, }); + const content = ( + <> + onClose(surface)}> + + {props.pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {props.renaming ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} + /> + ) : ( + + { + if (surface.kind === "device" && props.canRename) onStartRename(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); return (
onAuxClick(event, surface)} + onContextMenu={(event) => onContextMenu(event, surface)} {...listeners} ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition }} - className={cn(className, isDragging && "relative z-10")} - /> + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + props.ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + props.active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + isDragging && "relative z-10", + )} + > + {content} +
); } @@ -1024,16 +1175,27 @@ export function RightPanelTabs(props: RightPanelTabsProps) { break; } }; - const handleTabMouseDown = (event: ReactMouseEvent) => { - if (event.button !== 1) return; - event.preventDefault(); - }; const handleTabAuxClick = (event: ReactMouseEvent, surface: RightPanelSurface) => { if (event.button !== 1) return; event.preventDefault(); event.stopPropagation(); props.onCloseSurface(surface); }; + // Every tab receives these. Parents pass fresh closures on each render, so + // stable forwarders keep a tab's props equal unless its own surface changed. + const onActivateTab = useStableCallback(props.onActivate); + const onCloseTab = useStableCallback(props.onCloseSurface); + const onTabAuxClick = useStableCallback(handleTabAuxClick); + const onTabContextMenu = useStableCallback( + (event: ReactMouseEvent, surface: RightPanelSurface) => { + void handleTabContextMenu(event, surface); + }, + ); + const onStartRenameTab = useStableCallback((surfaceId: string) => setRenamingDevice(surfaceId)); + const onFinishRenameTab = useStableCallback((surfaceId: string, title: string) => { + props.onRenameDevice?.(surfaceId, title); + setRenamingDevice(null); + }); useEffect(() => { if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; @@ -1111,134 +1273,41 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle( + const previewTabId = previewTabIdOf(surface, props.previewSessions); + const favicon = previewFaviconOf( surface, props.previewSessions, - props.terminalLabelsById, - ); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + props.desktopByTabId, ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; return ( - handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + surface={surface} + dragDisabled={!props.onMoveSurface || renamingDevice === surface.id} + active={surface.id === props.activeSurfaceId} + pending={props.pendingSurfaceIds.has(surface.id)} + title={surfaceTitle(surface, props.previewSessions, props.terminalLabelsById)} + audio={tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, )} - > - props.onCloseSurface(surface)} - > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - - - } - /> - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } - /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} - + audioRuntimeTabId={ + previewTabId ? (props.previewRuntimeTabId?.(previewTabId) ?? null) : null + } + previewUrl={favicon.url} + previewCapturedUrl={favicon.capturedUrl} + theme={resolvedTheme} + environmentId={props.environmentId} + pullRequestStatusSeed={props.pullRequestStatusSeeds?.[surface.id]} + renaming={renamingDevice === surface.id} + canRename={props.onRenameDevice !== undefined} + ownsDesktopTitleBar={ownsDesktopTitleBar} + onActivate={onActivateTab} + onClose={onCloseTab} + onAuxClick={onTabAuxClick} + onContextMenu={onTabContextMenu} + onStartRename={onStartRenameTab} + onFinishRename={onFinishRenameTab} + /> ); })}