diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 7ce186c26b..f746ab7dca 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -122,6 +122,7 @@ import { BASIC_TREE, DEFAULT_TREE, registerLayoutPresets } from './layout-preset import { bindLayoutSides } from './layout-sides' import { FilesPane, LogsPane, ReviewPaneContent } from './panes' import { ContribWiring, WiredPane } from './wiring' +import { WorkspacePageHeaderHostContext } from './workspace-page-header' /** * 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 // the contribution (new title) and a fresh closure would remount the chat. -const renderWorkspacePane = () => +// 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 = () => ( + + + +) // 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. diff --git a/apps/desktop/src/app/contrib/workspace-page-header.test.tsx b/apps/desktop/src/app/contrib/workspace-page-header.test.tsx new file mode 100644 index 0000000000..dd4888aac6 --- /dev/null +++ b/apps/desktop/src/app/contrib/workspace-page-header.test.tsx @@ -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 = ( + + + +) + +const workspaceZone = (chatRoutes: ReactNode) => ( + + + +) + +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('[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 counter + } + + const render0 = workspace().render + + render(workspaceZone()) + + 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( + + + + ) + + expect(within(container).getAllByRole('button', { name: 'probe-ctl' })).toHaveLength(1) + expect(registry.getArea(WORKSPACE_PAGE_HEADER_AREA)).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/app/contrib/workspace-page-header.tsx b/apps/desktop/src/app/contrib/workspace-page-header.tsx new file mode 100644 index 0000000000..88efcb3150 --- /dev/null +++ b/apps/desktop/src/app/contrib/workspace-page-header.tsx @@ -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) ? ( + + {children} + + ) : ( + <>{children} + ) +} diff --git a/apps/desktop/src/plugins/kanban/board-switcher-placement.test.tsx b/apps/desktop/src/plugins/kanban/board-switcher-placement.test.tsx new file mode 100644 index 0000000000..dcebf0894f --- /dev/null +++ b/apps/desktop/src/plugins/kanban/board-switcher-placement.test.tsx @@ -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()), + 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.