feat(desktop): routes, nav, and command palette as contributions
parent
aae35c5ee4
commit
f1379bd6c2
|
|
@ -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<PaletteContribution & { key: string }> {
|
||||
return useContributions(PALETTE_AREA)
|
||||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
|
||||
.filter(item => Boolean(item.label && item.run))
|
||||
}
|
||||
|
|
@ -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<PaletteGroup[]>(() => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
const RESERVED_PATHS: ReadonlySet<string> = 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue