From 338e73859abdaa0d88a39cdb06a56fe512c6a1d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 16:59:17 -0800 Subject: [PATCH 001/309] fix(web): thread shortcuts work with the narrow sidebar closed (#23568) --- apps/web/src/components/Sidebar.motion.ts | 6 +++ .../components/sidebar/SidebarThreadRow.tsx | 11 ++-- .../sidebar/routeReveal.browser.test.tsx | 51 ++++++++++++++++++- .../web/src/components/sidebar/routeReveal.ts | 23 +++++++-- .../components/ui/sidebar.browser.test.tsx | 30 +++++++++++ apps/web/src/components/ui/sidebar.tsx | 27 +++++++--- 6 files changed, 132 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index fbc0d8434a82b..0e86c3c9a0e94 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -120,6 +120,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { return { update(animate: boolean) { if (disposed) return; + // A hidden list (the closed narrow sheet stays mounted) reports zero + // offsets, which would make the first pass after it opens animate every row. + if (parent.getClientRects().length === 0) { + suspend(); + return; + } const next = new Map( Array.from(parent.children) .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 05e8984200470..1813eba805656 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -560,7 +560,7 @@ type SidebarThreadRowProps = { wokeAt: string | null; isActive: boolean; // Set on the active row only; scrolls it into view when a reveal is owed. - revealRef?: ((row: HTMLElement, threadKey: string) => void) | undefined; + revealRef?: ((row: HTMLElement, threadKey: string) => (() => void) | undefined) | undefined; // 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; @@ -668,8 +668,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const { leaseLiveStatus, rowRef: leaseRowRef } = useSidebarRowSubscriptionLease(props.isActive); const { revealRef } = props; const rowRef = (row: HTMLElement | null) => { - if (row !== null) revealRef?.(row, threadKey); - return leaseRowRef(row); + const stopReveal = row === null ? undefined : revealRef?.(row, threadKey); + const releaseLease = leaseRowRef(row); + if (!stopReveal) return releaseLease; + return () => { + stopReveal(); + releaseLease?.(); + }; }; const isRegeneratingTitle = thread.titleRegeneration != null; const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); diff --git a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx index a87333e40ff08..a3b9c1f349e30 100644 --- a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx +++ b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../../test/render"; +import { Sidebar, SidebarProvider, SidebarTrigger, useSidebar } from "../ui/sidebar"; import { type SidebarRouteReveal, useActiveRowReveal } from "./routeReveal"; const ROW_HEIGHT_PX = 24; @@ -28,6 +29,12 @@ function RevealList(props: { ); } +// Reads the sidebar context like the real thread list, so opening the sheet re-renders it. +function SheetRevealList(props: Parameters[0]) { + useSidebar(); + return ; +} + const rowKeys = (count: number) => Array.from({ length: count }, (_, index) => `thread-${index}`); const newReveal = (): SidebarRouteReveal => ({ observedKey: null, pendingKey: null }); @@ -141,4 +148,46 @@ describe("useActiveRowReveal", () => { ); await expect.element(screen.getByText("thread-40", { exact: true })).toBeInViewport(); }); + + it("stops watching a hidden active row once it unmounts", async () => { + const reveal = newReveal(); + using observe = vi.spyOn(ResizeObserver.prototype, "observe"); + using unobserve = vi.spyOn(ResizeObserver.prototype, "unobserve"); + using disconnect = vi.spyOn(ResizeObserver.prototype, "disconnect"); + await using screen = await render( + , + // No router: the route arrives as the activeKey prop. + { router: false }, + ); + const row = screen.getByText("thread-40", { exact: true }).element(); + expect(observe).toHaveBeenCalledWith(row); + + // A widening viewport unmounts the closed sheet before it ever opens. + await screen.rerender(