diff --git a/apps/desktop/src/app/command-palette/contrib.ts b/apps/desktop/src/app/command-palette/contrib.ts new file mode 100644 index 000000000..bd3e669cb --- /dev/null +++ b/apps/desktop/src/app/command-palette/contrib.ts @@ -0,0 +1,28 @@ +/** + * Command-palette contribution surface — `palette` data contributions become + * rows in the ⌘K root list, same schema as every other area. Contributions + * with an `action` id render that action's live keybind as their hotkey hint. + */ + +import { useContributions } from '@/contrib/react/use-contributions' +import type { IconComponent } from '@/lib/icons' + +export const PALETTE_AREA = 'palette' + +/** Payload of a `palette` data contribution. */ +export interface PaletteContribution { + id: string + label: string + /** Keybind action id — its live combo renders as the hotkey hint. */ + action?: string + icon?: IconComponent + keywords?: string[] + run: () => void +} + +/** Contributed palette rows, with stable render keys. */ +export function usePaletteContributions(): Array { + return useContributions(PALETTE_AREA) + .map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) })) + .filter(item => Boolean(item.label && item.run)) +} diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index be89ebb4e..f7a6e0139 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' +import { usePaletteContributions } from './contrib' import { MarketplaceThemePage } from './marketplace-theme-page' import { PetInlineToggle, PetPalettePage } from './pet-palette-page' @@ -368,6 +369,8 @@ export function CommandPalette() { [t.settings.fieldLabels] ) + const contributedItems = usePaletteContributions() + const baseGroups = useMemo(() => { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` const cc = t.commandCenter @@ -559,9 +562,26 @@ export function CommandPalette() { run: go(settingsTab(entry.tab)) })) ] - } + }, + // Registry-contributed rows (core features + plugins) — one group, + // omitted while nothing contributes. + ...(contributedItems.length > 0 + ? [ + { + heading: cc.commands, + items: contributedItems.map(item => ({ + action: item.action, + icon: item.icon ?? Zap, + id: item.key, + keywords: item.keywords, + label: item.label, + run: item.run + })) + } + ] + : []) ] - }, [go, settingsSectionLabel, t, worktrees]) + }, [contributedItems, go, settingsSectionLabel, t, worktrees]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the diff --git a/apps/desktop/src/app/index.tsx b/apps/desktop/src/app/index.tsx index ad8f79afe..a1dc8140b 100644 --- a/apps/desktop/src/app/index.tsx +++ b/apps/desktop/src/app/index.tsx @@ -1 +1,6 @@ -export { DesktopController as default } from './desktop-controller' +// The app root is the contribution-driven shell: panes, titlebar/statusbar +// items, keybinds, palette commands, routes, and themes all register through +// the contribution registry (src/contrib) — core surfaces use the same calls +// plugins do. Everything lives under ./contrib: the wiring (gateway boot, +// sessions, streams) + pane surfaces, and the pane/layout registration. +export { ContribController as default } from './contrib' diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 66ab264e4..8db7ee7c3 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -1,3 +1,8 @@ +import { atom } from 'nanostores' +import type { ReactNode } from 'react' + +import { registry } from '@/contrib/registry' + export const SESSION_ROUTE_PREFIX = '/' export const NEW_CHAT_ROUTE = '/' export const SETTINGS_ROUTE = '/settings' @@ -16,6 +21,11 @@ export type AppView = | 'chat' | 'command-center' | 'cron' + // A contributed (plugin) full page at its own route — NOT chat. Without this + // distinction contributed paths fell through appViewForPath's 'chat' default, + // so the sidebar kept a session highlighted and the titlebar kept the + // session-title dropdown while a plugin page was showing. + | 'extension' | 'messaging' | 'profiles' | 'settings' @@ -56,6 +66,52 @@ export const APP_ROUTES = [ const APP_VIEW_BY_PATH = new Map(APP_ROUTES.map(route => [route.path, route.view])) const RESERVED_PATHS: ReadonlySet = new Set(APP_ROUTES.map(route => route.path)) +// ── Contributed routes — the `routes` registry area ───────────────────────── +// A contribution mounts a FULL PAGE in the workspace pane at `data.path` +// (`render` on the contribution itself, like every other area). Contributed +// paths are reserved exactly like APP_ROUTES so the session-id parser never +// mistakes them for a session route. Navigate with `host.navigate(path)`. + +export const ROUTES_AREA = 'routes' + +/** Payload of a `routes` contribution's `data`. */ +export interface RouteContribution { + /** Absolute path, e.g. `/kanban`. One segment; no params. */ + path: string +} + +export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> { + return registry + .getArea(ROUTES_AREA) + .map(c => ({ + key: `${c.source ?? 'core'}:${c.id}`, + path: (c.data as RouteContribution | undefined)?.path ?? '', + title: c.title, + render: c.render! + })) + .filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path)) +} + +function isContributedPath(pathname: string): boolean { + return contributedRoutes().some(route => route.path === pathname) +} + +// ── Contributed sidebar nav — the `sidebar.nav` registry area ──────────────── +// A DATA contribution adds a row to the sidebar's top nav (below Artifacts). +// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up +// while the app is there. + +export const SIDEBAR_NAV_AREA = 'sidebar.nav' + +/** Payload of a `sidebar.nav` data contribution. */ +export interface SidebarNavContribution { + /** Codicon name, e.g. `'project'`. */ + codicon: string + label: string + /** Route to navigate to (usually a contributed page's path). */ + path: string +} + // Views that render as a full-screen modal card (OverlayView) over the shell. // While one is open the app's titlebar control clusters must hide so they don't // bleed over the overlay (they sit at a higher z-index than the overlay card). @@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean { } export function routeSessionId(pathname: string): string | null { - if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) { + if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) { return null } @@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView { return 'chat' } + if (isContributedPath(pathname)) { + return 'extension' + } + return APP_VIEW_BY_PATH.get(pathname) ?? 'chat' } + +/** True while the workspace pane shows a FULL PAGE (skills/messaging/ + * artifacts/plugin routes) instead of the chat. Published by the wiring + * (which owns the router location); the workspace pane contribution mirrors + * it as `headerVeto` so the zone tab bar stands down on pages. Overlays + * (settings/…) don't count — the chat stays beneath them. */ +export const $workspaceIsPage = atom(false) + +export function syncWorkspaceIsPage(pathname: string): void { + const view = appViewForPath(pathname) + const isPage = view !== 'chat' && !isOverlayView(view) + + if (isPage !== $workspaceIsPage.get()) { + $workspaceIsPage.set(isPage) + } +}