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