fix(desktop): Kanban board switcher outside the full-page layout

A Kanban board opened in a split route tile rendered no board switcher:
the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally,
and only the workspace pane paints that area. The tile's contribution
also leaked into another page's header and shared its id with the full
page's, so closing the tile removed the page's switcher.

Add WorkspacePageHeaderControl (exported via the plugin SDK). The
workspace pane's render provides a private host context; inside it the
control projects into the page header, anywhere else it renders inline.
The board mounts BoardSwitcher once, through it, in its own header row.

Fixes #123597

Originally authored by Justin Haynes (@jhaynes).
This commit is contained in:
Brooklyn Nicholson
2026-09-27 12:51:14 -05:00
committed by brooklyn!
parent 14c4b62e6a
commit aa25f9e85f
9 changed files with 380 additions and 18 deletions

View File

@@ -122,6 +122,7 @@ import { BASIC_TREE, DEFAULT_TREE, registerLayoutPresets } from './layout-preset
import { bindLayoutSides } from './layout-sides' import { bindLayoutSides } from './layout-sides'
import { FilesPane, LogsPane, ReviewPaneContent } from './panes' import { FilesPane, LogsPane, ReviewPaneContent } from './panes'
import { ContribWiring, WiredPane } from './wiring' import { ContribWiring, WiredPane } from './wiring'
import { WorkspacePageHeaderHostContext } from './workspace-page-header'
/** /**
* Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting * Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting
@@ -144,7 +145,13 @@ import { ContribWiring, WiredPane } from './wiring'
// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers // ONE render identity for the workspace pane — syncWorkspaceTitle re-registers
// the contribution (new title) and a fresh closure would remount the chat. // the contribution (new title) and a fresh closure would remount the chat.
const renderWorkspacePane = () => <WiredPane part="chatRoutes" /> // The host context marks this subtree as the one whose zone paints
// WORKSPACE_PAGE_HEADER_AREA; route tiles and the HUD render outside it.
const renderWorkspacePane = () => (
<WorkspacePageHeaderHostContext.Provider value={true}>
<WiredPane part="chatRoutes" />
</WorkspacePageHeaderHostContext.Provider>
)
// Boot-hidden panes mount behind display:none (instant-toggle contract) — defer // Boot-hidden panes mount behind display:none (instant-toggle contract) — defer
// them to idle so they're off the first-paint path, warm before reveal. // them to idle so they're off the first-paint path, warm before reveal.

View File

@@ -0,0 +1,107 @@
/**
* The workspace pane is the one host whose zone paints the page header
* (#123597). These read the REAL `workspace` contribution registered by the
* controller, render it through the real tree renderer, and check that a
* `WorkspacePageHeaderControl` lands in that header — and renders inline
* wherever the host context is absent.
*/
import { act, cleanup, render, screen, within } from '@testing-library/react'
import { type ReactNode, useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TreeGroup } from '@/components/pane-shell/tree/renderer/tree-group'
import { stubResizeObserver } from '@/test/jsdom'
import { $workspaceIsPage, WORKSPACE_PAGE_HEADER_AREA } from '../routes'
import { ContribWiringContext, WiredPane } from './context'
import type { WiringApi } from './types'
import { WorkspacePageHeaderControl } from './workspace-page-header'
const { registry } = await import('@/contrib/registry')
await import('./controller')
const workspace = () => registry.getArea('panes').find(c => c.id === 'workspace')!
const wiring = (chatRoutes: ReactNode) => ({ chatRoutes }) as unknown as WiringApi
const probe = (
<WorkspacePageHeaderControl id="probe:ctl">
<button type="button">probe-ctl</button>
</WorkspacePageHeaderControl>
)
const workspaceZone = (chatRoutes: ReactNode) => (
<ContribWiringContext.Provider value={wiring(chatRoutes)}>
<TreeGroup
leftEdge
node={{ active: 'workspace', id: 'main-zone', panes: ['workspace'], type: 'group' }}
rightEdge
/>
</ContribWiringContext.Provider>
)
afterEach(() => {
cleanup()
act(() => $workspaceIsPage.set(false))
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('workspace page header host', () => {
it('projects a hosted control into the painted page header, not the pane body', () => {
vi.stubGlobal('CSS', { escape: (value: string) => value })
stubResizeObserver()
act(() => $workspaceIsPage.set(true))
const { container } = render(workspaceZone(probe))
const header = container.querySelector<HTMLElement>('[data-panel-page-header]')
expect(header).not.toBeNull()
expect(within(header!).getAllByRole('button', { name: 'probe-ctl' })).toHaveLength(1)
expect(screen.getAllByRole('button', { name: 'probe-ctl' })).toHaveLength(1)
})
it('keeps one render identity and never remounts the pane across re-registration', () => {
vi.stubGlobal('CSS', { escape: (value: string) => value })
stubResizeObserver()
let mounts = 0
function MountCounter() {
useEffect(() => {
mounts += 1
}, [])
return <span>counter</span>
}
const render0 = workspace().render
render(workspaceZone(<MountCounter />))
act(() => $workspaceIsPage.set(true))
expect(workspace().render).toBe(render0)
act(() => $workspaceIsPage.set(false))
expect(workspace().render).toBe(render0)
const dispose = registry.register({ area: WORKSPACE_PAGE_HEADER_AREA, id: 'probe:area', render: () => null })
act(() => $workspaceIsPage.set(true))
act(() => dispose())
expect(workspace().render).toBe(render0)
expect(mounts).toBe(1)
})
it('renders inline and registers nothing outside the host (the HUD shape)', () => {
const { container } = render(
<ContribWiringContext.Provider value={wiring(probe)}>
<WiredPane part="chatRoutes" />
</ContribWiringContext.Provider>
)
expect(within(container).getAllByRole('button', { name: 'probe-ctl' })).toHaveLength(1)
expect(registry.getArea(WORKSPACE_PAGE_HEADER_AREA)).toHaveLength(0)
})
})

View File

@@ -0,0 +1,30 @@
/**
* Page-owned header controls (#123597). A page route can render in the
* workspace pane, whose zone paints `WORKSPACE_PAGE_HEADER_AREA` as the page
* header, or in a route tile, where nothing reads that area. The control asks
* WHERE it renders instead of reading the window-global `$workspaceIsPage`,
* which can't tell a tile's mount from the workspace's.
*/
import { createContext, type ReactNode, useContext } from 'react'
import { Contribute } from '@/contrib/react/contribute'
import { WORKSPACE_PAGE_HEADER_AREA } from '../routes'
/** True inside the workspace pane's routes, whose zone paints the page header.
* Provided only by the workspace pane registration (controller.tsx). The
* default is false, so any other host fails safe to rendering inline. */
export const WorkspacePageHeaderHostContext = createContext(false)
/** Page-owned control: projected into the workspace page header when this
* subtree is hosted by it; rendered inline, in place, anywhere else. */
export function WorkspacePageHeaderControl({ children, id }: { children: ReactNode; id: string }) {
return useContext(WorkspacePageHeaderHostContext) ? (
<Contribute area={WORKSPACE_PAGE_HEADER_AREA} id={id}>
{children}
</Contribute>
) : (
<>{children}</>
)
}

View File

@@ -0,0 +1,185 @@
/**
* Where the board switcher mounts (#123597). The full page projects it into
* the workspace page header; a route tile has no painted page header, so the
* same switcher sits in the board's own header row. These run the REAL
* registry, `Contribute`, `Slot` and `RouteTilePane` with only the REST
* layer mocked.
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// The placement under test lives in core: the route tile, the page-header host
// and the Slot that reads the area. Plugins can't reach those at runtime.
// eslint-disable-next-line no-restricted-imports
import { RouteTilePane } from '@/app/chat/route-tile'
// eslint-disable-next-line no-restricted-imports
import { WorkspacePageHeaderHostContext } from '@/app/contrib/workspace-page-header'
// eslint-disable-next-line no-restricted-imports
import { WORKSPACE_PAGE_HEADER_AREA } from '@/app/routes'
// eslint-disable-next-line no-restricted-imports
import { Slot } from '@/contrib/react/slot'
// eslint-disable-next-line no-restricted-imports
import { registry } from '@/contrib/registry'
// Test harness supplies the host's locale registration, as plugin loading does.
// eslint-disable-next-line no-restricted-imports
import { registerPluginLocales } from '@/i18n/plugin-i18n'
import type * as KanbanApi from './api'
import { $boardSlug } from './api'
import { KanbanBoardPage } from './board'
import { KANBAN_LOCALES } from './i18n'
vi.mock('./api', async importOriginal => ({
...(await importOriginal<typeof KanbanApi>()),
fetchBoards: vi.fn(async () => ({
boards: [
{ name: 'Shipping', project_id: null, slug: 'shipping', total: 3 },
{ name: 'Research', project_id: null, slug: 'research', total: 1 }
],
current: 'shipping'
})),
fetchBoard: vi.fn(async () => ({ assignees: [], columns: [], tenants: [] })),
fetchOrchestration: vi.fn(async () => ({ default_assignee: '' })),
fetchProfiles: vi.fn(async () => ({ profiles: [] }))
}))
// The trigger's accessible name, built from the loaded en strings
// (`${k.board}: ${label}`). Exact, so a copy change fails loudly.
const SWITCHER = 'Board: Shipping'
let disposeLocales: () => void = () => undefined
let disposePage: () => void = () => undefined
let queryClient = new QueryClient()
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
disposeLocales = registerPluginLocales('kanban', KANBAN_LOCALES)
disposePage = registry.register({
area: 'routes',
id: 'kanban:page',
data: { path: '/kanban' },
render: () => <KanbanBoardPage />
})
})
afterEach(() => {
cleanup()
disposePage()
disposeLocales()
$boardSlug.set('')
vi.restoreAllMocks()
})
const withQuery = (ui: ReactNode) => <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
// Another page's (or this page's) painted workspace header: the one Slot that
// reads the area, as the workspace zone renders it.
const pageHeader = () => (
<div data-testid="page-header">
<Slot area={WORKSPACE_PAGE_HEADER_AREA} />
</div>
)
// The full page: the workspace pane's routes, inside the host provider.
const workspacePage = () => (
<div data-testid="workspace">
<WorkspacePageHeaderHostContext.Provider value={true}>
<KanbanBoardPage />
</WorkspacePageHeaderHostContext.Provider>
</div>
)
const tile = () => (
<div data-testid="tile">
<RouteTilePane path="/kanban" />
</div>
)
const switchers = (scope: HTMLElement) => within(scope).queryAllByRole('button', { name: SWITCHER })
const boardHeader = (scope: HTMLElement) => within(scope).getByRole('banner')
const switcherEntries = () => registry.getArea(WORKSPACE_PAGE_HEADER_AREA).filter(c => c.id === 'kanban:board-switcher')
describe('kanban board switcher placement (#123597)', () => {
it('a split tile shows the switcher in the board header and contributes nothing to the page header', async () => {
const register = vi.spyOn(registry, 'register')
render(withQuery(tile()))
const inTile = screen.getByTestId('tile')
await within(boardHeader(inTile)).findByRole('button', { name: SWITCHER })
expect(switchers(boardHeader(inTile))).toHaveLength(1)
expect(registry.getArea(WORKSPACE_PAGE_HEADER_AREA)).toHaveLength(0)
expect(register.mock.calls.some(([c]) => c.area === WORKSPACE_PAGE_HEADER_AREA)).toBe(false)
})
it('the full page keeps the switcher in the page header, not the board header', async () => {
render(
withQuery(
<>
{pageHeader()}
{workspacePage()}
</>
)
)
const header = screen.getByTestId('page-header')
await within(header).findByRole('button', { name: SWITCHER })
expect(switchers(header)).toHaveLength(1)
expect(switchers(boardHeader(screen.getByTestId('workspace')))).toHaveLength(0)
expect(screen.getAllByRole('button', { name: SWITCHER })).toHaveLength(1)
})
it("a tile's switcher does not leak into an available page-header slot", async () => {
render(
withQuery(
<>
{pageHeader()}
{tile()}
</>
)
)
const inTile = screen.getByTestId('tile')
await within(boardHeader(inTile)).findByRole('button', { name: SWITCHER })
expect(switchers(boardHeader(inTile))).toHaveLength(1)
expect(switchers(screen.getByTestId('page-header'))).toHaveLength(0)
})
it('the full page and a tile each keep one switcher, and closing the tile leaves the page its own', async () => {
const view = render(
withQuery(
<>
{pageHeader()}
{workspacePage()}
{tile()}
</>
)
)
const header = screen.getByTestId('page-header')
await within(header).findByRole('button', { name: SWITCHER })
await within(boardHeader(screen.getByTestId('tile'))).findByRole('button', { name: SWITCHER })
expect(switchers(header)).toHaveLength(1)
expect(switchers(screen.getByTestId('tile'))).toHaveLength(1)
expect(switcherEntries()).toHaveLength(1)
view.rerender(
withQuery(
<>
{pageHeader()}
{workspacePage()}
</>
)
)
await waitFor(() => expect(screen.queryByTestId('tile')).toBeNull())
expect(switchers(screen.getByTestId('page-header'))).toHaveLength(1)
expect(switcherEntries()).toHaveLength(1)
})
})

View File

@@ -1,6 +1,8 @@
/** /**
* Board switcher projected through `WORKSPACE_PAGE_HEADER_AREA` into the * Board switcher. On the full page it is projected through
* workspace panel's tab-header space while the board page is mounted. * `WORKSPACE_PAGE_HEADER_AREA` into the workspace panel's tab-header space; in
* a split route tile it renders in the board's own header row. Placed by
* `WorkspacePageHeaderControl` (board.tsx).
*/ */
import { import {

View File

@@ -1,8 +1,9 @@
/** /**
* The Kanban board page — mounted at `/kanban` (a ROUTES_AREA contribution) in * The Kanban board page — mounted at `/kanban` (a ROUTES_AREA contribution) in
* the workspace pane. The desktop port of the dashboard board: one compact * the workspace pane or a split route tile. The desktop port of the dashboard
* header row (count, filter kebab, search, settings, new task — the board * board: one compact header row (count, board switcher, filter kebab, search,
* SWITCHER lives in the titlebar, see board-switcher.tsx), columns in * settings, new task — on the full page the switcher is projected into the
* page header instead, see WorkspacePageHeaderControl), columns in
* BOARD_COLUMNS order, drag-to-move (optimistic, workflow-checked), * BOARD_COLUMNS order, drag-to-move (optimistic, workflow-checked),
* primary-modifier-click multi-select with a floating bulk bar, right-click * primary-modifier-click multi-select with a floating bulk bar, right-click
* actions, and the detail drawer. Dispatch nudges ride every write (see api.ts). * actions, and the detail drawer. Dispatch nudges ride every write (see api.ts).
@@ -18,7 +19,6 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
Contribute,
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
@@ -49,7 +49,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
useValue, useValue,
WORKSPACE_PAGE_HEADER_AREA WorkspacePageHeaderControl
} from '@hermes/plugin-sdk' } from '@hermes/plugin-sdk'
import { import {
type CSSProperties, type CSSProperties,
@@ -1325,16 +1325,16 @@ export function KanbanBoardPage() {
return ( return (
<div className="relative flex h-full flex-col overflow-hidden bg-(--ui-surface-background)"> <div className="relative flex h-full flex-col overflow-hidden bg-(--ui-surface-background)">
{/* Page-owned header chrome: exists exactly while this page is mounted. */}
<Contribute area={WORKSPACE_PAGE_HEADER_AREA} id="kanban:board-switcher">
<BoardSwitcher />
</Contribute>
<header className="flex shrink-0 flex-wrap items-center gap-2 px-4 py-2"> <header className="flex shrink-0 flex-wrap items-center gap-2 px-4 py-2">
<h1 className="text-sm font-semibold text-foreground">{k.title}</h1> <h1 className="text-sm font-semibold text-foreground">{k.title}</h1>
<span className="rounded-full bg-(--ui-bg-quaternary) px-1.5 py-px text-[0.625rem] tabular-nums text-(--ui-text-tertiary)"> <span className="rounded-full bg-(--ui-bg-quaternary) px-1.5 py-px text-[0.625rem] tabular-nums text-(--ui-text-tertiary)">
{total} {total}
</span> </span>
{/* The full page projects this into its page header; a split tile has
none, so the switcher stays here in the row. */}
<WorkspacePageHeaderControl id="kanban:board-switcher">
<BoardSwitcher />
</WorkspacePageHeaderControl>
{board && ( {board && (
<FilterMenu <FilterMenu
archived={archived} archived={archived}

View File

@@ -1687,6 +1687,12 @@ export { SidebarRowLead } from '@/app/chat/sidebar/chrome'
export { ConnectionGlyph } from '@/app/chat/sidebar/connection-glyph' export { ConnectionGlyph } from '@/app/chat/sidebar/connection-glyph'
export { SIDEBAR_ROW_LEAD, SIDEBAR_TRUNCATED_LEADING } from '@/app/chat/sidebar/row-geometry' export { SIDEBAR_ROW_LEAD, SIDEBAR_TRUNCATED_LEADING } from '@/app/chat/sidebar/row-geometry'
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
/** Page-owned header control (the kanban board switcher): projected into the
* workspace page header when the page renders in the workspace pane, and
* rendered inline, in place, anywhere else (a split route tile). Prefer it
* over a raw `<Contribute area={WORKSPACE_PAGE_HEADER_AREA}>`, which nothing
* paints outside the workspace pane. */
export { WorkspacePageHeaderControl } from '@/app/contrib/workspace-page-header'
/** THE overdue test for a cron job's `next_run_at`: non-null once the stored slot /** THE overdue test for a cron job's `next_run_at`: non-null once the stored slot
* sits past the scheduler grace and the job is expected to fire. Every surface * sits past the scheduler grace and the job is expected to fire. Every surface
* that prints a next run switches its label on this (`t.cron.next` → * that prints a next run switches its label on this (`t.cron.next` →

View File

@@ -229,7 +229,7 @@ Import the area constants from the SDK; each area has its own `data` payload.
| Sidebar nav | `SIDEBAR_NAV_AREA` | `data: { path, label, codicon }` | | Sidebar nav | `SIDEBAR_NAV_AREA` | `data: { path, label, codicon }` |
| Status bar | `STATUSBAR_AREAS.left` / `.right` | `render` (or `data` as `StatusbarItem`) | | Status bar | `STATUSBAR_AREAS.left` / `.right` | `render` (or `data` as `StatusbarItem`) |
| Title bar | `TITLEBAR_AREAS.left` / `.center` / `.right` | `data` as `TitlebarTool`, or a mount-scoped `<Contribute>` | | Title bar | `TITLEBAR_AREAS.left` / `.center` / `.right` | `data` as `TitlebarTool`, or a mount-scoped `<Contribute>` |
| Page header | `WORKSPACE_PAGE_HEADER_AREA` | `render` via a mount-scoped `<Contribute>` inside your page | | Page header | `WORKSPACE_PAGE_HEADER_AREA` | `<WorkspacePageHeaderControl>` inside your page (inline in a split tile) |
| ⌘K palette | `PALETTE_AREA` | `data: PaletteContribution` | | ⌘K palette | `PALETTE_AREA` | `data: PaletteContribution` |
| Keybind | `KEYBINDS_AREA` | `data: KeybindContribution` | | Keybind | `KEYBINDS_AREA` | `data: KeybindContribution` |
| Theme | `THEMES_AREA` | `data` as a `DesktopTheme` | | Theme | `THEMES_AREA` | `data` as a `DesktopTheme` |
@@ -331,8 +331,12 @@ mid-navigation.
Controls that belong to ONE page (the Kanban board switcher) go in Controls that belong to ONE page (the Kanban board switcher) go in
`WORKSPACE_PAGE_HEADER_AREA` instead: it renders in the workspace panel's `WORKSPACE_PAGE_HEADER_AREA` instead: it renders in the workspace panel's
tab-header row while that page is on screen and is empty otherwise. Register it tab-header row while that page is on screen and is empty otherwise. Wrap the
with a mount-scoped `<Contribute>` (below) so it leaves with the page. control in `<WorkspacePageHeaderControl>` (below) inside your page's own header
row. In the workspace pane it projects into the page header; when the page is
opened in a split route tile, which has no page header, it renders inline where
you placed it. A raw `<Contribute area={WORKSPACE_PAGE_HEADER_AREA}>` only
shows up in the workspace pane.
### Palette commands and keybinds ### Palette commands and keybinds
@@ -813,6 +817,25 @@ jsx(Contribute, {
It registers on mount and disposes on unmount automatically. It registers on mount and disposes on unmount automatically.
For a page-header control, use `WorkspacePageHeaderControl` instead. It picks
the placement from where the page renders: in the workspace pane it
contributes to `WORKSPACE_PAGE_HEADER_AREA`, and anywhere else (a split route
tile) it renders its children in place. Put it where the control should sit
when inline:
```javascript
import { WorkspacePageHeaderControl } from '@hermes/plugin-sdk'
jsx(WorkspacePageHeaderControl, {
id: 'my-page:switcher', // namespace with your slug
children: jsx(MySwitcher, {})
})
```
`WorkspacePageHeaderControl` is new in this release. A plugin that must also
run on older desktop builds, where the import is `undefined`, keeps the raw
`Contribute` form above.
### Sidebar nav visibility and order (`SIDEBAR_NAV_PREFS_AREA`) ### Sidebar nav visibility and order (`SIDEBAR_NAV_PREFS_AREA`)
A plugin hides or re-orders the sidebar's top nav rows by **contributing a A plugin hides or re-orders the sidebar's top nav rows by **contributing a
@@ -1551,7 +1574,7 @@ pipeline as a trust boundary.
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` | | Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS` | | Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS` |
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` | | Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` |
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` | | React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute`, `WorkspacePageHeaderControl` |
| Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) | | Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) |
| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText`, `SandboxedFrame` | | UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText`, `SandboxedFrame` |
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness`, `catalogProviderMatches` | | Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness`, `catalogProviderMatches` |

View File

@@ -235,7 +235,9 @@ In the Desktop app the board switcher sits in the header row at the top of
the Kanban page, beside the page title: a **Board** control showing the current board's the Kanban page, beside the page title: a **Board** control showing the current board's
name and task count, with a chevron — hover it for "Switch board". Click name and task count, with a chevron — hover it for "Switch board". Click
it to pick another board, or to rename, configure, export, import, it to pick another board, or to rename, configure, export, import,
create, or archive boards. Like the dashboard, the desktop keeps its own create, or archive boards. When Kanban is open in a split tile, the same
**Board** control sits in the board's own header row, after the task count.
Like the dashboard, the desktop keeps its own
selection (persisted locally) and does not move the CLI's `current` selection (persisted locally) and does not move the CLI's `current`
pointer. pointer.