From 698d04a81e40b4e415e64b2ff8934d7ab298b8f8 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 16:16:51 +0800 Subject: [PATCH] fix(AppShell): attach the agent-body height observer via a callback ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent sidebar sometimes rendered with no background. The body's measured height feeds AgentSidebarHeader's single full-window shape (the fill); when that height is 0 the header falls back to a body-less cap and the panel paints with no background. The height came from a ResizeObserver in an [isOpen]-keyed effect, but the body node mounts on `isOpen && sidebarWidth > 0` — a condition that flips true WITHOUT isOpen changing on the reload-restore path (open stays true while the width seeds 0 -> >0, and the async width GET lands the same way). The effect missed that mount, so the observer never attached, agentBodyH stayed 0, and the background was missing until an unrelated close+reopen toggled isOpen. A callback ref binds the observer to the actual DOM mount/unmount, so it can never desync from the render condition. Adds a regression test asserting the body is observed on a reload-restore open (open=true from mount, width seeds after) — pre-fix that mount went unobserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../layout/app-shell/AppShell.test.tsx | 36 ++++++++++++++++++ src/components/layout/app-shell/AppShell.tsx | 37 +++++++++++++------ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/components/layout/app-shell/AppShell.test.tsx b/src/components/layout/app-shell/AppShell.test.tsx index 3d5fc1f47..30ae3640c 100644 --- a/src/components/layout/app-shell/AppShell.test.tsx +++ b/src/components/layout/app-shell/AppShell.test.tsx @@ -286,6 +286,42 @@ describe("AppShell controlled sidebar open", () => { expect(screen.queryByLabelText("Open agent")).not.toBeInTheDocument(); }); + it("observes the body height on a reload-restore open, so the background paints", async () => { + // Regression for "sometimes the sidebar has no background": the body's + // height feeds AgentSidebarHeader's single full-window shape (the fill). + // On the reload-restore path open stays true from mount while the width + // seeds 0 → >0, so isOpen NEVER transitions. The body height observer must + // still attach when the body node mounts, or agentBodyH is stuck at 0, the + // header drops to its body-less cap, and the panel paints with no + // background. A callback ref (not a [isOpen]-keyed effect) attaches on the + // actual mount; pre-fix, that mount was missed and the body went unobserved. + const observed: Element[] = []; + class MockResizeObserver { + constructor(_cb: ResizeObserverCallback) {} + observe(el: Element) { + observed.push(el); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", MockResizeObserver); + try { + render( + {}}> + content + , + ); + await act(async () => { + await Promise.resolve(); + }); + const body = document.querySelector(".flex-1.min-h-0.flex.flex-col"); + expect(body).not.toBeNull(); + expect(observed).toContain(body); + } finally { + vi.unstubAllGlobals(); + } + }); + it("paints closed (FAB shown) when open is false", () => { render( {}}> diff --git a/src/components/layout/app-shell/AppShell.tsx b/src/components/layout/app-shell/AppShell.tsx index 56f1c2c7b..e44c1b323 100644 --- a/src/components/layout/app-shell/AppShell.tsx +++ b/src/components/layout/app-shell/AppShell.tsx @@ -1,4 +1,10 @@ -import { useState, useRef, useEffect, type ReactNode } from "react"; +import { + useState, + useRef, + useEffect, + useCallback, + type ReactNode, +} from "react"; import { cn } from "@/utils/cn"; import type { ComponentMeta } from "@/types/component-meta"; import { IconButton } from "@/components/ui/actions/icon-button/IconButton"; @@ -309,7 +315,7 @@ function AppShellInner({ // the window-minus-header height the AgentSidebarHeader needs to draw the // whole window as ONE Notch (rounded body + active-tab notch, single fill) — // killing the header↔body cream seam that aliases on fractional-DPI. - const agentBodyRef = useRef(null); + const agentBodyObserverRef = useRef(null); const [agentBodyH, setAgentBodyH] = useState(0); useEffect(() => { @@ -327,20 +333,27 @@ function AppShellInner({ // (uncontrolled — behavior unchanged when `open` is undefined). const isOpen = open ?? sidebarWidth > 0; - // Track the agent body's own height (window height MINUS the 40px header) so + // Measure the agent body's own height (window height MINUS the 40px header) so // the single window shape always matches it through opens, drag-resize, and - // viewport changes. Re-runs when the sidebar mounts/unmounts (isOpen) so the - // observer attaches once the body element exists. - useEffect(() => { - const el = agentBodyRef.current; - if (!el) return; - if (typeof ResizeObserver === "undefined") return; + // viewport changes. This is a CALLBACK REF, not a ref + [isOpen] effect, + // because the body node mounts on `isOpen && sidebarWidth > 0` — a condition + // that can flip true WITHOUT isOpen changing: the reload-restore path holds + // open=true while the width seeds 0 → >0 (and the async width GET lands the + // same way). An [isOpen]-keyed effect misses that mount, so the observer never + // attaches, agentBodyH stays 0, the header falls back to its body-less cap, and + // the sidebar paints with NO background until an unrelated close+reopen toggles + // isOpen. A callback ref fires exactly when the node attaches/detaches, so it + // can never desync from the actual mount. + const setAgentBodyEl = useCallback((el: HTMLDivElement | null) => { + agentBodyObserverRef.current?.disconnect(); + agentBodyObserverRef.current = null; + if (!el || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { setAgentBodyH(entries[0].contentRect.height); }); observer.observe(el); - return () => observer.disconnect(); - }, [isOpen]); + agentBodyObserverRef.current = observer; + }, []); const isOverlaying = isOpen && @@ -571,7 +584,7 @@ function AppShellInner({ (flex/min-h-0) and drop bg-on-background + rounded-2xl so the shape shows through with no abutting seam. The inner scroller and input carry no opaque bg of their own, so this is the only fill to strip. */} -
+
{/* The host's chat surface when wired; otherwise the personalized empty-state opener under the brand logo