diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 0f88818e3..c5c6e0cd9 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -59,7 +59,8 @@ The host composes one channel sidebar beside independently mounted pages. It reu session-owned roster, unread, creation and preferences capabilities; it does not retain a hidden Channels page or message reader. Sidebar and page render errors have separate boundaries. Sidebar presentation helpers currently remain importable -from `bundled/channels`; no public sidebar contribution contract is introduced. +from `bundled/channels`. The only sidebar contribution is a page's top-level +destinations (see `navigation` below); channel rows and sections stay host-owned. Channels is the page-authoring example, not a thin registration wrapper over a host-owned product page. Keep page-specific components, styles, interactions and tests @@ -81,6 +82,16 @@ A plugin exports `inject` and `apply(ctx)`. Pages register with plugin; the registry adds installation identity and revision and removes the contribution when its Cordis scope ends. +A page may add top-level sidebar destinations with +`navigation: [{ title, icon, params?, requiresCommunity? }]`. Each entry opens the +page; `params` selects a view inside it and must pass the page's `route.validate`, +so one page can offer several destinations (Channels offers Inbox and Bestie). The +icon is a component or an image URL drawn in the current text color. An entry +with `requiresCommunity` is disabled until a community is selected. The host +lists bundled pages before external ones, keeps each page's entries in declared +order, and marks an entry selected when its page and params are open. The +sidebar has no other built-in destinations. + A page calls `panels.resolve(target)` and renders `PanelView` with the resulting contribution, the target string, and a close callback. The first active matcher wins; a throwing matcher is skipped. Panels receive `{ target, close }` plus diff --git a/src/app/App.tsx b/src/app/App.tsx index c603578ba..b49d50f7e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -16,7 +16,11 @@ import { useAppNavigation } from "./navigation"; import { NavigationControls } from "./shell/NavigationControls"; import { registerNavigationShortcuts } from "./shortcuts"; import { AppShell } from "./shell/AppShell"; -import { pagePresentation, shellPresentation } from "./shell/presentation"; +import { + navigationDestinations, + pagePresentation, + shellPresentation, +} from "./shell/presentation"; import { usePanelLauncher } from "./shell/usePanelLauncher"; import { PanelLaunchers } from "./shell/PanelLaunchers"; import { PanelCard } from "../features/panels/PanelCard"; @@ -125,9 +129,7 @@ function ConnectedApp({ services }: { services: AppServices }) { sessionsEnabled={route.pages.some( (page) => page.pluginId === "buzz.sessions", )} - agentsEnabled={route.pages.some( - (page) => page.key === "buzz.agents/agents", - )} + destinations={navigationDestinations(route.pages)} /> ) } diff --git a/src/app/shell/presentation.test.ts b/src/app/shell/presentation.test.ts index 083676307..029429515 100644 --- a/src/app/shell/presentation.test.ts +++ b/src/app/shell/presentation.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import type { RegisteredPage } from "../../features/pages/service"; -import { orderPages } from "./presentation"; +import { navigationDestinations, orderPages } from "./presentation"; function page(key: string, title: string): RegisteredPage { const separator = key.indexOf("/"); @@ -39,3 +39,34 @@ test("other pages sort by label then full key and cannot claim bundled slots", ( expected, ); }); + +test("sidebar destinations list bundled pages first, then external, in declared entry order", () => { + const icon = () => null; + const channels = { + ...messages, + navigation: [ + { title: "Inbox", icon, params: "Inbox" }, + { title: "Bestie", icon, params: "Bestie" }, + ], + }; + const agents = { + ...page("buzz.agents/agents", "Agents"), + navigation: [{ title: "Agents", icon }], + }; + // An external page titled to sort first still follows every bundled page. + const external = { + ...page("example.threads/threads", "Active threads"), + revision: "0123abcd", + navigation: [{ title: "Active threads", icon: "/threads.svg" }], + }; + const titles = (pages: RegisteredPage[]) => + navigationDestinations(pages).map(({ entry }) => entry.title); + const expected = ["Inbox", "Bestie", "Agents", "Active threads"]; + expect(titles([external, agents, projects, channels])).toEqual(expected); + expect(titles([channels, external, agents])).toEqual(expected); + expect(navigationDestinations([external])[0]).toMatchObject({ + key: "example.threads/threads#0", + pluginId: "example.threads", + pageId: "threads", + }); +}); diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index 38ab44b23..f794e1074 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -6,7 +6,10 @@ import { LightningIcon, RobotIcon, } from "../../shared/design-system/icons/index"; -import type { RegisteredPage } from "../../features/pages/service"; +import type { + NavigationEntry, + RegisteredPage, +} from "../../features/pages/service"; // Shell-owned presentation keeps plugin content independent of navigation chrome. // Add page identities here; unknown plugins inherit a consistent layout default. @@ -34,6 +37,20 @@ export function orderPages(pages: readonly RegisteredPage[]) { ); } +// Sidebar destinations: bundled pages before external ones, each group in page +// order, and each page's entries in the order it declared them. +export function navigationDestinations(pages: readonly RegisteredPage[]) { + const bundled = pages.filter((page) => page.revision === "bundled"); + const external = pages.filter((page) => page.revision !== "bundled"); + return [...orderPages(bundled), ...orderPages(external)].flatMap((page) => + (page.navigation ?? []).map((entry: NavigationEntry, index) => ({ + key: `${page.key}#${index}`, + pluginId: page.pluginId, + pageId: page.id, + entry, + })), + ); +} export function pagePresentation(page: RegisteredPage) { if (page.id === "channels") return shellPresentation.channels; return { diff --git a/src/bundled/agents/index.tsx b/src/bundled/agents/index.tsx index d9d3e32af..8bd7044de 100644 --- a/src/bundled/agents/index.tsx +++ b/src/bundled/agents/index.tsx @@ -1,6 +1,7 @@ import type { PluginModule } from "../../plugins/api"; import { AgentsPage } from "./AgentsPage"; import { editAgentRoute } from "./edit-route"; +import { RobotIcon } from "../../shared/design-system/icons"; export const inject = ["pages", "relay", "agentControl", "navigation"]; export const apply: PluginModule["apply"] = (ctx) => { const relay = ctx.relay; @@ -10,6 +11,9 @@ export const apply: PluginModule["apply"] = (ctx) => { title: "Agents", layout: "workspace", handlesNavigation: true, + navigation: [ + { title: "Agents", icon: () => }, + ], route: { version: 1, validate: (params) => editAgentRoute(params) !== null, diff --git a/src/bundled/channel-templates/agent-selection.test.tsx b/src/bundled/channel-templates/agent-selection.test.tsx index 7399b8b1d..77d07bcc3 100644 --- a/src/bundled/channel-templates/agent-selection.test.tsx +++ b/src/bundled/channel-templates/agent-selection.test.tsx @@ -1207,7 +1207,6 @@ it.each([ }, }} sessionsEnabled - agentsEnabled /> , diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index 4c8da81f8..20332284c 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -107,6 +107,15 @@ .sidebar [data-variant="row"] > :is(svg, img) { flex-shrink: 0; } +/* Plugin page icons take the row's text color, like the built-in glyphs. */ +.pageIcon { + width: 15px; + height: 15px; + background: currentColor; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} .sidebarIcon { display: inline-grid; width: 18px; diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 6858f11f1..240f345de 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -2,6 +2,7 @@ import { isChannelRoute } from "../../features/channel-navigation/routes"; import type { PluginModule } from "../../plugins/api"; import { ChannelsPage } from "./ChannelsPage"; import { ChannelSetupSettings } from "./ChannelSetupSettings"; +import { BellIcon } from "../../shared/design-system/icons"; export const inject = [ "pages", "agentControl", @@ -38,6 +39,20 @@ export const apply: PluginModule["apply"] = (ctx) => { companion: true, handlesNavigation: true, route: { version: 1, validate: isChannelRoute }, + navigation: [ + { + title: "Inbox", + icon: () => , + params: "Inbox", + requiresCommunity: true, + }, + { + title: "Bestie", + icon: () => , + params: "Bestie", + requiresCommunity: true, + }, + ], component: ({ companion, navigation }) => ( ({ @@ -74,6 +75,16 @@ afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); }); +const icon = () => null; +// The bundled channels page's two destinations, as the host orders them. +const channelDestinations: SidebarDestination[] = ["Inbox", "Bestie"].map( + (title, index) => ({ + key: `buzz.channels/channels#${index}`, + pluginId: "buzz.channels", + pageId: "channels", + entry: { title, icon, params: title, requiresCommunity: true }, + }), +); const noProviders: [] = []; const providers = { snapshot: () => noProviders, @@ -86,6 +97,7 @@ function fixture( typeof createSidebarPreferencesStore >["queries"], status: RelaySnapshot["status"] = "ready", + destinations: SidebarDestination[] = channelDestinations, ) { const owner = createRelaySession(null); owners.push(owner); @@ -123,20 +135,24 @@ function fixture( async clearCache() {}, } satisfies RelayData; const navigator = { open: vi.fn() } as unknown as Navigation; - const view = (id: string, sessionsEnabled = true) => ( + const view = ( + id: string, + sessionsEnabled = true, + target: OpenTarget = { + version: 1, + kind: "conversation", + channelId: id, + scope: { viewer: "viewer", communityOrigin: "https://relay.test" }, + }, + ) => ( ); @@ -301,6 +317,72 @@ it("opens Inbox and Bestie in the ready community", () => { } }); +it("renders contributed destinations, selects the open view and disables community-only entries", () => { + const external: SidebarDestination = { + key: "example.threads/threads#0", + pluginId: "example.threads", + pageId: "threads", + entry: { title: "Active threads", icon: "/threads.svg" }, + }; + const h = fixture(undefined, "ready", [...channelDestinations, external]); + const { rerender } = render(h.view("alpha")); + const names = screen + .getAllByRole("button") + .map((button) => button.textContent) + .filter((text) => + ["Inbox", "Bestie", "Active threads"].includes(text ?? ""), + ); + expect(names).toEqual(["Inbox", "Bestie", "Active threads"]); + const threads = screen.getByRole("button", { name: "Active threads" }); + expect(threads.querySelector("[style*='threads.svg']")).not.toBeNull(); + fireEvent.click(threads); + expect(h.navigator.open).toHaveBeenLastCalledWith({ + version: 1, + kind: "page", + pluginId: "example.threads", + pageId: "threads", + scope: { viewer: "viewer", communityOrigin: "https://relay.test" }, + }); + expect(threads).not.toHaveAttribute("aria-current"); + rerender( + h.view("alpha", true, { + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + route: { version: 1, params: "Bestie" }, + }), + ); + expect(screen.getByRole("button", { name: "Bestie" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("button", { name: "Inbox" })).not.toHaveAttribute( + "aria-current", + ); +}); + +it("keeps unscoped destinations usable before a community connects", () => { + const external: SidebarDestination = { + key: "example.threads/threads#0", + pluginId: "example.threads", + pageId: "threads", + entry: { title: "Active threads", icon }, + }; + const h = fixture(undefined, "connecting", [ + ...channelDestinations, + external, + ]); + render(h.view("alpha")); + expect(screen.getByRole("button", { name: "Inbox" })).toBeDisabled(); + const threads = screen.getByRole("button", { name: "Active threads" }); + expect(threads).toBeEnabled(); + fireEvent.click(threads); + expect(h.navigator.open).toHaveBeenLastCalledWith( + expect.objectContaining({ pluginId: "example.threads", scope: null }), + ); +}); + it("opens creation from a legacy subgroup + with that destination selected and retained in the create input", async () => { const preferences = createSidebarPreferencesStore( async () => ({ diff --git a/src/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx index d280ce08a..a5ea357bc 100644 --- a/src/features/channel-navigation/ChannelSidebar.tsx +++ b/src/features/channel-navigation/ChannelSidebar.tsx @@ -7,6 +7,7 @@ import { useRef, useState, useSyncExternalStore, + type ComponentType, type ReactNode, } from "react"; import { ChannelLifecycleDialog } from "../../bundled/channels/ChannelLifecycleDialog"; @@ -19,6 +20,7 @@ import type { RelaySession } from "../relay/session"; import { useChannelList, useRelayConnection } from "../relay/react"; import type { Navigation } from "../navigation/controller"; import type { OpenTarget } from "../navigation/targets"; +import type { NavigationEntry } from "../pages/service"; import { Panel } from "../../shared/design-system/ui/Panel"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { Button } from "../../shared/design-system/ui/Button"; @@ -41,7 +43,6 @@ import { clientMetrics } from "../developer/client-metrics"; import { BellIcon, BellSlashIcon, - RobotIcon, FolderSimpleIcon, } from "../../shared/design-system/icons"; import { ChannelReadMenuItem } from "../../bundled/channels/ChannelReadMenuItem"; @@ -67,7 +68,7 @@ import { } from "../../bundled/channels/CreateChannelDialog"; import { writeView } from "../../shared/view-state"; import { useChannelNavigation } from "./ChannelNavigationState"; -import { channelPlaceholder, newSessionParent } from "./routes"; +import { newSessionParent } from "./routes"; import { ChannelSidebarResizeHandle } from "./ChannelSidebarResizeHandle"; import styles from "../../bundled/channels/Channels.module.css"; @@ -77,13 +78,14 @@ type Props = { providers: TemplateProviders; target: OpenTarget; sessionsEnabled: boolean; - agentsEnabled: boolean; + /** Top-level destinations contributed by pages, in host order. */ + destinations?: readonly SidebarDestination[] | undefined; }; export function ChannelSidebar(props: Props) { const connection = useRelayConnection(props.relay); const navigation = ( & { scope: string; viewer?: string | undefined; }; function SidebarNavigation({ - agentsEnabled, + destinations = [], navigator, scope, target, viewer, }: SidebarNavigationProps) { - const placeholder = - target.kind === "page" && target.pluginId === "buzz.channels" - ? channelPlaceholder(target.route?.params) - : undefined; const communityOrigin = viewer ? scope.slice(0, -(viewer.length + 1)) : undefined; - const openChannelDestination = (destination: "Inbox" | "Bestie") => { - if (!viewer || communityOrigin === undefined) return; - void navigator.open({ - version: 1, - kind: "page", - pluginId: "buzz.channels", - pageId: "channels", - scope: { viewer, communityOrigin }, - route: { version: 1, params: destination }, - }); - }; - const destinations = [ - { - title: "Inbox", - icon: , - selected: placeholder === "Inbox", - disabled: !viewer || communityOrigin === undefined, - open: () => openChannelDestination("Inbox"), - }, - { - title: "Bestie", - icon: , - selected: placeholder === "Bestie", - disabled: !viewer || communityOrigin === undefined, - open: () => openChannelDestination("Bestie"), - }, - ...(agentsEnabled - ? [ - { - title: "Agents", - disabled: false, - icon: , - selected: - target.kind === "page" && target.pluginId === "buzz.agents", - open: () => - void navigator.open({ - version: 1, - kind: "page", - pluginId: "buzz.agents", - pageId: "agents", - scope: - viewer && communityOrigin !== undefined - ? { viewer, communityOrigin } - : null, - }), - }, - ] - : []), - ]; + const community = + viewer && communityOrigin !== undefined + ? { viewer, communityOrigin } + : null; + const items = destinations.map(({ key, pluginId, pageId, entry }) => ({ + key, + title: entry.title, + icon: , + disabled: !!entry.requiresCommunity && !community, + selected: + target.kind === "page" && + target.pluginId === pluginId && + target.pageId === pageId && + (entry.params === undefined || + JSON.stringify(target.route?.params) === JSON.stringify(entry.params)), + open: () => + void navigator.open({ + version: 1, + kind: "page", + pluginId, + pageId, + scope: community, + ...(entry.params !== undefined && { + route: { version: 1, params: entry.params }, + }), + }), + })); return ( <>
@@ -207,9 +183,9 @@ function SidebarNavigation({ />
- {destinations.map(({ title, icon, selected, disabled, open }) => ( + {items.map(({ key, title, icon, selected, disabled, open }) => ( ); } +// Image URLs are masks so plugin icons follow the row's text color. +function PageIcon({ icon: Icon }: { icon: string | ComponentType }) { + if (typeof Icon === "function") return ; + return ( +