diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 98778ff166..0590e870bc 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -84,7 +84,9 @@ retain a hidden Channels page or message reader. Sidebar and page render errors have separate boundaries. A page sidebar is isolated by the same owned-contribution boundary, so a failure falls back to a concise alert without taking down the page, Settings, or shell. The host owns the slot, collapse state, labels, and narrow drawer; -the page owns its sidebar content and local interaction state. +the page owns its sidebar content and local interaction state. Sidebar presentation +helpers remain importable from `bundled/channels`; the conversation sidebar also +exposes a versioned section contribution for plugins. 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 @@ -203,6 +205,56 @@ remaining acceptance work and historical evidence. ## Starting contracts +### Conversation sidebar sections + +A plugin may register a versioned `sidebarSections` contribution. Read it with +`ctx.reflect.get("sidebarSections")` instead of adding it to required `inject`; +the lookup returns `undefined` when an older host has not provided the capability. The host +renders its rows inside the existing Team sidebar using the same collapsible, +reorderable, resizable section frame as built-in +sections. It owns presentation and persists contributed section expansion and +relative order; a plugin supplies row data and actions, not another sidebar. + +Each registration is owned by the plugin's Cordis scope and is removed on +disable, replacement, or teardown. Rows use an external-store snapshot and +subscription: return a stable immutable array until the data changes, then notify +listeners. `select` is a pure row-to-`OpenTarget` function used for both active +selection and opening the row. Optional +header actions are compact buttons; each has a stable id, accessible label, +icon, and callback. A render failure replaces only that section with its +unavailable state, and rejected row/header actions stay local to that section. + +```tsx +import { createElement } from "react"; + +const rows = [{ + id: "today", + label: "Today", + count: 2, +}]; +const listeners = new Set<() => void>(); +ctx.reflect.get("sidebarSections")?.register({ + version: 1, + id: "work", + title: "Work", + icon: "W", + rows: { + snapshot: () => rows, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + select: () => ({ version: 1, kind: "home" }), + actions: [{ + id: "new", + label: "New item", + icon: createElement("span", { "aria-hidden": true }, "+"), + onSelect: createItem, + }], +}); +``` + A plugin exports `inject` and `apply(ctx)`. Pages register with `ctx.pages.register({ id, title, layout?, companion?, primary?, placement?, icon?, component, sidebar? })`. Panels register with `ctx.panels.register({ id, title, matches, order?, launcher?, toolbar?, component })`. An optional `toolbar` supplies live controls beside shell search in place of the static launcher. Its lifetime stays with its plugin, and shell render failures are isolated. Huddles is the first caller. IDs are local to the diff --git a/src/app/App.tsx b/src/app/App.tsx index f96b73a842..778018a4e8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -369,6 +369,7 @@ function ConnectedApp({ services }: { services: AppServices }) { navigator={services.navigation} agents={services.agents} chatIcons={services.conversation.chatIcons} + sidebarSections={services.sidebarSections} target={workspace.target} > {pageNavigation} diff --git a/src/app/services.ts b/src/app/services.ts index bf170f004f..1505998a42 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -35,6 +35,7 @@ import { PanelsService } from "../features/panels/service"; import { Context } from "@deepseek-ai/cordis"; import { BrowserService } from "../features/browser/service"; import { PagesService } from "../features/pages/service"; +import { SidebarSectionsService } from "../features/channel-navigation/sidebar-sections-service"; import { bundledPlugins } from "../bundled"; import { createPluginManager } from "../plugins/manager"; import { withTimeout } from "../plugins/timeout"; @@ -56,6 +57,7 @@ export function createServices() { const shortcuts = new ShortcutsService(ctx, undefined, shortcutBindings); const decisions = new DecisionsService(ctx); const pages = new PagesService(ctx); + const sidebarSections = new SidebarSectionsService(ctx); const panels = new PanelsService(ctx); const accountActions = new AccountActionsService(ctx); const conversation = new ConversationService(ctx); @@ -140,6 +142,7 @@ export function createServices() { settingsCards, adminAccess, pages, + sidebarSections, panels, plugins, relay, diff --git a/src/app/shell/PrimarySidebar.tsx b/src/app/shell/PrimarySidebar.tsx index 332138204c..7e28b6cb7c 100644 --- a/src/app/shell/PrimarySidebar.tsx +++ b/src/app/shell/PrimarySidebar.tsx @@ -29,6 +29,7 @@ import { readView, writeView } from "../../shared/view-state"; import type { ChannelList } from "../../features/relay/contracts"; import type { ContributionReader } from "../../features/conversation/contracts"; import type { ChatIcon } from "../../features/conversation/service"; +import type { SidebarSections } from "../../features/channel-navigation/sidebar-sections-service"; const noSubscription = () => () => {}; @@ -44,6 +45,7 @@ type Props = { navigator: Navigation; agents?: Agents | undefined; chatIcons?: ContributionReader | undefined; + sidebarSections?: SidebarSections | undefined; target: OpenTarget; children: ReactNode; }; @@ -180,6 +182,7 @@ function ScopedSidebar({ queries={connection.session} scope={scope} viewer={connection.viewer} + sidebarSections={props.sidebarSections} cached={!!connection.cached} layoutReady={filter === "team" || soloReady} connectionError={connection.error} diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index 60eec31fd1..243c03c2ce 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -97,6 +97,25 @@ flex: 0 0 1.125rem; place-items: center; } +.contributionNavigationItem { + width: 100%; + min-width: 0; + container-type: inline-size; +} +.contributionMetadata { + display: block; + min-width: 0; + overflow-wrap: anywhere; + text-align: end; + white-space: normal; +} +@container (max-width: 17rem) { + .contributionNavigationItem [class~="navigation-item-trailing"] { + max-width: 40%; + min-width: 0; + flex: 0 1 auto; + } +} .channelListFrame { position: relative; /* Bring the scrollbar near the divider without clipping its native thumb. */ @@ -361,6 +380,13 @@ overflow: hidden; white-space: nowrap; } +.contributionFeedback { + margin: 0; + padding: var(--space-1) var(--space-2); + color: var(--text-danger); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); +} .sectionLabel [data-sidebar-group-icon] { width: var(--space-5); margin-inline-end: var(--space-1); diff --git a/src/bundled/channels/SidebarSection.tsx b/src/bundled/channels/SidebarSection.tsx index b11393fb3e..19cfe4604d 100644 --- a/src/bundled/channels/SidebarSection.tsx +++ b/src/bundled/channels/SidebarSection.tsx @@ -57,6 +57,9 @@ export function SidebarSection({ session, open, onToggle, + headerActions, + headerNotice, + hideMoreActions = false, createChannel, newMessage, sort, @@ -76,11 +79,14 @@ export function SidebarSection({ sectionKey: string; title: string; hideTitle?: boolean; - icon?: string | undefined; + icon?: ReactNode; session?: RelaySession | undefined; open: boolean; onToggle: (open: boolean) => void; + headerActions?: ReactNode; + headerNotice?: ReactNode; onRemove?: (() => unknown) | undefined; + hideMoreActions?: boolean; channelCount?: number | undefined; removalFocus?: (() => HTMLElement | null | undefined) | undefined; createChannel?: @@ -196,9 +202,11 @@ export function SidebarSection({ {sectionKey === "starred" ? ( - ) : icon && session ? ( + ) : typeof icon === "string" && session ? ( - ) : null} + ) : ( + icon + )} {title} @@ -213,6 +221,7 @@ export function SidebarSection({ render={(props) => (