Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)}
/>
)
}
Expand Down
33 changes: 32 additions & 1 deletion src/app/shell/presentation.test.ts
Original file line number Diff line number Diff line change
@@ -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("/");
Expand Down Expand Up @@ -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",
});
});
19 changes: 18 additions & 1 deletion src/app/shell/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/bundled/agents/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +11,9 @@ export const apply: PluginModule["apply"] = (ctx) => {
title: "Agents",
layout: "workspace",
handlesNavigation: true,
navigation: [
{ title: "Agents", icon: () => <RobotIcon weight="bold" size={15} /> },
],
route: {
version: 1,
validate: (params) => editAgentRoute(params) !== null,
Expand Down
1 change: 0 additions & 1 deletion src/bundled/channel-templates/agent-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,6 @@ it.each([
},
}}
sessionsEnabled
agentsEnabled
/>
</ChannelNavigationProvider>
</ToastProvider>,
Expand Down
9 changes: 9 additions & 0 deletions src/bundled/channels/Channels.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/bundled/channels/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -38,6 +39,20 @@ export const apply: PluginModule["apply"] = (ctx) => {
companion: true,
handlesNavigation: true,
route: { version: 1, validate: isChannelRoute },
navigation: [
{
title: "Inbox",
icon: () => <BellIcon weight="bold" size={15} />,
params: "Inbox",
requiresCommunity: true,
},
{
title: "Bestie",
icon: () => <img src="/bestie.png" alt="" width={17} height={17} />,
params: "Bestie",
requiresCommunity: true,
},
],
component: ({ companion, navigation }) => (
<ChannelsPage
agentControl={agentControl}
Expand Down
100 changes: 91 additions & 9 deletions src/features/channel-navigation/ChannelSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import type { SidebarPreferences } from "../relay/sidebar-preferences";
import type { RelayData, RelaySnapshot } from "../relay/service";
import type { ChannelList } from "../relay/contracts";
import type { Navigation } from "../navigation/controller";
import { ChannelSidebar } from "./ChannelSidebar";
import type { OpenTarget } from "../navigation/targets";
import { ChannelSidebar, type SidebarDestination } from "./ChannelSidebar";
import { ChannelNavigationProvider } from "./ChannelNavigationState";

const { rowRender, menuRender } = vi.hoisted(() => ({
Expand Down Expand Up @@ -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,
Expand All @@ -86,6 +97,7 @@ function fixture(
typeof createSidebarPreferencesStore
>["queries"],
status: RelaySnapshot["status"] = "ready",
destinations: SidebarDestination[] = channelDestinations,
) {
const owner = createRelaySession(null);
owners.push(owner);
Expand Down Expand Up @@ -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" },
},
) => (
<ChannelNavigationProvider relay={relay}>
<ChannelSidebar
relay={relay}
navigator={navigator}
providers={providers}
target={{
version: 1,
kind: "conversation",
channelId: id,
scope: { viewer: "viewer", communityOrigin: "https://relay.test" },
}}
target={target}
sessionsEnabled={sessionsEnabled}
agentsEnabled={true}
destinations={destinations}
/>
</ChannelNavigationProvider>
);
Expand Down Expand Up @@ -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 () => ({
Expand Down
Loading
Loading