From cda0b8c962aa599ff25b99578824c390d3fa9627 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 15 Jul 2026 06:38:28 +0000 Subject: [PATCH] refactor(hub)!: drop synthesized built-in docks for integration-owned ~builtin views The hub no longer synthesizes ~terminals/~messages/~settings dock entries. A high-level integration now registers the built-in views it wants, and a ~builtin-typed view defaults its own category to ~builtin. The ~builtin category is retained for ordering (it still sorts last). BREAKING CHANGE: Removed the BuiltinDocksOptions type and the createHubContext `builtinDocks` option, and DevframeDocksHost.values() no longer accepts { includeBuiltin }. Integrations register the settings/terminals/messages docks themselves. --- docs/guide/hub.md | 2 +- .../devframe/minimal-next-devframe-hub.ts | 10 +++ .../tests/minimal-next-devframe-hub.test.ts | 11 ++- .../hub/src/node/__tests__/context.test.ts | 20 +---- .../hub/src/node/__tests__/host-docks.test.ts | 69 ++++++++------- packages/hub/src/node/context.ts | 23 ++--- packages/hub/src/node/host-docks.ts | 83 +++++-------------- packages/hub/src/types/docks.ts | 45 ++++------ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 1 - .../tsnapi/@devframes/hub/node.snapshot.d.ts | 11 +-- .../tsnapi/@devframes/hub/node.snapshot.js | 6 +- .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 - 12 files changed, 102 insertions(+), 180 deletions(-) diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 5971519..d2f6f60 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -133,7 +133,7 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys | Channel | Type | What it carries | |---|---|---| -| `devframe:docks` shared state | `DevframeDockEntry[]` | The full dock list, including the hub's `~terminals` / `~messages` / `~settings` builtins. | +| `devframe:docks` shared state | `DevframeDockEntry[]` | Every dock entry the mounted integrations registered. | | `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). | | `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. | | `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. | diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts index 4d9664b..8387a50 100644 --- a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts +++ b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts @@ -204,6 +204,16 @@ export async function minimalNextDevframeHub( handler: () => 'pong', }) + // The hub no longer synthesizes built-in docks — a high-level integration + // registers the viewer's native views it wants. `~builtin` views default + // their category to `~builtin`, so this Settings tab sorts last on its own. + context.docks.register({ + type: '~builtin', + id: '~settings', + title: 'Settings', + icon: 'ph:gear-duotone', + }) + // Demo devframes alongside the dogfooded built-in plugin packages. const devframes = options.devframes ?? [demoDevframe, demoDevframeB, ...await loadBuiltinPlugins()] diff --git a/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts b/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts index 5e28e3e..7d68275 100644 --- a/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts +++ b/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts @@ -28,15 +28,18 @@ describe('minimal-next-devframe-hub (example)', () => { }) }) - it('registers hub built-in docks and the mounted demo devframe', async () => { + it('registers a hub-owned settings dock and the mounted plugin docks', async () => { server = await minimalNextDevframeHub({ host: '127.0.0.1' }) - const dockIds = server.context.docks.values().map(d => d.id) + const docks = server.context.docks.values() + const dockIds = docks.map(d => d.id) expect(dockIds).toContain('next-demo-tool') - expect(dockIds).toContain('~terminals') - expect(dockIds).toContain('~messages') + // The hub synthesizes no built-in docks; the integration registers the + // settings tab itself, and `~builtin` views default to the `~builtin` category. expect(dockIds).toContain('~settings') + expect(docks.find(d => d.id === '~settings')?.category).toBe('~builtin') // The dogfooded built-in plugin packages mount their own docks. + expect(dockIds).toContain('devframes-plugin-terminals') expect(dockIds).toContain('devframes-plugin-messages') }) diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index 5120312..a8866cd 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -16,7 +16,7 @@ function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-contex } describe('createHubContext shared state', () => { - it('seeds built-in docks immediately', async () => { + it('seeds an empty dock list — the hub synthesizes no built-in docks', async () => { const context = await createHubContext({ cwd: process.cwd(), mode: 'build', @@ -24,23 +24,7 @@ describe('createHubContext shared state', () => { }) const docks = await context.rpc.sharedState.get('devframe:docks') - expect(docks.value().map(dock => dock.id)).toEqual([ - '~terminals', - '~messages', - '~settings', - ]) - }) - - it('suppresses built-ins gated off via builtinDocks', async () => { - const context = await createHubContext({ - cwd: process.cwd(), - mode: 'build', - host: createHost(), - builtinDocks: { terminals: false, messages: false }, - }) - - const docks = await context.rpc.sharedState.get('devframe:docks') - expect(docks.value().map(dock => dock.id)).toEqual(['~settings']) + expect(docks.value()).toEqual([]) }) }) diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index ede10a6..da86135 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -16,10 +16,6 @@ function createContext(): DevframeHubContext { resolveOrigin: () => 'http://localhost:5173', getStorageDir: () => storageDir, }, - // Minimal stubs so the built-in `~terminals`/`~messages` getters - // (`when`/`badge`) can be evaluated without a full context. - terminals: { sessions: new Map() }, - messages: { entries: new Map() }, } as unknown as DevframeHubContext } @@ -38,7 +34,7 @@ describe('devframeDockHost remote URL enrichment', () => { remote: true, }) - const first = host.values({ includeBuiltin: false })[0] + const first = host.values()[0] expect(first.type).toBe('iframe') const firstUrl = first.type === 'iframe' ? first.url : '' expect(firstUrl).toContain(`#/inspect?tab=state&${REMOTE_CONNECTION_KEY}=`) @@ -57,7 +53,7 @@ describe('devframeDockHost remote URL enrichment', () => { remote: true, }) - const second = host.values({ includeBuiltin: false })[0] + const second = host.values()[0] const secondUrl = second.type === 'iframe' ? second.url : '' expect(secondUrl.match(new RegExp(REMOTE_CONNECTION_KEY, 'g'))).toHaveLength(1) expect(secondUrl).toContain('#/inspect?tab=state&') @@ -77,7 +73,7 @@ describe('devframeDockHost remote URL enrichment', () => { remote: true, }) - const entry = host.values({ includeBuiltin: false })[0] + const entry = host.values()[0] const url = entry.type === 'iframe' ? entry.url : '' expect(url).toContain(`#section&${REMOTE_CONNECTION_KEY}=`) expect(parseRemoteConnection(url)?.websocket).toBe('ws://localhost:4173') @@ -101,7 +97,7 @@ describe('devframeDockHost grouping', () => { expect(host.views.has('nuxt')).toBe(true) expect(emitted).toEqual(['nuxt']) - const entry = host.values({ includeBuiltin: false })[0] + const entry = host.values()[0] expect(entry.type).toBe('group') expect(entry).toMatchObject({ id: 'nuxt', defaultChildId: 'nuxt:overview' }) }) @@ -117,7 +113,7 @@ describe('devframeDockHost grouping', () => { groupId: 'nuxt', }) - const entry = host.values({ includeBuiltin: false })[0] + const entry = host.values()[0] expect(entry.groupId).toBe('nuxt') }) @@ -138,7 +134,7 @@ describe('devframeDockHost grouping', () => { icon: 'logos:nuxt-icon', }) - const ids = host.values({ includeBuiltin: false }).map(entry => entry.id) + const ids = host.values().map(entry => entry.id) expect(ids).toEqual(['nuxt:overview', 'nuxt']) }) @@ -194,33 +190,40 @@ describe('devframeDockHost grouping', () => { }) }) -describe('devframeDockHost built-in gating', () => { - it('includes all three built-ins by default', () => { +describe('devframeDockHost ~builtin category default', () => { + it('returns no docks until an integration registers one', () => { const host = new DevframeDocksHost(createContext()) - const ids = host.values().map(entry => entry.id) - expect(ids).toEqual(['~terminals', '~messages', '~settings']) + expect(host.values()).toEqual([]) }) - it('treats an empty builtinDocks map as all-enabled', () => { - const host = new DevframeDocksHost(createContext(), {}) - const ids = host.values().map(entry => entry.id) - expect(ids).toEqual(['~terminals', '~messages', '~settings']) - }) + it('defaults a ~builtin view without a category to the ~builtin category', () => { + const host = new DevframeDocksHost(createContext()) + host.register({ + type: '~builtin', + id: '~settings', + title: 'Settings', + icon: 'ph:gear-duotone', + }) - it('omits the built-ins gated with `false`, keeping the rest', () => { - const host = new DevframeDocksHost(createContext(), { terminals: false, messages: false }) - const ids = host.values().map(entry => entry.id) - expect(ids).toEqual(['~settings']) + const entry = host.values()[0] + expect(entry).toMatchObject({ id: '~settings', type: '~builtin', category: '~builtin' }) }) - it('keeps an explicitly-enabled built-in and drops an omitted-as-false sibling', () => { - const host = new DevframeDocksHost(createContext(), { terminals: true, settings: false }) - const ids = host.values().map(entry => entry.id) - expect(ids).toEqual(['~terminals', '~messages']) + it('preserves an explicit category on a ~builtin view', () => { + const host = new DevframeDocksHost(createContext()) + host.register({ + type: '~builtin', + id: '~settings', + title: 'Settings', + icon: 'ph:gear-duotone', + category: 'app', + }) + + expect(host.values()[0].category).toBe('app') }) - it('keeps user views ahead of gated built-ins', () => { - const host = new DevframeDocksHost(createContext(), { messages: false, settings: false }) + it('leaves a non-builtin view without a category untouched', () => { + const host = new DevframeDocksHost(createContext()) host.register({ type: 'iframe', id: 'app:overview', @@ -229,12 +232,6 @@ describe('devframeDockHost built-in gating', () => { url: '/__app/', }) - const ids = host.values().map(entry => entry.id) - expect(ids).toEqual(['app:overview', '~terminals']) - }) - - it('drops every built-in when includeBuiltin is false, regardless of gating', () => { - const host = new DevframeDocksHost(createContext(), { terminals: true, messages: true, settings: true }) - expect(host.values({ includeBuiltin: false })).toEqual([]) + expect(host.values()[0].category).toBeUndefined() }) }) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index c1a8939..5e89759 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -1,7 +1,7 @@ import type { CreateHostContextOptions } from 'devframe/node' import type { DevframeHost, DevframeNodeContext } from 'devframe/types' import type { DevframeCommandsHost } from '../types/commands' -import type { BuiltinDocksOptions, DevframeDocksHost } from '../types/docks' +import type { DevframeDocksHost } from '../types/docks' import type { JsonRenderer, JsonRenderSpec } from '../types/json-render' import type { DevframeMessagesHost } from '../types/messages' import type { DevframeTerminalsHost } from '../types/terminals' @@ -57,19 +57,12 @@ export interface DevframeHubContext extends DevframeNodeContext { createJsonRenderer: (spec: JsonRenderSpec) => JsonRenderer } -export interface CreateHubContextOptions extends CreateHostContextOptions { - /** - * Gate the hub's synthesized built-in dock entries (`~terminals`, - * `~messages`, `~settings`). Each entry defaults to present; set one to - * `false` to suppress it — e.g. when mounting `@devframes/plugin-terminals` - * or `@devframes/plugin-messages`, which replace the built-in tabs. - * - * Omitting this option keeps all three built-ins. - * - * @default { terminals: true, messages: true, settings: true } - */ - builtinDocks?: BuiltinDocksOptions -} +/** + * Options for {@link createHubContext} — devframe's + * {@link CreateHostContextOptions} plus any hub-level additions kits layer on + * through declaration merging. + */ +export interface CreateHubContextOptions extends CreateHostContextOptions {} /** * Create a hub-level node context: wraps devframe's `createHostContext`, @@ -87,7 +80,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis }) const context = baseContext as DevframeHubContext - const docks = new DocksHostImpl(context, options.builtinDocks) + const docks = new DocksHostImpl(context) const terminals = new TerminalsHostImpl(context) const messages = new MessagesHostImpl(context) const commands = new CommandsHostImpl(context) diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 6a1a391..b3aa71e 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -1,11 +1,9 @@ import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { - BuiltinDocksOptions, DevframeDockEntry, DevframeDocksHost as DevframeDocksHostType, DevframeDockUserEntry, - DevframeViewBuiltin, DevframeViewIframe, RemoteConnectionInfo, RemoteDockOptions, @@ -93,15 +91,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { constructor( public readonly context: DevframeHubContext, - /** - * Per-entry toggles for the synthesized built-in dock entries. An omitted - * (or `undefined`) flag keeps that built-in; only an explicit `false` - * suppresses it. - */ - private readonly builtinDocks: BuiltinDocksOptions = {}, - ) { - - } + ) {} async init() { this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', { @@ -112,54 +102,8 @@ export class DevframeDocksHost implements DevframeDocksHostType { }) } - values({ - includeBuiltin = true, - }: { - includeBuiltin?: boolean - } = {}): DevframeDockEntry[] { - const context = this.context - // An omitted flag keeps the built-in; only an explicit `false` drops it. - const { terminals = true, messages = true, settings = true } = this.builtinDocks - const builtinDocksEntries: DevframeViewBuiltin[] = [] - if (terminals) { - builtinDocksEntries.push({ - type: '~builtin', - id: '~terminals', - title: 'Terminals', - icon: 'ph:terminal-duotone', - category: '~builtin', - get when() { - return context.terminals.sessions.size === 0 ? 'false' : undefined - }, - }) - } - if (messages) { - builtinDocksEntries.push({ - type: '~builtin', - id: '~messages', - title: 'Messages & Notifications', - icon: 'ph:notification-duotone', - category: '~builtin', - get badge() { - const size = context.messages.entries.size - return size > 0 ? String(size) : undefined - }, - }) - } - if (settings) { - builtinDocksEntries.push({ - type: '~builtin', - id: '~settings', - title: 'Settings', - category: '~builtin', - icon: 'ph:gear-duotone', - }) - } - - return [ - ...Array.from(this.views.values(), view => this.projectView(view)), - ...(includeBuiltin ? builtinDocksEntries : []), - ] + values(): DevframeDockEntry[] { + return Array.from(this.views.values(), view => this.projectView(view)) } private projectView(view: DevframeDockUserEntry): DevframeDockUserEntry { @@ -195,8 +139,9 @@ export class DevframeDocksHost implements DevframeDocksHostType { } this.validateGroupMembership(view) this.prepareRemoteRegistration(view) - this.views.set(view.id, view) - this.events.emit('dock:entry:updated', view) + const entry = this.withBuiltinCategory(view) + this.views.set(entry.id, entry) + this.events.emit('dock:entry:updated', entry) return { update: (patch) => { @@ -214,8 +159,20 @@ export class DevframeDocksHost implements DevframeDocksHostType { } this.validateGroupMembership(view) this.prepareRemoteRegistration(view) - this.views.set(view.id, view) - this.events.emit('dock:entry:updated', view) + const entry = this.withBuiltinCategory(view) + this.views.set(entry.id, entry) + this.events.emit('dock:entry:updated', entry) + } + + /** + * `~builtin` views default their category to `~builtin` so the viewer's + * native views group together and sort last — a high-level integration + * registering one needn't repeat the category. + */ + private withBuiltinCategory(view: T): T { + if (view.type === '~builtin' && view.category === undefined) + return { ...view, category: '~builtin' } + return view } private validateGroupMembership(view: DevframeDockUserEntry): void { diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 92d8714..bd40665 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -11,38 +11,13 @@ export interface DevframeDocksHost { update: (patch: Partial) => void } update: (entry: DevframeDockUserEntry) => void - values: (options?: { includeBuiltin?: boolean }) => DevframeDockEntry[] -} - -/** - * Per-entry toggles for the hub's synthesized built-in dock entries. - * - * Each flag defaults to `true` (the entry is present). Set one to `false` to - * suppress that built-in everywhere it would otherwise appear — useful when a - * host mounts a plugin that supersedes the built-in tab (e.g. - * `@devframes/plugin-terminals` replacing the `~terminals` entry). - */ -export interface BuiltinDocksOptions { - /** - * Include the built-in `~terminals` dock entry. - * @default true - */ - terminals?: boolean - /** - * Include the built-in `~messages` dock entry. - * @default true - */ - messages?: boolean - /** - * Include the built-in `~settings` dock entry. - * @default true - */ - settings?: boolean + values: () => DevframeDockEntry[] } // Known categories the hub orders by default. Kits may pass their own // category ids; `(string & {})` keeps autocomplete on the known set while -// allowing arbitrary string values. +// allowing arbitrary string values. `~builtin` is reserved for the viewer's +// own built-in views (see {@link DevframeViewBuiltin}) and always sorts last. export type DevframeDockEntryCategory = | 'app' | 'framework' @@ -197,6 +172,16 @@ export interface DevframeViewCustomRender extends DevframeDockEntryBase { renderer: ClientScriptEntry } +/** + * A view rendered natively by the viewer rather than by a plugin — the + * settings panel, the terminals feed, the messages feed, etc. A high-level + * integration registers the built-in views it wants; the viewer recognizes the + * reserved `id` and renders its own UI for it. + * + * Its {@link DevframeDockEntryBase.category} defaults to `'~builtin'` when + * omitted, so built-in views group together and sort last without every + * integration repeating it. + */ export interface DevframeViewBuiltin extends DevframeDockEntryBase { type: '~builtin' id: '~terminals' | '~messages' | '~client-auth-notice' | '~settings' | '~popup' @@ -230,8 +215,8 @@ export interface DevframeViewGroup extends DevframeDockEntryBase { defaultChildId?: string } -export type DevframeDockUserEntry = DevframeViewIframe | DevframeViewAction | DevframeViewCustomRender | DevframeViewLauncher | DevframeViewJsonRender | DevframeViewGroup +export type DevframeDockUserEntry = DevframeViewIframe | DevframeViewAction | DevframeViewCustomRender | DevframeViewLauncher | DevframeViewJsonRender | DevframeViewGroup | DevframeViewBuiltin -export type DevframeDockEntry = DevframeDockUserEntry | DevframeViewBuiltin +export type DevframeDockEntry = DevframeDockUserEntry export type DevframeDockEntriesGrouped = [category: string, entries: DevframeDockEntry[]][] diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 93cac2b..d365d4a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -16,7 +16,6 @@ export declare const defineHubRpcFunction: ; private readonly remoteDocks; - constructor(_: DevframeHubContext, - _?: BuiltinDocksOptions); + constructor(_: DevframeHubContext); init(): Promise; - values({ - includeBuiltin - }?: { - includeBuiltin?: boolean; - }): DevframeDockEntry[]; + values(): DevframeDockEntry[]; private projectView; private resolveDevServerOrigin; register(_: T, _?: boolean): { update: (_: Partial) => void; }; update(_: DevframeDockUserEntry): void; + private withBuiltinCategory; private validateGroupMembership; private prepareRemoteRegistration; } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 5740f8f..1de7d77 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -16,18 +16,18 @@ export class DevframeCommandsHost { } export class DevframeDocksHost { context - builtinDocks views events userSettings remoteDocks - constructor(_, _) {} + constructor(_) {} async init() {} - values(_) {} + values() {} projectView(_) {} resolveDevServerOrigin() {} register(_, _) {} update(_) {} + withBuiltinCategory(_) {} validateGroupMembership(_) {} prepareRemoteRegistration(_) {} } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index d832b93..fddd258 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -2,7 +2,6 @@ * Generated by tsnapi — public API snapshot of `@devframes/hub/types` */ // #region Other -export { BuiltinDocksOptions } export { ClientScriptEntry } export { ConnectionMeta } export { CreateHubContextOptions }