From 2fa2f42d412a5299fa0200bcd44baee38dffe62c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:19:34 -0700 Subject: [PATCH] docs(desktop-sdk): document model menu row decorations and data-session-switching Both seams are public contracts for plugin/theme authors (first consumer: t3-code-theme, #129600); the page documents the arbitration, failure isolation and lifetime guarantees so a theme never needs DOM reads. --- .../developer-guide/desktop-plugin-sdk.md | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 081bbb9c3c..051c3767f0 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -234,6 +234,7 @@ Import the area constants from the SDK; each area has its own `data` payload. | Keybind | `KEYBINDS_AREA` | `data: KeybindContribution` | | Theme | `THEMES_AREA` | `data` as a `DesktopTheme` | | Composer | `COMPOSER_AREAS.*` | render slots, or middleware / attachment providers | +| Model menu rows | `MODEL_MENU_ROW_AREA` | `data: ModelMenuRowContribution` — a leading icon / trailing badge per model | | Appearance settings | `APPEARANCE_AREAS.extra` | `render` — controls appended to Settings → Appearance | ### Panes @@ -421,6 +422,45 @@ manual pick. To tint the *active* theme rather than replace it, use [Accent Picker](https://github.com/NousResearch/hermes-desktop-accent-picker) plugin is the worked example (it is also a complete, installable disk plugin). +#### Styling the chat switch — `data-session-switching` + +Opening a chat places its transcript in steps: the session loads, the rows +land, then the restored scroll position settles a few frames later. A theme +that wants that hidden (or faded) targets one documented attribute instead of +watching the route or the DOM: core sets `data-session-switching="true"` on the +chat surface root (`[data-chat-surface]`) from the frame the switch starts until +the new transcript's rows are on screen and its scroll position has settled, +then removes it. + +```css +/* Hide the transcript while it is being placed, fade it in when it lands. */ +:root[data-hermes-theme="noir"] [data-chat-surface] [data-slot="aui_thread-viewport"] { + transition: opacity 0.12s ease-out; +} +:root[data-hermes-theme="noir"] [data-chat-surface][data-session-switching] [data-slot="aui_thread-viewport"] { + opacity: 0; + transition: none; +} +``` + +- **Per surface.** The primary chat and every tile has its own + `[data-chat-surface]`; the attribute marks only the one switching. Narrow to + the primary pane with `[data-composer-target="main"]`. +- **Always ends.** It is held by core's own load and scroll-restore phases: + the load phase ends when the transcript arrives or the resume gives up, the + restore phase on settle (a bounded number of frames) or the first user + scroll/key/pointer input, and both on unmount — a theme that hides content + under it cannot strand the chat hidden. A brand-new empty draft never sets it. +- **The contract is the attribute.** Target `[data-session-switching]` and the + `data-chat-surface` / `data-slot` hooks; internal class names are not a + contract and change without notice. Do not toggle the attribute yourself or + reproduce it with a route listener or `MutationObserver` (catalog rule 8). + +This replaces the t3-code-theme `installSwitchFade` pattern (a focus-store +listener plus a `requestAnimationFrame` loop that polled the transcript's rows +and scroll position, then toggled its own root attribute): the CSS above is the +whole migration. + ### Composer extensions `COMPOSER_AREAS` (`top`, `bottom`, `underside`, `leading`, `actions`, @@ -652,6 +692,60 @@ register(ctx) { The reasoning-pill visibility CSS the plugin also injected has no hook; it is only needed if the app ever hides that label at narrow widths. +#### Model menu row decorations + +`MODEL_MENU_ROW_AREA` puts a per-model mark inside the native model menu — the +one the composer's pill opens, and every other surface that renders +`ModelCatalogMenu`. A contribution supplies `decorate(row)`; core paints what it +returns in two fixed slots of the row: a **leading icon** before the model name +and a **trailing badge** after core's own chips. The row's markup, name, +star, submenu and click stay core's. + +```ts +import { MODEL_MENU_ROW_AREA, type ModelMenuRowContribution } from '@hermes/plugin-sdk' + +interface ModelMenuRowContext { + provider: string // provider slug: 'anthropic', 'openrouter', … + model: string // the model id the row commits + label: string // the display name core paints on the row +} +interface ModelMenuRowDecoration { + icon?: ReactNode // element (, , a component) or short text, drawn in a 1rem box + badge?: string // plain text chip +} + +ctx.register({ + area: MODEL_MENU_ROW_AREA, + id: 'provider-marks', + data: { + decorate: ({ provider }) => { + const src = PROVIDER_ICONS[provider] // data: URL of an SVG mark + return src ? { icon: } : null + } + } satisfies ModelMenuRowContribution +}) +``` + +**Arbitration.** Decorators run in registry order, **per slot**: the first one +that returns a usable `icon` fills the icon slot, the first usable `badge` the +badge slot, so an icon plugin and a pricing-badge plugin compose on the same +row. `null` (or nothing usable) declines. Only a React element or a non-empty +string is an icon and only a non-empty string is a badge; anything else is +ignored rather than rendered. A decorator that **throws** declines too, and an +icon component that throws while rendering blanks only its own slot (it sits +in its own error boundary) — a broken plugin can never take the menu down. +`decorate()` re-runs only when the registry or the row's provider/model/label +changes, so keep it a pure lookup. + +**Teardown.** An ordinary data contribution: the `ctx.register` disposer (and +plugin disable/reload) removes it and the rows repaint bare. + +**Migrating t3-code-theme.** Its provider marks were painted into the open menu +by a `MutationObserver` that located the rows in the menu's DOM and wrote mask +images onto them. The same marks come from `decorate({ provider })` returning +`{ icon: }` — no DOM reads, +and the row keeps working when the menu's markup changes. + ### Appearance settings `APPEARANCE_AREAS.extra` renders contributions at the end of **Settings → @@ -1634,7 +1728,7 @@ pipeline as a trust boundary. |----------|---------| | Host | `host` (`.state.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`, `.skills`, `.toolsets`, `.profiles`, `.pluginDecisions`) | | Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` | -| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS` | +| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `MODEL_MENU_ROW_AREA`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS` | | Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` | | React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute`, `WorkspacePageHeaderControl` | | Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) |