Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
34 changes: 33 additions & 1 deletion src/components/layout/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,12 @@ function AppShellInner({
const maxWidthRef = useRef(800);
const dragWidthRef = useRef(0);
const sidebarElRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
const [agentBodyH, setAgentBodyH] = useState(0);

useEffect(() => {
const update = () => {
Expand All @@ -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 &&
Expand Down Expand Up @@ -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 } : {})}
/>

<div className="rounded-2xl bg-on-background flex-1 min-h-0 flex flex-col">
{/* 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. */}
<div ref={agentBodyRef} className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4">
{/* The host's chat surface when wired; otherwise the
personalized empty-state opener under the brand logo
Expand Down
199 changes: 126 additions & 73 deletions src/components/ui/agent/sidebar/AgentSidebar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <Notch> (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<HTMLDivElement>(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 <Notch> 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. */}
<div ref={agentBodyRef} className="flex-1 flex flex-col min-h-0">
{children}
</div>
</>
);
}

const meta: Meta = {
title: "UI/Agent/Sidebar",
decorators: [
Expand Down Expand Up @@ -213,66 +260,70 @@ export const QueuedMessage: StoryObj = {
* web-account) passes its own greeting. */
export const EmptyState: StoryObj = {
render: () => (
<>
<AgentSidebarHeader
sidebarWidth={420}
onToggleCollapse={() => {}}
onClose={() => {}}
history={mockAgentHistory}
onSelectHistoryItem={(id) => console.log("history", id)}
/>
<div className="flex-1 bg-on-background rounded-2xl flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-4">
<AgentSidebarMessages
messages={[]}
emptyState={
<p className="px-1 text-center text-sm text-inverse-on-surface/70">
Hi, Sam, ask me anything about this app.
</p>
}
/>
</div>
<AgentSidebarInput
onSend={(msg) => console.log("Send:", msg)}
models={mockAgentModels}
selectedModelId={DEFAULT_MODEL_ID}
<WindowFrame
header={(windowBodyHeight) => (
<AgentSidebarHeader
sidebarWidth={420}
onToggleCollapse={() => {}}
onClose={() => {}}
history={mockAgentHistory}
onSelectHistoryItem={(id) => console.log("history", id)}
windowBodyHeight={windowBodyHeight}
/>
)}
>
<div className="flex-1 overflow-y-auto p-4">
<AgentSidebarMessages
messages={[]}
emptyState={
<p className="px-1 text-center text-sm text-inverse-on-surface/70">
Hi, Sam, ask me anything about this app.
</p>
}
/>
</div>
</>
<AgentSidebarInput
onSend={(msg) => console.log("Send:", msg)}
models={mockAgentModels}
selectedModelId={DEFAULT_MODEL_ID}
/>
</WindowFrame>
),
};

/** Same empty thread with `hideEmptyStateLogo` — for hosts that want only
* their opener with no brand mark above it. */
export const EmptyStateNoLogo: StoryObj = {
render: () => (
<>
<AgentSidebarHeader
sidebarWidth={420}
onToggleCollapse={() => {}}
onClose={() => {}}
history={mockAgentHistory}
onSelectHistoryItem={(id) => console.log("history", id)}
/>
<div className="flex-1 bg-on-background rounded-2xl flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-4">
<AgentSidebarMessages
messages={[]}
hideEmptyStateLogo
emptyState={
<p className="px-1 py-2 text-sm text-inverse-on-surface/70">
Hi, Sam, ask me anything about this app.
</p>
}
/>
</div>
<AgentSidebarInput
onSend={(msg) => console.log("Send:", msg)}
models={mockAgentModels}
selectedModelId={DEFAULT_MODEL_ID}
<WindowFrame
header={(windowBodyHeight) => (
<AgentSidebarHeader
sidebarWidth={420}
onToggleCollapse={() => {}}
onClose={() => {}}
history={mockAgentHistory}
onSelectHistoryItem={(id) => console.log("history", id)}
windowBodyHeight={windowBodyHeight}
/>
)}
>
<div className="flex-1 overflow-y-auto p-4">
<AgentSidebarMessages
messages={[]}
hideEmptyStateLogo
emptyState={
<p className="px-1 py-2 text-sm text-inverse-on-surface/70">
Hi, Sam, ask me anything about this app.
</p>
}
/>
</div>
</>
<AgentSidebarInput
onSend={(msg) => console.log("Send:", msg)}
models={mockAgentModels}
selectedModelId={DEFAULT_MODEL_ID}
/>
</WindowFrame>
),
};

Expand Down Expand Up @@ -337,31 +388,33 @@ export const Playground: StoryObj = {
}, [isStreaming, queued.length]);

return (
<>
<AgentSidebarHeader
sidebarWidth={420}
onToggleCollapse={() => {}}
onClose={() => {}}
history={mockAgentHistory}
onSelectHistoryItem={(id) => console.log("history", id)}
/>
<div className="flex-1 bg-on-background rounded-2xl flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-4">
<AgentSidebarMessages messages={messages} />
</div>
<AgentSidebarInput
onSend={handleSend}
onAttachFile={() => 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))
}
<WindowFrame
header={(windowBodyHeight) => (
<AgentSidebarHeader
sidebarWidth={420}
onToggleCollapse={() => {}}
onClose={() => {}}
history={mockAgentHistory}
onSelectHistoryItem={(id) => console.log("history", id)}
windowBodyHeight={windowBodyHeight}
/>
)}
>
<div className="flex-1 overflow-y-auto p-4">
<AgentSidebarMessages messages={messages} />
</div>
</>
<AgentSidebarInput
onSend={handleSend}
onAttachFile={() => 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))
}
/>
</WindowFrame>
);
},
};
Loading
Loading