diff --git a/package.json b/package.json index 47574ec74..3af983163 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.5.13", + "version": "0.5.14", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/layout/app-shell/AppShell.tsx b/src/components/layout/app-shell/AppShell.tsx index 5d8f2fe01..33a2f3207 100644 --- a/src/components/layout/app-shell/AppShell.tsx +++ b/src/components/layout/app-shell/AppShell.tsx @@ -305,6 +305,12 @@ function AppShellInner({ const maxWidthRef = useRef(800); const dragWidthRef = useRef(0); const sidebarElRef = useRef(null); + // Body container (everything below the 40px header). Its measured height is + // 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 [agentBodyH, setAgentBodyH] = useState(0); useEffect(() => { const update = () => { @@ -321,6 +327,21 @@ 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 + // 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; + const observer = new ResizeObserver((entries) => { + setAgentBodyH(entries[0].contentRect.height); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [isOpen]); + const isOverlaying = isOpen && windowWidth > 0 && @@ -530,9 +551,20 @@ function AppShellInner({ onRenameConversation={onRenameAgentConversation} onDeleteConversation={onDeleteAgentConversation} labels={agentHeaderLabels} + // Once measured, the header draws the whole window as ONE Notch + // (rounded body + active-tab notch, single fill) BEHIND the body. + // The body below is transparent so that shape shows through — no + // header↔body cream seam. Omit until measured to keep the + // original separate-cap rendering on first paint. + {...(agentBodyH > 0 ? { windowBodyHeight: agentBodyH } : {})} /> -
+ {/* Transparent body: the single window shape from AgentSidebarHeader + supplies the cream fill + rounding behind it. We keep ONLY layout + (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 diff --git a/src/components/ui/agent/sidebar/AgentSidebar.stories.tsx b/src/components/ui/agent/sidebar/AgentSidebar.stories.tsx index 27c3f7d3b..885844e3c 100644 --- a/src/components/ui/agent/sidebar/AgentSidebar.stories.tsx +++ b/src/components/ui/agent/sidebar/AgentSidebar.stories.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { AgentSidebarHeader } from "./AgentSidebarHeader"; import { AgentSidebarInput, type AgentQueuedMessage } from "./AgentSidebarInput"; @@ -11,6 +11,53 @@ import type { AgentSidebarHistoryGroup, ChatTab } from "./types"; const DEFAULT_MODEL_ID = "anthropic.claude-haiku-4-5-20251001-v1:0"; +/** + * Full-window assembly with the one-shape drawing: instead of the header + * painting only the active-tab cap above a SEPARATE cream body (two fills of + * --color-on-background meeting edge-to-edge → a 1px seam that aliases on + * fractional-DPI), the header draws the WHOLE window as ONE (rounded + * body + active-tab notch, one fill) behind everything once we hand it the + * body's measured height. The body container is therefore TRANSPARENT — it + * supplies only layout (flex/min-h-0), never a fill of its own, so the single + * shape shows through. We measure the body's own height (window height minus + * the 40px header) via a ResizeObserver on agentBodyRef and pass it as + * windowBodyHeight only once measured (>0). + */ +function WindowFrame({ + header, + children, +}: { + header: (windowBodyHeight?: number) => ReactNode; + children: ReactNode; +}) { + const agentBodyRef = useRef(null); + const [agentBodyH, setAgentBodyH] = useState(0); + + useEffect(() => { + const el = agentBodyRef.current; + if (!el) return; + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + setAgentBodyH(entries[0].contentRect.height); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( + <> + {header(agentBodyH > 0 ? agentBodyH : undefined)} + {/* Transparent body: only layout classes remain. The single the + header draws (rounded body + active-tab notch) supplies the surface, + so there is no bg-on-background / rounded-2xl / shadow here to abut + the header cap and alias into a seam. */} +
+ {children} +
+ + ); +} + const meta: Meta = { title: "UI/Agent/Sidebar", decorators: [ @@ -213,32 +260,34 @@ export const QueuedMessage: StoryObj = { * web-account) passes its own greeting. */ export const EmptyState: StoryObj = { render: () => ( - <> - {}} - onClose={() => {}} - history={mockAgentHistory} - onSelectHistoryItem={(id) => console.log("history", id)} - /> -
-
- - Hi, Sam, ask me anything about this app. -

- } - /> -
- console.log("Send:", msg)} - models={mockAgentModels} - selectedModelId={DEFAULT_MODEL_ID} + ( + {}} + onClose={() => {}} + history={mockAgentHistory} + onSelectHistoryItem={(id) => console.log("history", id)} + windowBodyHeight={windowBodyHeight} + /> + )} + > +
+ + Hi, Sam, ask me anything about this app. +

+ } />
- + console.log("Send:", msg)} + models={mockAgentModels} + selectedModelId={DEFAULT_MODEL_ID} + /> +
), }; @@ -246,33 +295,35 @@ export const EmptyState: StoryObj = { * their opener with no brand mark above it. */ export const EmptyStateNoLogo: StoryObj = { render: () => ( - <> - {}} - onClose={() => {}} - history={mockAgentHistory} - onSelectHistoryItem={(id) => console.log("history", id)} - /> -
-
- - Hi, Sam, ask me anything about this app. -

- } - /> -
- console.log("Send:", msg)} - models={mockAgentModels} - selectedModelId={DEFAULT_MODEL_ID} + ( + {}} + onClose={() => {}} + history={mockAgentHistory} + onSelectHistoryItem={(id) => console.log("history", id)} + windowBodyHeight={windowBodyHeight} + /> + )} + > +
+ + Hi, Sam, ask me anything about this app. +

