← 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/decisions.md130 lines · View on GitHub

Decisions: Chat Panel

Name: Chat Panel (2026-10-19)

  • Decision: the component is called Chat Panel (ChatPanel).
  • Why: "Chat Drawer" would collide with the PDS Drawer and imply an overlay behavior this does not own; "Chat UI" is too broad (Chat Input and Chat Message are already chat UI); "Chat Container" says nothing about what is inside. PDS names its surfaces "Panel".
  • Port impact: none yet.

Slot props, not compound components (2026-10-19)

  • Decision: regions are props (title, header, footer, children), like Chat Input's aboveField / fieldHeader / actionsStart.
  • Why: it matches the proposals already here, so the port is mostly a copy. Teams change what is inside a slot without touching the frame. Compound parts (ChatPanel.Header) were rejected: they let a team reorder or repeat regions, which the layout can't support.
  • Port impact: none.

The footer is footer, not composer (2026-10-19)

  • Decision: the bottom slot is footer; the field stays named Chat Input.
  • Why: teams put other things there (a disclaimer, a stop bar). "Composer" names the area of pieces (Chat Input, Chat Attachment, the Send button, the model picker), not a slot.
  • Port impact: none.

The Compose / Chat / Review switch lives in header, the panel doesn't know it (2026-10-19)

  • Decision: the switch is the PDS SegmentedButton (size m) placed in header (first built with Tabs, changed at the designer's request); the screen swaps children when the value changes.
  • Why: what each tab contains is not this component's business.
  • Port impact: none.

Inset and bleed per region (2026-10-19)

  • Decision: headerInset, contentInset and footerInset take default or none. none removes the inline padding only (block padding stays); the child then supplies its own. The title row is always inset so the close button never touches the edge.
  • Why: a negative-margin utility class on the child breaks when padding changes. A per-region prop keeps the rule in one place.
  • Port impact: none.

Dividers are booleans, always on when set (2026-10-19)

  • Decision: hasHeaderDivider and hasFooterDivider, both default false. A on-scroll mode was not built.
  • Why: the reference has no rules; scroll-aware dividers need scroll listeners for a small gain.
  • Port impact: none.

Header pinned, scroll owned by the screen (2026-10-19)

  • Decision: title row, header and footer are pinned; only the content scrolls. Stick-to-bottom was first left to the screen; it is now opt-in through hasScrollToLatest (see "Scroll to latest and a content fade").
  • Why: the defaults chosen with the designer. A headerBehavior option and built-in autoscroll can be added if teams ask.
  • Port impact: none.

No container query (2026-10-19)

  • Decision: the panel fills its container and has no breakpoints of its own.
  • Why: nothing in it changes with width yet, so container-type would be unused. Add it with the first width-dependent rule.
  • Port impact: none.

Close button is the package CloseButton (2026-10-19)

  • Decision: hasCloseButton (default true), onClose and closeButtonLabel (the screen-reader text, no default so no English is hardcoded), same names as the PDS Drawer.
  • Why: reuse; matches Drawer's API.
  • Port impact: none.

Default inset matches the PDS Panel content (2026-10-19)

  • Decision: --chat-panel-inset is --pds-spacing-l, and --pds-spacing-xl from the m breakpoint up, the same as the PDS Panel's standard content padding (panel.css). It sets the inline padding of the title row and of default regions.
  • Why: the designer asked to use the existing default. The block padding of the regions (--pds-spacing-s / -m) is the panel's own and is not from the Panel.
  • Port impact: the query is written (min-width: 768px) because the custom media form doesn't work in this repo; restore @media (--pds-bp-m). The breakpoint is the viewport's, as in the Panel, not the panel's width.

Size: m and l, the PDS Drawer widths (2026-10-19)

  • Decision: size is m (25rem) or l (37.5rem), the Drawer's m and l; the Drawer's s (20rem) is not offered. m is the default. max-width: 100% keeps it inside narrow screens. (A fill size was added later the same day; see below.)
  • Why: the designer asked for a medium (default) and a large to match the reference drawer, shown through the controls, not a separate story. Named m / l, not md / lg, to match the Drawer and the other PDS size props.
  • Port impact: the Drawer and the panel each set a width; a drawer holding a size='m' panel should use the same size.

size='fill' removed (2026-10-19)

  • Decision: size is m (default) or l only; the fill option and its --fill modifier are removed.
  • Why: it was added for the full-screen chat, which is not needed for now. A team that needs a full-width panel can override the width with a class.
  • Port impact: none.

Scroll to latest and a content fade (2026-10-19)

  • Decision: two opt-in props. hasScrollToLatest (with scrollToLatestLabel) does two things from one piece of position tracking: it shows a floating button at the bottom center of the content area while the person is scrolled away from the end, and it keeps the view at the end as content grows, but only while they are at the end. hasContentFade is separate: a gradient mask fades the bottom edge of the content area into the footer, with bottom padding equal to the fade so the last line is clear at the end. Not built: a "new messages" dot or label on the button (the designer: not right now).
  • How it works: an invisible marker at the end of the content is watched by an IntersectionObserver (24px slack) to know whether the person is at the end. Content growth fires no scroll event, but a scroll by the person does, and that sets whether they are following; when growth pushes the marker out of view while they are following, the panel scrolls to the end. The button is outside the faded element, so the fade never dims it. Choosing it scrolls to the end (instantly with prefers-reduced-motion) and focuses the scroll area (tabindex=-1), so keyboard users keep their place when the button goes away. The fade needs an opaque mask color only, so it uses a foreground token.
  • Why: the recipe called scrollIntoView on every update, which yanked the view back to the bottom while the person was reading earlier messages. This partly reverses the earlier decision that the screen owns scrolling to the newest message: with the prop on, the panel owns it; without it, the screen still does.
  • Shape: the button is the standard square icon button (the input radius), bordered, with --pds-elevation-raised, not a circle. Two earlier decisions point that way (controls follow the input radius; the Send Button's circle was rejected), it needs no new modifier, and the floating cue comes from the shadow and the position. A circle is one border-radius away if wanted. The reference uses an arrow; the icon set has no arrowDown, so it was first the angleDown chevron (now anglesDown, see the last entry).
  • Port impact: adds the three props and the parts __end, __scroll, __scroll-button and --fade; the panel imports the local IconButton. The fade uses mask-image (with the -webkit- prefix); the scrollbar fades with the edge, which is hidden on macOS anyway.

Empty state heading is xl, set in the story (2026-10-19)

Note (2026-10-20): this sample now lives in the recipe Recipes/Chat with an agent, not in Chat Panel's stories.

  • Decision: the empty state in the stories uses the PDS VerticalEmptyState with its heading set to --pds-typography-size-xl by a story-only class. The PDS default is 2xl and the component has no size prop.
  • Why: the designer asked for an XL heading in the chat empty state.
  • Also: the description is pds-copy-xs in --pds-color-foreground-default-secondary (a <p> passed as message, with a story class for the color).
  • Port impact: the empty state is not part of Chat Panel. If it ships as a component, the engineer needs a heading size option on VerticalEmptyState or a local variant.

Trial: a neutral grey (silver) Tag in the sample header (2026-10-19)

Note (2026-10-20): the sample header is no longer in Chat Panel's stories; check the recipe Recipes/Chat with an agent for whether the trial survived.

  • Decision: the "Home" Tag in the sample header borrows the Badge's silver-muted tokens (a true neutral grey) through a story-only override of --tag-bg / --tag-fg. The Tag's own greyest color, slate, is blue-tinted and stone is beige.
  • Why: the designer wanted a greyer chip that still has the remove X (a Badge can't be removed).
  • Port impact: if kept, the PDS Tag needs a neutral color (for example silver) and the sample uses it; the override is then dropped. Not a Chat Panel change.

Drag and drop files over the whole panel (2026-10-19)

  • Decision: the panel owns the drop surface. onFilesDrop(files) turns it on for the whole panel (title row, header, content, footer); while files are dragged over, dropLabel and a file icon (file) show on a faded sheet over the content area only (pds-chat-panel__drop, pointer-events: none). The screen hands the files to the same handler it gives Chat Input (attachments, limits and errors stay with the screen and Chat Attachment).
  • Why: the target is the content area, outside the Chat Input's DOM, and the panel owns the regions. Chat Input keeps its own onFilesDrop for use without a panel.
  • Details: only dragged files react (not text or links). A counter on dragenter/dragleave stops flicker over children. A drop that Chat Input already took (it calls preventDefault before the event bubbles here) is not handed over twice: the panel skips a drop whose default is already prevented. A polite status region reads dropLabel to screen readers when the overlay appears. Dropping is mouse-only; the add menu in Chat Input is the keyboard route. ...props come last, so a consumer's own onDrop or onDrag* replaces the panel's.
  • Port impact: adds onFilesDrop and dropLabel, a __body wrapper around the content, and the overlay. The panel imports the local Icon for the file icon (the real one at port).

Thanks for the feedback fades back to the thumbs (2026-10-19)

  • Decision: in the sample, after a choice the thumbs are replaced by "Thanks for the feedback" for 4.5 seconds, then come back with the chosen thumb pressed. Pressing the pressed thumb clears the choice (no thanks). The timer is in the story (the screen), not in FeedbackBar, which stays UI only.
  • Why: a permanent thank-you under every reply is noise, but the bar must not vanish: the pressed thumb records the choice and lets people change their mind. The status is announced when it appears, so the delay is only for people who see it.
  • Port impact: the screen owns the delay; the component needs nothing.

The sample chat moved to a recipe (2026-10-19)

  • Decision: the working chat that used to be the With Sample Content story is now the recipe Recipes/Chat with an agent (proposals/ChatWithAgent/). Chat Panel keeps Default, Bleed and Content Only.
  • Why: the sample is the whole chat experience (the empty state, a moving chat input, a chain of thought, a suggestion card with a code diff, feedback thumbs, file drops), not the panel. Under Chat Panel it was hard to find, and a recipe is what the repo calls a composition of existing proposals.
  • Port impact: none; the panel is unchanged.

Open questions

  • 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. An engineer needs to decide whether the Drawer gets a "no wrapper" option or whether the panel docks only.
  • Empty state ("Work it out in conversation") is a composition in the recipe Recipes/Chat with an agent, not a component. Does it need its own component?
  • Dragging a file out of the window or dropping outside the browser can leave the overlay up until the next dragenter or drop; add a window-level dragend reset if that shows up.
  • Widths, breakpoints and small screens (needs the designer's specs).

Scroll to latest: opaque fill and double-angle icon (2026-10-19)

  • Decision: the floating button uses the anglesDown icon (was angleDown), and its fill rule is doubled up (.pds-chat-panel__scroll-button.pds-icon-button, plus the hover state) so it beats the IconButton's own background.
  • Why: the fill was not winning, so the content showed through the button (a chevron from the message above overlapped its own). The double angle reads as "jump to the end" and does not look like the chevron that toggles a section open.