← All proposals

Chat Panel

A layout shell for the content of an AI chat drawer or docked side panel.

Net-new componentOpen in Storybook

proposals/ChatPanel/HANDOFF.md107 lines · View on GitHub

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 Drawer and 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, not composer: teams also put a disclaimer or a stop bar there.
  • The view switch (Compose / Chat / Review) is a SegmentedButton the screen puts in header; the screen swaps children. The panel does not know about it.
  • headerInset, contentInset and footerInset (default or none) 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, then xl from the m breakpoint).
  • size is m (25rem, default) or l (37.5rem), the Drawer widths. A fill size for full-screen chat was added and removed the same day.
  • hasScrollToLatest (with scrollToLatestLabel): 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's scrollIntoView on every update, which yanked the view down while people read earlier messages. hasContentFade is separate. The button is a bordered icon button with --pds-elevation-raised, icon anglesDown.
  • 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 local IconButton (scroll button) and Icon (drop overlay file icon); mergeClasses from the local Button utils (_base/Button/utils); the pds-heading-l class for the drop label; the visually-hidden class. Stories compose ChatMessage and ChatMessageList and a story-only SlotPlaceholder.
  • 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-height and --chat-panel-inset. No raw values kept. Details in custom.md.

Accessibility

  • No role is set on the panel; it is a plain div. Give it a role or aria-label through ...props if the screen needs a landmark (for example role='complementary').
  • Close button: the package CloseButton, a real button with closeButtonLabel as 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 real IconButton with scrollToLatestLabel as label and tooltip, so it is reachable by Tab and works with Enter and Space. On activation, scrolling is instant if prefers-reduced-motion is set, and focus moves to the scroll area, since the button then disappears. The end marker is aria-hidden. Nothing is announced when the button appears.
  • Drag and drop: the overlay is aria-hidden; a visually hidden role='status' region (aria-live='polite') reads dropLabel when 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 hasScrollToLatest is 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 VerticalEmptyState or 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 dragend reset 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-radius away)? A "new messages" dot or label was left out.
  • Because ...props come last, a consumer's own onDrop or onDrag* 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).