+ } />
- + console.log("Send:", msg)} + models={mockAgentModels} + selectedModelId={DEFAULT_MODEL_ID} + /> +
), }; @@ -337,31 +388,33 @@ export const Playground: StoryObj = { }, [isStreaming, queued.length]); return ( - <> - {}} - onClose={() => {}} - history={mockAgentHistory} - onSelectHistoryItem={(id) => console.log("history", id)} - /> -
-
- -
- console.log("attach")} - onMic={() => console.log("mic")} - models={mockAgentModels} - selectedModelId={DEFAULT_MODEL_ID} - queuedMessages={queued} - onCancelQueued={(id) => - setQueued((q) => q.filter((m) => m.id !== id)) - } + ( + {}} + onClose={() => {}} + history={mockAgentHistory} + onSelectHistoryItem={(id) => console.log("history", id)} + windowBodyHeight={windowBodyHeight} /> + )} + > +
+
- + console.log("attach")} + onMic={() => console.log("mic")} + models={mockAgentModels} + selectedModelId={DEFAULT_MODEL_ID} + queuedMessages={queued} + onCancelQueued={(id) => + setQueued((q) => q.filter((m) => m.id !== id)) + } + /> +
); }, }; diff --git a/src/components/ui/agent/sidebar/AgentSidebarHeader.tsx b/src/components/ui/agent/sidebar/AgentSidebarHeader.tsx index 1e26c42cb..49b0e752e 100644 --- a/src/components/ui/agent/sidebar/AgentSidebarHeader.tsx +++ b/src/components/ui/agent/sidebar/AgentSidebarHeader.tsx @@ -36,6 +36,11 @@ export interface AgentSidebarHeaderProps { onDeleteConversation?: (id: string) => void; /** Label overrides. All have EN defaults. */ labels?: AgentSidebarHeaderLabels; + /** When set (px), render the whole window as ONE shape: a rounded body of this + * height with the active tab notched up out of its top edge, filled once — so the + * header tab + body are a single surface with no abutting edge to alias into a + * seam. The consumer MUST give the body element a transparent background. */ + windowBodyHeight?: number; } const TAB_WIDTH = 100; @@ -50,6 +55,19 @@ const TAB_IR = 12; const TABS_PAD = 40 + TAB_IR; const HEADER_H = 40; +// Shared appearance for the active-tab notch — used by BOTH the headOnly cap and +// the combined full-window shape; only their geometry / z-index / position differ. +// strokeWidth 0 (no stroke) means no half-px viewBox pad, so the fill is exactly +// its nominal size with no overhang. +const TAB_NOTCH_BASE = { + notchSide: "bottom", + radius: TAB_R, + inverseRadius: TAB_IR, + fill: "var(--color-on-background)", + stroke: "none", + strokeWidth: 0, +} as const; + // History dropdown const HIST_W = 260; const HIST_NOTCH_H = 24; @@ -75,6 +93,7 @@ export function AgentSidebarHeader({ onRenameConversation, onDeleteConversation, labels, + windowBodyHeight, }: AgentSidebarHeaderProps) { const isCollapsed = sidebarWidth <= 350; @@ -109,7 +128,7 @@ export function AgentSidebarHeader({ const nextIdRef = useRef(2); const headerRef = useRef(null); const activeTabRef = useRef(null); - const [activeTabRect, setActiveTabRect] = useState<{ left: number; width: number } | null>(null); + const [activeTabRect, setActiveTabRect] = useState<{ left: number; width: number; headerWidth: number } | null>(null); const historyBtnRef = useRef(null); const historyDropdownRef = useRef(null); const tabsContainerRef = useRef(null); @@ -200,7 +219,7 @@ export function AgentSidebarHeader({ const measure = () => { const tRect = tab.getBoundingClientRect(); const hRect = header.getBoundingClientRect(); - return { left: tRect.left - hRect.left, width: tRect.width }; + return { left: tRect.left - hRect.left, width: tRect.width, headerWidth: hRect.width }; }; setActiveTabRect(measure()); if (typeof ResizeObserver === "undefined") return; @@ -212,7 +231,12 @@ export function AgentSidebarHeader({ const next = measure(); flushSync(() => setActiveTabRect((prev) => - prev && prev.left === next.left && prev.width === next.width ? prev : next, + prev && + prev.left === next.left && + prev.width === next.width && + prev.headerWidth === next.headerWidth + ? prev + : next, ), ); }); @@ -269,27 +293,41 @@ export function AgentSidebarHeader({ tab on every ResizeObserver fire — without it, parent transitions (e.g. the wrapper's transition-all on open / drag-end) can bleed in via `all` and give the notch a perceptible easing curve. */} - {activeTabRect && ( - - )} + {activeTabRect && + (windowBodyHeight != null ? ( + // One filled shape: rounded body + active tab notched out of its top edge, + // so the tab and body are a single surface (no header↔body seam). + // notchSide="bottom" renders the tab on the screen TOP (getTransform + // rotates the build path) — the same convention the headOnly cap uses. + // Unlike the cap, the non-headOnly buildPath carves the TAB_IR flange from + // WITHIN notchHeight and curls it OUTSIDE notchOffset on its own — so pass + // notchHeight={HEADER_H} and notchOffset={left} directly; subtracting + // TAB_IR would double-count the curl (12px left shift) and seat the body + // 12px high. -z-10 keeps the shape behind all content. + + ) : ( + // Original active-tab cap: just the tab nub (headOnly), no body. + + ))} {/* History */}
@@ -301,7 +339,9 @@ export function AgentSidebarHeader({
{histContentH > 0 && (