()),
+ 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: () =>
+ })
+})
+
+afterEach(() => {
+ cleanup()
+ disposePage()
+ disposeLocales()
+ $boardSlug.set('')
+ vi.restoreAllMocks()
+})
+
+const withQuery = (ui: ReactNode) => {ui}
+
+// 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 = () => (
+
+
+
+)
+
+// The full page: the workspace pane's routes, inside the host provider.
+const workspacePage = () => (
+
+
+
+
+
+)
+
+const tile = () => (
+
+
+
+)
+
+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)
+ })
+})
diff --git a/apps/desktop/src/plugins/kanban/board-switcher.tsx b/apps/desktop/src/plugins/kanban/board-switcher.tsx
index b08f937b88..fa77ef89a1 100644
--- a/apps/desktop/src/plugins/kanban/board-switcher.tsx
+++ b/apps/desktop/src/plugins/kanban/board-switcher.tsx
@@ -1,6 +1,8 @@
/**
- * Board switcher projected through `WORKSPACE_PAGE_HEADER_AREA` into the
- * workspace panel's tab-header space while the board page is mounted.
+ * Board switcher. On the full page it is projected through
+ * `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 {
diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx
index 79f0509507..d09a122008 100644
--- a/apps/desktop/src/plugins/kanban/board.tsx
+++ b/apps/desktop/src/plugins/kanban/board.tsx
@@ -1,8 +1,9 @@
/**
* The Kanban board page — mounted at `/kanban` (a ROUTES_AREA contribution) in
- * the workspace pane. The desktop port of the dashboard board: one compact
- * header row (count, filter kebab, search, settings, new task — the board
- * SWITCHER lives in the titlebar, see board-switcher.tsx), columns in
+ * the workspace pane or a split route tile. The desktop port of the dashboard
+ * board: one compact header row (count, board switcher, filter kebab, search,
+ * 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),
* 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).
@@ -18,7 +19,6 @@ import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
- Contribute,
Dialog,
DialogContent,
DialogFooter,
@@ -49,7 +49,7 @@ import {
useQuery,
useQueryClient,
useValue,
- WORKSPACE_PAGE_HEADER_AREA
+ WorkspacePageHeaderControl
} from '@hermes/plugin-sdk'
import {
type CSSProperties,
@@ -1325,16 +1325,16 @@ export function KanbanBoardPage() {
return (
- {/* Page-owned header chrome: exists exactly while this page is mounted. */}
-
-
-
-
{k.title}
{total}
+ {/* The full page projects this into its page header; a split tile has
+ none, so the switcher stays here in the row. */}
+
+
+
{board && (
`, 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
* 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` →
diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md
index 9a3122cc35..5bbc349a83 100644
--- a/website/docs/developer-guide/desktop-plugin-sdk.md
+++ b/website/docs/developer-guide/desktop-plugin-sdk.md
@@ -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 }` |
| 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 `` |
-| Page header | `WORKSPACE_PAGE_HEADER_AREA` | `render` via a mount-scoped `` inside your page |
+| Page header | `WORKSPACE_PAGE_HEADER_AREA` | `` inside your page (inline in a split tile) |
| ⌘K palette | `PALETTE_AREA` | `data: PaletteContribution` |
| Keybind | `KEYBINDS_AREA` | `data: KeybindContribution` |
| 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
`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
-with a mount-scoped `` (below) so it leaves with the page.
+tab-header row while that page is on screen and is empty otherwise. Wrap the
+control in `` (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 `` only
+shows up in the workspace pane.
### Palette commands and keybinds
@@ -813,6 +817,25 @@ jsx(Contribute, {
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`)
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` |
| 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` |
-| 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`) |
| 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` |
diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md
index 0363bbd6c8..b682fb8917 100644
--- a/website/docs/user-guide/features/kanban.md
+++ b/website/docs/user-guide/features/kanban.md
@@ -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
name and task count, with a chevron — hover it for "Switch board". Click
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`
pointer.