PageListToolbar
A shared layout component for the filter/action toolbar row that appears on account list pages (Channels, Workspaces, Knowledge Bases).
Audit: current toolbar state per page
Section titled “Audit: current toolbar state per page”Channels (src/app/account/channels/page.tsx, lines 1207–1238)
Section titled “Channels (src/app/account/channels/page.tsx, lines 1207–1238)”<div className="flex items-center gap-2 border-b border-border px-6 py-4"> <div className="flex flex-1 items-center gap-2"> ← filters slot <MultiSelectCombobox allLabel="All workspaces" /> <MultiSelectCombobox allLabel="All knowledge bases" /> </div> <AddConnectionButtonGroup /> ← actions slot (no view switcher)</div>- Filters: 2 ×
MultiSelectCombobox - View switcher: absent
- Primary CTA:
AddConnectionButtonGroup(a compound button/popover, not a plain<Button>)
Workspaces (src/app/account/workspaces/_components/workspaces-page-client.tsx, lines 246–275)
Section titled “Workspaces (src/app/account/workspaces/_components/workspaces-page-client.tsx, lines 246–275)”<div className="flex items-center gap-2 border-b border-border px-6 py-4"> <div className="flex flex-1 items-center gap-2"> ← filters slot <MultiSelectCombobox allLabel="All workspaces" /> <GroupFilterCombobox /> </div> <div className="flex items-center gap-2"> ← actions slot <ViewSwitcherPill /> ← view switcher <Button>Add workspace</Button> ← primary CTA </div></div>- Filters:
MultiSelectCombobox+GroupFilterCombobox - View switcher:
ViewSwitcherPill(card / table) - Primary CTA:
<Button variant="default">Add workspace</Button>
Knowledge Bases (src/app/account/knowledge-bases/_components/knowledge-bases-page-client.tsx, lines 128–160)
Section titled “Knowledge Bases (src/app/account/knowledge-bases/_components/knowledge-bases-page-client.tsx, lines 128–160)”{/* Row 1 – filters + CTA */}<div className="flex items-center gap-2 border-b border-border px-6 py-4"> <div className="flex flex-1 items-center gap-2"> ← filters slot <MultiSelectCombobox allLabel="All workspaces" /> </div> <Button asChild>Add knowledge base</Button> ← primary CTA</div>
{/* Row 2 – view switcher only */}<div className="flex items-center gap-2 border-b border-border px-6 py-4"> <div className="flex flex-1" /> <ViewSwitcherPill /> ← view switcher</div>- Filters: 1 ×
MultiSelectCombobox - View switcher:
ViewSwitcherPill— but in a separate second row (diverges from Workspaces) - Primary CTA:
<Button asChild>(Link to workspace detail page)
Inconsistency: Knowledge Bases splits what Workspaces combines into one row. This is the main divergence to fix.
Decision: named shared component
Section titled “Decision: named shared component”Recommendation: create PageListToolbar as a thin layout-only component.
Rationale:
- All three pages share identical outer styles (
flex items-center gap-2 border-b border-border px-6 py-4). - The inner content varies enough that prop-drilling specific controls would make the component too opinionated. Slots (children props) keep it flexible.
- A named component enforces the padding/border contract and prevents future pages from drifting (e.g. Knowledge Bases’ second toolbar row).
- It is not a design-system primitive — it is an app-level layout shell, so it lives in
src/components/alongside other shared app components.
Component API
Section titled “Component API”File location
Section titled “File location”src/components/page-list-toolbar.tsxinterface PageListToolbarProps { /** * Filter controls rendered on the left (flex-1, gap-2). * Typically one or more MultiSelectCombobox / GroupFilterCombobox. */ filters?: React.ReactNode;
/** * View switcher control (e.g. ViewSwitcherPill). * Rendered between filters and the primary action, right-aligned. * Optional — omit on pages that have no view toggle (Channels). */ viewSwitcher?: React.ReactNode;
/** * Primary call-to-action rendered at the far right. * Can be a Button, AddConnectionButtonGroup, or any ReactNode. * Optional — omit if the toolbar has no CTA. */ actions?: React.ReactNode;
/** Extra class names forwarded to the outer div. Rarely needed. */ className?: string;}Rendered structure
Section titled “Rendered structure”<div className={cn( "flex items-center gap-2 border-b border-border px-6 py-4", className)}> {/* Left: filters */} <div className="flex flex-1 items-center gap-2"> {filters} </div>
{/* Right: optional view switcher + optional actions */} {(viewSwitcher || actions) && ( <div className="flex items-center gap-2"> {viewSwitcher} {actions} </div> )}</div>The right-side wrapper (
flex items-center gap-2) is only rendered when at least one ofviewSwitcheroractionsis present, avoiding an empty<div>.
Usage per page
Section titled “Usage per page”Channels
Section titled “Channels”<PageListToolbar filters={ <> <MultiSelectCombobox allLabel="All workspaces" ... /> <MultiSelectCombobox allLabel="All knowledge bases" ... /> </> } actions={<AddConnectionButtonGroup ... />}/>Workspaces
Section titled “Workspaces”<PageListToolbar filters={ <> <MultiSelectCombobox allLabel="All workspaces" ... /> <GroupFilterCombobox ... /> </> } viewSwitcher={<ViewSwitcherPill value={view} onChange={handleSetView} />} actions={ <Button variant="default" onClick={() => setAddDialogOpen(true)}> <Icon name="add" size={16} /> Add workspace </Button> }/>Knowledge Bases
Section titled “Knowledge Bases”Collapse the current two-row pattern into one:
<PageListToolbar filters={ <MultiSelectCombobox allLabel="All workspaces" ... /> } viewSwitcher={<ViewSwitcherPill value={view} onChange={handleSetView} />} actions={ <Button asChild variant="default"> <Link href={`/account/workspaces/${DEFAULT_WORKSPACE_ID}`}> <Icon name="add" size={16} /> Add knowledge base </Link> </Button> }/>This eliminates the second standalone toolbar row and aligns Knowledge Bases with the Workspaces pattern.
Rules for future pages
Section titled “Rules for future pages”- Every account list page that has a filter/action bar must use
PageListToolbar. - Filters always go in the
filtersprop (left side, flex-1). - View switchers always go in the
viewSwitcherprop (right side, before actions). - The primary CTA always goes in the
actionsprop (rightmost). - Do not add a second standalone toolbar row for the view switcher — put it in
viewSwitcher. - Do not inline the outer
flex items-center gap-2 border-b border-border px-6 py-4styles on the page — let the component own them.
Implementation notes for coder
Section titled “Implementation notes for coder”- The component is a pure layout shell — no state, no context.
- Import path:
@/components/page-list-toolbar. - After creating the component, update the three pages above to use it.
- Knowledge Bases requires removing the second
border-btoolbar row (lines 154–160 ofknowledge-bases-page-client.tsx) and merging itsViewSwitcherPillinto theviewSwitcherprop of the singlePageListToolbar. - No other behavioral changes are needed.
Canonical CTA spec for actions slot
Section titled “Canonical CTA spec for actions slot”Problem
Section titled “Problem”Every PageListToolbar usage passes a primary CTA in the actions prop, but each page has independently authored the button’s Tailwind classes. The result is subtle visual drift:
| Page / file | Element | py | px | leading | hover |
|---|---|---|---|---|---|
Workspaces workspaces-page-client.tsx | <Button variant="default"> | py-1.5 | px-3 | — | hover:opacity-80 |
Knowledge Bases knowledge-bases-page-client.tsx | <Button asChild variant="default"> | py-2.5 | px-3 | leading-[100%] | hover:opacity-80 |
Channels add-connection-button-group.tsx | <Button variant="default"> | py-1.5 | px-3 | — | hover:opacity-80 |
Workspace Open [workspaceId]/page.tsx | bare <Link> (no Button) | py-1.5 | px-2.5 | — | hover:opacity-90 |
Canonical class list
Section titled “Canonical class list”All primary CTAs in PageListToolbar actions slots must use exactly:
h-9 gap-1.5 rounded-[8px] px-3 text-[14px] font-medium leading-none transition-opacity hover:opacity-80Combined with <Button variant="default"> (or <Button asChild variant="default"> for link-CTAs), this produces:
- Height:
h-9— fixed 36 px, matching all filter controls (MultiSelectCombobox,GroupFilterCombobox) in the same toolbar row. - Padding:
px-3— 12 px horizontal. No explicitpy— withh-9the button uses flexbox centering; an explicitpywould fight the fixed height. - Gap (icon + label):
gap-1.5— 6 px between leading icon and text. - Border radius:
rounded-[8px]. - Font:
text-[14px] font-medium— 14 px, medium weight. - Line height:
leading-none— collapses line-height to 1 so text does not affect the vertical rhythm. - Hover:
transition-opacity hover:opacity-80— consistent fade, not a background shift. - Color / background: inherited from
variant="default"(bg-primary text-primary-foreground).
Why
h-9and noth-auto?
MultiSelectComboboxandGroupFilterComboboxtriggers are bothh-9(36 px). Usingh-autoon the CTA produced a 28 px button next to 36 px filter controls — an 8 px height mismatch visible on every toolbar.h-9aligns all controls to the same row height.
Element to use
Section titled “Element to use”Always use <Button> from @/components/ui/button, never a bare <a> or <Link>.
For link-CTAs (navigates to a route), use <Button asChild variant="default"> wrapping a <Link>.
// Button CTA (opens dialog / triggers action)<Button variant="default" className="h-9 gap-1.5 rounded-[8px] px-3 text-[14px] font-medium leading-none transition-opacity hover:opacity-80" onClick={onOpen}> <Icon name="add" size={16} className="text-primary-foreground" /> Add workspace</Button>
// Link CTA (navigates to a route)<Button asChild variant="default" className="h-9 gap-1.5 rounded-[8px] px-3 text-[14px] font-medium leading-none transition-opacity hover:opacity-80"> <Link href="/account/workspaces/ws-1"> <Icon name="add" size={16} className="text-primary-foreground" /> Add knowledge base </Link></Button>Compound / popover CTAs
Section titled “Compound / popover CTAs”AddConnectionButtonGroup (Channels page) renders a <Button> internally. Its internal button classes must also match the canonical spec. The component imports and uses PAGE_LIST_TOOLBAR_CTA_CLS directly, so it stays in sync automatically.
Shared className constant
Section titled “Shared className constant”The canonical class string is exported from src/components/page-list-toolbar.tsx as PAGE_LIST_TOOLBAR_CTA_CLS:
/** * Canonical Tailwind classes for a primary CTA button in a PageListToolbar actions slot. * Apply to <Button variant="default" className={PAGE_LIST_TOOLBAR_CTA_CLS}>. * h-9 aligns the CTA to the same 36 px height as MultiSelectCombobox and GroupFilterCombobox triggers. */export const PAGE_LIST_TOOLBAR_CTA_CLS = "h-9 gap-1.5 rounded-[8px] px-3 text-[14px] font-medium leading-none transition-opacity hover:opacity-80";All CTA files import and use this constant. Future height or spacing changes are a one-line edit here.
Do not create a new
PageListToolbarButtonwrapper component. TheButtoncomponent already handles all variants andasChildbehaviour. A constant is the right level of abstraction here — it enforces visual consistency without adding an unnecessary component layer.