Handoff: ChatPanel
What and why
The frame of an AI chat drawer or docked side panel: a title row with a built-in close button, a pinned header, a scrolling content area and a pinned footer. Each AI chat screen would otherwise lay out the header, context rows, conversation and composer by hand, and the chat pieces proposed here (Chat Input, Chat Message, Chat Attachment, Suggestion Card, Agent Avatar, Identity Block) would have nowhere to sit together. PDS has no such component. The panel owns only the layout, the scroll and the inset; every region is a slot the team fills. It is the content, not the overlay: place it inside a drawer or dock it beside the page. Figma: Chat Panel page (the designer's hand-built component set, not a capture). Context: context.md.
Exploration and decisions
- Named Chat Panel: "Chat Drawer" would collide with the PDS
Drawerand imply an overlay; PDS names its surfaces "Panel". - Slot props (
title,header,footer,children), not compound parts: matches Chat Input's slots, and teams cannot reorder or repeat regions. - The footer slot is
footer, notcomposer: teams also put a disclaimer or a stop bar there. - The view switch (Compose / Chat / Review) is a
SegmentedButtonthe screen puts inheader; the screen swapschildren. The panel does not know about it. headerInset,contentInsetandfooterInset(defaultornone) per region instead of negative-margin classes on the child. The title row is always inset.- Dividers are plain booleans, always on when set. A scroll-aware mode was not built.
- Close button is the package
CloseButton, with Drawer-style names (hasCloseButton,onClose,closeButtonLabel; no default label, so no English in the component). - Default inset matches the PDS Panel content (
l, thenxlfrom thembreakpoint). sizeism(25rem, default) orl(37.5rem), the Drawer widths. Afillsize for full-screen chat was added and removed the same day.hasScrollToLatest(withscrollToLatestLabel): a floating button while scrolled away from the end, and the view follows growing content only while the person is at the end. Replaces the recipe'sscrollIntoViewon every update, which yanked the view down while people read earlier messages.hasContentFadeis separate. The button is a bordered icon button with--pds-elevation-raised, iconanglesDown.onFilesDrop+dropLabel: drag and drop over the whole panel, overlay over the content area only.- The working chat sample moved out to the recipe
Recipes/Chat with an agent.
Full list and reasons: decisions.md.
API
ChatPanel (a div; extends div props except title):
| Prop | Type | Default | Purpose |
|---|---|---|---|
children |
ReactNode |
none | The content area. It is the only region that scrolls. |
className |
string |
none | Additional class names. |
closeButtonLabel |
string |
none | Screen-reader text for the close button. Pass it whenever hasCloseButton is on. |
contentInset |
'default' | 'none' |
'default' |
Inline padding of the content area. none bleeds to the edges. |
dropLabel |
string |
none | Text on the drop overlay, also announced when it appears. |
footer |
ReactNode |
none | Pinned below the content (for example the composer). |
footerInset |
'default' | 'none' |
'default' |
Inline padding of the footer. |
hasCloseButton |
boolean |
true |
Shows the built-in close button in the title row. |
hasContentFade |
boolean |
false |
Fades the bottom edge of the content into the footer; the last line stays clear at the end. |
hasFooterDivider |
boolean |
false |
Rule above the footer. |
hasHeaderDivider |
boolean |
false |
Rule under the header. |
hasScrollToLatest |
boolean |
false |
Floating scroll-to-latest button, and follow-the-end while the person is at the end. Pass scrollToLatestLabel with it. |
header |
ReactNode |
none | Pinned under the title row (summary strip, context rows, tabs). |
headerInset |
'default' | 'none' |
'default' |
Inline padding of the header. |
onClose |
() => void |
none | Called when the close button is clicked. |
onFilesDrop |
(files: File[]) => void |
none | Setting it turns on file drop for the whole panel. Dragged text and links are ignored; a drop a child already handled (Chat Input) is not handed over again. |
scrollToLatestLabel |
string |
none | Screen-reader text and tooltip for the scroll button. |
size |
'm' | 'l' |
'm' |
Preset width, as the Drawer: 25rem or 37.5rem. It fills the height of its container. |
title |
ReactNode |
none | The title row, beside the close button (for example an Identity Block). |
Exported types: ChatPanelInset, ChatPanelSize, ChatPanelProps.
Variants and states: size m / l; inset default / none per region; dividers; content fade; scroll to latest (button hidden at the end, shown when scrolled away); drag over (overlay shown). Every region is optional; the title row renders only if there is a title or hasCloseButton. Stories: Default, Bleed, Content Only, Scrolling. The drag overlay and the full chat are in the recipe Recipes/Chat with an agent.
Tokens used
--pds-border-width-default--pds-color-border-default-secondary--pds-color-foreground-default--pds-color-surface-default--pds-elevation-drawer-right(story frame only)--pds-elevation-raised--pds-spacing-2xl--pds-spacing-l--pds-spacing-m--pds-spacing-s--pds-spacing-xl
Impact
From impact.md: no existing PDS component or token is changed. One opt-in addition: a doubled selector (.pds-chat-panel__scroll-button.pds-icon-button, plus its hover state) overrides the IconButton background so content does not show through; it applies only where the net-new class is added. The report lists no proposal that imports ChatPanel, but the recipe Recipes/Chat with an agent (proposals/ChatWithAgent/) composes it, and any change to the panel's regions or scroll behaviour should be checked there.
Reused vs net-new
- Reused: package
CloseButton; the localIconButton(scroll button) andIcon(drop overlayfileicon);mergeClassesfrom the local Button utils (_base/Button/utils); thepds-heading-lclass for the drop label; thevisually-hiddenclass. Stories composeChatMessageandChatMessageListand a story-onlySlotPlaceholder. - Net-new:
.pds-chat-panel(--m,--l,__body,__close,__content,__content--fade,__content--inset-default,__drop,__drop-icon,__drop-label,__end,__footer,__footer--divider,__footer--inset-default,__header,__header--divider,__header--inset-default,__scroll,__scroll-button,__title,__title-row); all props; custom CSS variables--chat-panel-fade-heightand--chat-panel-inset. No raw values kept. Details incustom.md.
Accessibility
- No role is set on the panel; it is a plain
div. Give it aroleoraria-labelthrough...propsif the screen needs a landmark (for examplerole='complementary'). - Close button: the package
CloseButton, a real button withcloseButtonLabelas its label. The label has no default, so an unlabeled button results if it is omitted. - Scroll to latest: when on, the scroll area gets
tabindex='-1'(focusable by script, not in the tab order). The button is a realIconButtonwithscrollToLatestLabelas label and tooltip, so it is reachable by Tab and works with Enter and Space. On activation, scrolling is instant ifprefers-reduced-motionis set, and focus moves to the scroll area, since the button then disappears. The end marker isaria-hidden. Nothing is announced when the button appears. - Drag and drop: the overlay is
aria-hidden; a visually hiddenrole='status'region (aria-live='polite') readsdropLabelwhen files are dragged over. Dropping is mouse-only; the keyboard route is the add menu in Chat Input, which must still exist. - The scroll area is not itself labeled or tabbable when
hasScrollToLatestis off. Keyboard users reach scrolling through focusable content inside it; check this with long non-interactive content. - The a11y panel has not been run, and light and dark were not checked by eye in a session.
Decisions and open questions
For the engineer or designer to settle:
- Inside the PDS
Drawer: the Drawer wraps its children in a padded, scrolling content box and adds its own title row and close button. No Drawer story is built; the panel is shown docked. Decide whether the Drawer gets a "no wrapper" option, or the panel docks only. - Does the empty state ("Work it out in conversation", in the recipe) need its own component? Its XL heading would need a size option on
VerticalEmptyStateor a local variant. - Dragging a file out of the window can leave the drop overlay up until the next dragenter or drop; add a window-level
dragendreset if it shows up. - Widths, breakpoints and small screens need the designer's specs. There is no container query; the inset breakpoint is the viewport's (as in the Panel), not the panel's width.
- Should the scroll button stay a square icon button, or become a circle (one
border-radiusaway)? A "new messages" dot or label was left out. - Because
...propscome last, a consumer's ownonDroporonDrag*replaces the panel's handlers. - The sample header's neutral grey Tag trial (a PDS Tag has no neutral color) belongs to the recipe, not this component.
Port notes
Would live in src/components/ (a chat or conversation category folder; generator npm run generate:component). Not yet matching v2: the local mergeClasses stand-in (use @libs/components/utils); the local Icon and IconButton wrappers (use the ported ones); the inset breakpoint is written @media (min-width: 768px) because the custom media form does not work in this repo, so restore @media (--pds-bp-m); the scroll button override relies on a doubled selector against the IconButton background; the fade uses mask-image with the -webkit- prefix. A drawer holding a size='m' panel should use the same size.
Dependencies (from node scripts/port-order.mjs --needs=ChatPanel): Button, Icon, IconButton, Spinner, ChatMessage (composed in the stories) and AgentAvatar. All are marked ready for dev, so none is blocking. Port IconButton and Icon before this one.
Preview
npm run storybook, story Component Proposals / AI Chat / Chat Panel (change size in the controls). The full chat is Recipes / Chat with an agent. Figma: https://www.figma.com/design/AMGWJbraJoi2PKWnxo29Ah/PDS-Proposals?node-id=255-468, page ↳ Chat Panel (the designer's component set, not a capture).