Skip to content

PageListToolbar

A shared layout component for the filter/action toolbar row that appears on account list pages (Channels, Workspaces, Knowledge Bases).


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.


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.

src/components/page-list-toolbar.tsx
interface 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;
}
<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 of viewSwitcher or actions is present, avoiding an empty <div>.


<PageListToolbar
filters={
<>
<MultiSelectCombobox allLabel="All workspaces" ... />
<MultiSelectCombobox allLabel="All knowledge bases" ... />
</>
}
actions={<AddConnectionButtonGroup ... />}
/>
<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>
}
/>

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.


  1. Every account list page that has a filter/action bar must use PageListToolbar.
  2. Filters always go in the filters prop (left side, flex-1).
  3. View switchers always go in the viewSwitcher prop (right side, before actions).
  4. The primary CTA always goes in the actions prop (rightmost).
  5. Do not add a second standalone toolbar row for the view switcher — put it in viewSwitcher.
  6. Do not inline the outer flex items-center gap-2 border-b border-border px-6 py-4 styles on the page — let the component own them.

  • 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-b toolbar row (lines 154–160 of knowledge-bases-page-client.tsx) and merging its ViewSwitcherPill into the viewSwitcher prop of the single PageListToolbar.
  • No other behavioral changes are needed.

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 / fileElementpypxleadinghover
Workspaces workspaces-page-client.tsx<Button variant="default">py-1.5px-3hover:opacity-80
Knowledge Bases knowledge-bases-page-client.tsx<Button asChild variant="default">py-2.5px-3leading-[100%]hover:opacity-80
Channels add-connection-button-group.tsx<Button variant="default">py-1.5px-3hover:opacity-80
Workspace Open [workspaceId]/page.tsxbare <Link> (no Button)py-1.5px-2.5hover:opacity-90

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-80

Combined 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 explicit py — with h-9 the button uses flexbox centering; an explicit py would 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-9 and not h-auto?
MultiSelectCombobox and GroupFilterCombobox triggers are both h-9 (36 px). Using h-auto on the CTA produced a 28 px button next to 36 px filter controls — an 8 px height mismatch visible on every toolbar. h-9 aligns all controls to the same row height.

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>

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.


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 PageListToolbarButton wrapper component. The Button component already handles all variants and asChild behaviour. A constant is the right level of abstraction here — it enforces visual consistency without adding an unnecessary component layer.