From 2900b4af31ac8a51ca6bdf710e5e83b9077ee5d7 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Fri, 21 Aug 2026 18:28:19 +0200 Subject: [PATCH 1/3] feat(hub): expose panel session lifecycle events --- docs/content/1.guide/20.events.md | 4 ++ .../hub-ui/src/client/state/context.test.ts | 33 +++++++++ packages/hub-ui/src/client/state/context.ts | 15 ++++- .../hub/src/client/__tests__/host.test.ts | 21 ++++++ packages/hub/src/client/host.ts | 24 +++++-- packages/hub/src/client/index.ts | 1 + packages/hub/src/client/panel-state.ts | 10 +++ packages/hub/src/events.ts | 2 + .../hub/src/node/__tests__/host-docks.test.ts | 43 +++++++++++- .../hub/src/node/__tests__/initiate.test.ts | 67 ++++++++++++++++++- .../src/node/__tests__/rpc-builtins.test.ts | 27 ++++++++ packages/hub/src/node/context.ts | 10 +++ packages/hub/src/node/initiate.ts | 7 ++ packages/hub/src/node/panel-state.ts | 39 +++++++++++ packages/hub/src/node/rpc-builtins.ts | 15 +++++ packages/hub/src/types/docks.ts | 7 ++ .../@devframes/hub/client.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/client.snapshot.js | 1 + .../@devframes/hub/constants.snapshot.d.ts | 2 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 13 ++++ .../tsnapi/@devframes/hub/node.snapshot.d.ts | 15 +++++ .../tsnapi/@devframes/hub/node.snapshot.js | 1 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + 23 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 packages/hub/src/client/panel-state.ts create mode 100644 packages/hub/src/node/panel-state.ts diff --git a/docs/content/1.guide/20.events.md b/docs/content/1.guide/20.events.md index ffa998cd..6e5d4479 100644 --- a/docs/content/1.guide/20.events.md +++ b/docs/content/1.guide/20.events.md @@ -19,15 +19,19 @@ Each subsystem host emits on `ctx..events`, consumed **inside the sam |---|---|---|---| | `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` | | `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` | +| `docks:panel:state` | viewer state reports and RPC disconnects | hub consumers | `DevframeDockPanelStateEvent` | | `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; terminals plugin | `DevframeTerminalSession` | | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; messages plugin | entry / entry / id / — | | `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id | +`docks:panel:state` emits `connected` with the first reported `open` value, `changed` when that value changes, and `disconnected` when the reporting RPC connection closes. Its numeric `sessionId` identifies that connection for the lifetime of the Node process. A reload or reconnect receives a new id. + ### Server RPC methods — client → server | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the viewer to switch its active dock — see [Deep Linking](/guide/deep-linking). | +| `hub:docks:panel-state` | `(open) => void` | Report this viewer connection's current dock-panel state. | | `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. | | `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). | | `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. | diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 7df400d9..9b4bbae0 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -1,6 +1,7 @@ import type { DevframeDockEntry } from '@devframes/hub' import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' +import { HUB_EVENTS } from '@devframes/hub/constants' import { DEVFRAME_EVENTS } from 'devframe/constants' import { createEventEmitter } from 'devframe/utils/events' import { createSharedState } from 'devframe/utils/shared-state' @@ -74,6 +75,38 @@ async function flushRestore(): Promise { } describe('createDocksContext', () => { + it('reports the restored panel state and later open-state transitions', async () => { + expect.assertions(4) + + const { rpc, sharedStates, trust } = createStubRpc() + const session = ref({ + open: true, + selectedDockId: 'git', + selectedDockRoute: null, + }) + await createDocksContext('embedded', rpc, undefined, session) + + trust() + sharedStates.get('devframe:docks')!.push([gitEntry]) + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + await vi.waitFor(() => { + if (vi.mocked(rpc.call).mock.calls.length !== 1) + throw new Error('waiting for the restored panel state report') + }) + + expect(rpc.call).toHaveBeenCalledTimes(1) + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, true) + + session.value.open = false + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false) + + session.value.open = false + await nextTick() + expect(rpc.call).toHaveBeenCalledTimes(2) + }) + it('mounts a restored dock once after all initial server state arrives', async () => { expect.assertions(7) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 5819bc73..8751d393 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -4,7 +4,7 @@ import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { Ref } from 'vue' import type { DevframeDocksUserSettings } from './dock-settings' -import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client' +import { attachFrameNavClient, createDockRenderersContext, reportDockPanelState } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants' import { DEVFRAME_EVENTS } from 'devframe/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' @@ -637,12 +637,13 @@ export async function createDocksContext( // the captured session intent. // `switchEntry` then consumes the persisted iframe route when the view boots. const restoreAfterInitialization = async (): Promise => { + await waitUntilTrusted() + const restoreDockId = restoreIntent.selectedDockId if (!restoreIntent.open || restoreDockId == null) return await Promise.all([ - waitUntilTrusted(), dockEntriesInitialSyncComplete, rendererManifestInitialSyncComplete, ]) @@ -658,7 +659,15 @@ export async function createDocksContext( initialRestorePending.value = false await switchEntry(restoreDockId) } - void restoreAfterInitialization() + const reportPanelStateAfterInitialization = async (): Promise => { + await restoreAfterInitialization() + watch( + () => sessionStore.value.open, + open => void reportDockPanelState(rpc, open).catch(() => {}), + { immediate: true }, + ) + } + void reportPanelStateAfterInitialization() docksContextByRpc.set(rpc, docksContext) return docksContext diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 38d9939c..1046f07a 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -3,6 +3,7 @@ import type { SharedState } from 'devframe/utils/shared-state' import type { DevframeDockEntry } from '../../types/docks' import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' +import { HUB_EVENTS } from '../../events' import { getDevframeClientContext } from '../context' import { createDevframeClientHost } from '../host' @@ -67,6 +68,26 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientHost', () => { + it('reports its initial panel state and later open-state assignments', async () => { + expect.assertions(3) + + const { rpc, calls } = createStubRpc() + const host = await createDevframeClientHost({ rpc, clientType: 'embedded' }) + + expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]]) + + host.context.panel.session.open = true + host.context.panel.session.open = true + expect(calls).toEqual([ + [HUB_EVENTS.rpc.docksPanelState, false], + [HUB_EVENTS.rpc.docksPanelState, true], + ]) + + host.context.panel.session.open = false + expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false]) + host.dispose() + }) + it('publishes the global client context with the full surface', async () => { const { rpc } = createStubRpc() const host = await createDevframeClientHost({ rpc }) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index dd8d1a7f..3938b20d 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -32,6 +32,7 @@ import { HUB_EVENTS } from '../events' import { getDevframeClientContext, setDevframeClientContext } from './context' import { attachFrameNavClient } from './frame-nav' import { createMessagesClient } from './messages' +import { reportDockPanelState } from './panel-state' import { createDockRenderersContext } from './renderers' const DOCKS_STATE_KEY = HUB_EVENTS.sharedState.docks @@ -154,7 +155,10 @@ export async function createDevframeClientHost( ...options.categoryOrder, } - const panel = createPanelContext(clientType) + const sendPanelState = (open: boolean): void => { + void reportDockPanelState(rpc, open).catch(() => {}) + } + const panel = createPanelContext(clientType, sendPanelState) const docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -225,6 +229,7 @@ export async function createDevframeClientHost( ) } setDevframeClientContext(context) + sendPanelState(panel.session.open) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -549,7 +554,10 @@ export async function createDevframeClientHost( // ── shared helpers ───────────────────────────────────────────────────────── -function createPanelContext(clientType: DockClientType): DocksPanelContext { +function createPanelContext( + clientType: DockClientType, + onOpenChange: (open: boolean) => void, +): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', width: 480, @@ -559,9 +567,17 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext { position: 'right', inactiveTimeout: 0, } + let open = clientType === 'standalone' const session: DocksPanelContext['session'] = { - // A standalone runtime owns the page, so its "panel" is always open. - open: clientType === 'standalone', + get open() { + return open + }, + set open(nextOpen) { + if (nextOpen === open) + return + open = nextOpen + onOpenChange(open) + }, selectedDockId: null, selectedDockRoute: null, } diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index 78c06ac6..0f785ca2 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -7,6 +7,7 @@ export * from './frame-location' export * from './frame-nav' export * from './host' export * from './messages' +export * from './panel-state' export * from './remote' export * from './renderers' export * from 'devframe/client' diff --git a/packages/hub/src/client/panel-state.ts b/packages/hub/src/client/panel-state.ts new file mode 100644 index 00000000..130207ca --- /dev/null +++ b/packages/hub/src/client/panel-state.ts @@ -0,0 +1,10 @@ +import type { DevframeRpcClient } from 'devframe/client' +import { HUB_EVENTS } from '../events' + +/** Report this RPC connection's current dock-panel state to the hub. */ +export async function reportDockPanelState( + rpc: DevframeRpcClient, + open: boolean, +): Promise { + await rpc.call(HUB_EVENTS.rpc.docksPanelState, open) +} diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index 6205d39f..7f3b21ac 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -23,6 +23,7 @@ export const HUB_EVENTS = { bus: { docksEntryUpdated: 'docks:entry:updated', docksActivate: 'docks:activate', + docksPanelState: 'docks:panel:state', terminalsSessionUpdated: 'terminals:session:updated', messagesAdded: 'messages:added', messagesUpdated: 'messages:updated', @@ -34,6 +35,7 @@ export const HUB_EVENTS = { /** Server RPC methods a connected client calls (client → server), `hub:` prefix. */ rpc: { docksActivate: 'hub:docks:activate', + docksPanelState: 'hub:docks:panel-state', commandsExecute: 'hub:commands:execute', messagesAdd: 'hub:messages:add', messagesUpdate: 'hub:messages:update', diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index c25c681d..e65973e9 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -1,4 +1,4 @@ -import type { DevframeViewLauncher } from '../../types/docks' +import type { DevframeDockPanelStateEvent, DevframeViewLauncher } from '../../types/docks' import type { DevframeHubContext } from '../context' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -7,7 +7,9 @@ import { REMOTE_CONNECTION_KEY } from 'devframe/constants' import { getInternalContext } from 'devframe/node/hub-internals' import { describe, expect, it, vi } from 'vitest' import { parseRemoteConnection } from '../../client/remote' +import { HUB_EVENTS } from '../../events' import { DevframeDocksHost } from '../host-docks' +import { disconnectDockPanelState, updateDockPanelState } from '../panel-state' function createContext(): DevframeHubContext { const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-docks-')) @@ -221,6 +223,45 @@ describe('devframeDockHost activate', () => { }) }) +describe('devframeDockHost panel state', () => { + it('emits the first report and changed values while suppressing duplicates', () => { + expect.assertions(1) + + const host = new DevframeDocksHost(createContext()) + const events: DevframeDockPanelStateEvent[] = [] + host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) + + updateDockPanelState(host, 11, false) + updateDockPanelState(host, 11, false) + updateDockPanelState(host, 11, true) + + expect(events).toEqual([ + { type: 'connected', sessionId: 11, open: false }, + { type: 'changed', sessionId: 11, open: true }, + ]) + }) + + it('tracks sessions independently and disconnects only reporting sessions', () => { + expect.assertions(1) + + const host = new DevframeDocksHost(createContext()) + const events: DevframeDockPanelStateEvent[] = [] + host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) + + updateDockPanelState(host, 11, true) + updateDockPanelState(host, 12, false) + disconnectDockPanelState(host, 99) + disconnectDockPanelState(host, 11) + disconnectDockPanelState(host, 11) + + expect(events).toEqual([ + { type: 'connected', sessionId: 11, open: true }, + { type: 'connected', sessionId: 12, open: false }, + { type: 'disconnected', sessionId: 11 }, + ]) + }) +}) + describe('devframeDockHost ~builtin category', () => { it('returns no docks until an integration registers one', () => { const host = new DevframeDocksHost(createContext()) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 4649b21d..6c61e621 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -1,4 +1,5 @@ import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import type { DevframeDockPanelStateEvent } from '../../types/docks' import { mkdtempSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' @@ -6,8 +7,9 @@ import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { DOCK_RENDERERS_STATE_KEY } from '../../constants' +import { HUB_EVENTS } from '../../events' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' function makeDist(html: string): string { @@ -38,10 +40,12 @@ function makeFrame(id: string, distDir?: string): DevframeDefinition { } function connectWsClient(url: string) { - return createRpcClient( + const channel = createWsRpcChannel({ url }) + const client = createRpcClient( {} as DevframeRpcClientFunctions, - { channel: createWsRpcChannel({ url }) }, + { channel }, ) + return Object.assign(client, { close: channel.close }) } describe('initHub', () => { @@ -152,6 +156,63 @@ describe('initHub', () => { } }) + it('tracks panel state by RPC connection and emits disconnect separately from close', async () => { + expect.assertions(9) + + const host = '127.0.0.1' + const port = await getPort({ port: 18215, host }) + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host, + ws: { port }, + devframes: [makeFrame('alpha')], + }) + const clients: ReturnType[] = [] + + try { + await hub.ready + const context = await hub.context + const lifecycleEvents: DevframeDockPanelStateEvent[] = [] + context.docks.events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event)) + + const firstClient = connectWsClient(`ws://${host}:${port}/__ws`) + const secondClient = connectWsClient(`ws://${host}:${port}/__ws`) + clients.push(firstClient, secondClient) + + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, false) + await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, false) + + expect(lifecycleEvents).toHaveLength(3) + expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', open: true }) + expect(typeof lifecycleEvents[0]!.sessionId).toBe('number') + expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, open: false }) + expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', open: false }) + expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId) + + firstClient.close() + await vi.waitFor(() => { + if (lifecycleEvents.length !== 4) + throw new Error('waiting for the first client to disconnect') + }) + expect(lifecycleEvents[3]).toEqual({ type: 'disconnected', sessionId: lifecycleEvents[0]!.sessionId }) + + const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`) + clients.push(reconnectedClient) + await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + + expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', open: true }) + expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId) + } + finally { + for (const client of clients) + client.close() + await hub.close() + } + }) + it('ui slot: viewer owns the root, embedded.js serves the entry, discovery still wins', async () => { const viewerDist = makeDist('hub viewer') const embeddedDir = mkdtempSync(join(tmpdir(), 'hub-embedded-')) diff --git a/packages/hub/src/node/__tests__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index 5a9e2e0e..40cd54cf 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -1,7 +1,11 @@ +import type { DevframeDockPanelStateEvent } from '../../types/docks' import type { DevframeHubContext } from '../context' +import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' +import { HUB_EVENTS } from '../../events' import { hubDocksActivate, + hubDocksPanelState, hubTerminalsRemove, hubTerminalsResize, hubTerminalsRestart, @@ -138,3 +142,26 @@ describe('hub docks activate RPC', () => { expect(activate).toHaveBeenCalledWith('devframes_plugin_messages', undefined) }) }) + +describe('hub docks panel-state RPC', () => { + it('derives the session id from the active RPC handler context', async () => { + expect.assertions(2) + + const events = createEventEmitter<{ + 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void + }>() + const lifecycleEvents: DevframeDockPanelStateEvent[] = [] + events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event)) + const getCurrentRpcSession = vi.fn(() => ({ meta: { id: 73 } })) + const ctx = { + docks: { events }, + rpc: { getCurrentRpcSession }, + } as unknown as DevframeHubContext + + const fn = await hubDocksPanelState.setup!(ctx) + await fn.handler!(true) + + expect(getCurrentRpcSession).toHaveBeenCalledOnce() + expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, open: true }]) + }) +}) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 6aee0ffc..f38a7a21 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -56,6 +56,16 @@ declare module 'devframe/types' { * selection. Handled by {@link import('./rpc-builtins').hubDocksActivate}. */ 'hub:docks:activate': (input: { dockId: string, params?: Record }) => Promise + /** + * Report this viewer connection's current dock-panel state. The server + * resolves the connection's session id and emits the typed lifecycle event + * on `ctx.docks.events`. + * + * Use `reportDockPanelState()` from `@devframes/hub/client`. + * + * @internal + */ + 'hub:docks:panel-state': (open: boolean) => Promise /** * Invoke a registered server command by id; trailing args are forwarded to * the command's handler. Handled by diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 6f6aa439..ca38bc85 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -22,6 +22,7 @@ import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from ' import { createHubContext } from './context' import { diagnostics } from './diagnostics' import { prepareDevframe } from './install-devframe' +import { disconnectDockPanelState } from './panel-state' /** A `devframes` entry with per-mount dock customization. */ export interface HubDevframeEntry { @@ -429,6 +430,7 @@ export function initHub(options: InitHubOptions): HubInstance { const cwd = options.cwd ?? process.cwd() const frames: { id: string, base: string, title: string }[] = [] const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) + let initializedContext: DevframeHubContext | undefined const shell = createInstanceShell({ base, @@ -441,6 +443,10 @@ export function initHub(options: InitHubOptions): HubInstance { sse: options.sse, allowedOrigins: options.allowedOrigins, destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, + onPeerDisconnect: (_connection, sessionMeta) => { + if (initializedContext) + disconnectDockPanelState(initializedContext.docks, sessionMeta.id) + }, register: resolveInstanceRegister(options.register, { id: options.name ?? 'devframes-hub', ...(options.name !== undefined ? { name: options.name } : {}), @@ -495,6 +501,7 @@ export function initHub(options: InitHubOptions): HubInstance { ...(options.rpcDeclarations ? { builtinRpcDeclarations: options.rpcDeclarations } : {}), }) } + initializedContext = ctx // Publish the host's bare-specifier resolution template before anything // registers a dock, so the docks host's bare-specifier capability check diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts new file mode 100644 index 00000000..a0743f22 --- /dev/null +++ b/packages/hub/src/node/panel-state.ts @@ -0,0 +1,39 @@ +import type { DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' +import { HUB_EVENTS } from '../events' + +const dockPanelStates = new WeakMap>() + +export function updateDockPanelState( + docks: DevframeDocksHost, + sessionId: number, + open: boolean, +): void { + let sessionStates = dockPanelStates.get(docks) + if (!sessionStates) { + sessionStates = new Map() + dockPanelStates.set(docks, sessionStates) + } + + const previousOpen = sessionStates.get(sessionId) + if (previousOpen === open) + return + + sessionStates.set(sessionId, open) + const event: DevframeDockPanelStateEvent = previousOpen === undefined + ? { type: 'connected', sessionId, open } + : { type: 'changed', sessionId, open } + docks.events.emit(HUB_EVENTS.bus.docksPanelState, event) +} + +export function disconnectDockPanelState( + docks: DevframeDocksHost, + sessionId: number, +): void { + const sessionStates = dockPanelStates.get(docks) + if (!sessionStates?.delete(sessionId)) + return + + docks.events.emit(HUB_EVENTS.bus.docksPanelState, { type: 'disconnected', sessionId }) + if (sessionStates.size === 0) + dockPanelStates.delete(docks) +} diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index 03fa34de..a82bf0ec 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -7,6 +7,7 @@ import type { import { defineHubRpcFunction } from '../define' import { HUB_EVENTS } from '../events' import { diagnostics } from './diagnostics' +import { updateDockPanelState } from './panel-state' /** * Resolve an interactive (PTY) terminal session by id, or throw. Sessions @@ -220,6 +221,19 @@ export const hubDocksActivate = defineHubRpcFunction({ }), }) +/** Record the current viewer connection's dock-panel state. */ +export const hubDocksPanelState = defineHubRpcFunction({ + name: HUB_EVENTS.rpc.docksPanelState, + type: 'action', + setup: context => ({ + async handler(open: boolean): Promise { + const session = context.rpc.getCurrentRpcSession() + if (session) + updateDockPanelState(context.docks, session.meta.id, open) + }, + }), +}) + /** * Framework-neutral RPC declarations auto-registered by * {@link createHubContext}. Provide additional RPCs by passing your own @@ -229,6 +243,7 @@ export const hubDocksActivate = defineHubRpcFunction({ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [ hubCommandsExecute, hubDocksActivate, + hubDocksPanelState, hubMessagesAdd, hubMessagesUpdate, hubMessagesRemove, diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 93992520..d68f09bc 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -5,6 +5,7 @@ export interface DevframeDocksHost { readonly events: EventEmitter<{ 'docks:entry:updated': (entry: DevframeDockUserEntry) => void 'docks:activate': (activation: DevframeDockActivation) => void + 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void }> register: (entry: T, force?: boolean) => { @@ -29,6 +30,12 @@ export interface DevframeDocksHost { activate: (dockId: string, params?: Record) => void } +/** Lifecycle event for one viewer's dock panel over an RPC connection. */ +export type DevframeDockPanelStateEvent + = | { type: 'connected', sessionId: number, open: boolean } + | { type: 'changed', sessionId: number, open: boolean } + | { type: 'disconnected', sessionId: number } + /** * A request to switch the active dock. `params` is an opaque, serializable * bag the target dock interprets — the terminals dock reads `params.sessionId` diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index e62aa678..cad6f6a1 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -241,6 +241,7 @@ export declare function createDockRenderersContext(_: CreateDockRenderersContext export declare function createMessagesClient(_: DevframeRpcClient, _?: MessagesClientOptions): DevframeMessagesClient; export declare function getDevframeClientContext(): DevframeClientContext | undefined; export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; +export declare function reportDockPanelState(_: DevframeRpcClient, _: boolean): Promise; export declare function resolveClientModuleSpecifier(_: string, _?: { resolveClientModule?: (_: string) => string | undefined; template?: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index 7cc6258b..8dbaef98 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -10,6 +10,7 @@ export function createDockRenderersContext(_) {} export function createMessagesClient(_, _) {} export function getDevframeClientContext() {} export function parseRemoteConnection(_) {} +export async function reportDockPanelState(_, _) {} export function resolveDockIcon(_, _) {} export function resolveDockUrl(_, _) {} export function setDevframeClientContext(_) {} diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 4d2876e2..09ea96a7 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -14,6 +14,7 @@ export declare const HUB_EVENTS: { readonly bus: { readonly docksEntryUpdated: "docks:entry:updated"; readonly docksActivate: "docks:activate"; + readonly docksPanelState: "docks:panel:state"; readonly terminalsSessionUpdated: "terminals:session:updated"; readonly messagesAdded: "messages:added"; readonly messagesUpdated: "messages:updated"; @@ -24,6 +25,7 @@ export declare const HUB_EVENTS: { }; readonly rpc: { readonly docksActivate: "hub:docks:activate"; + readonly docksPanelState: "hub:docks:panel-state"; readonly commandsExecute: "hub:commands:execute"; readonly messagesAdd: "hub:messages:add"; readonly messagesUpdate: "hub:messages:update"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 76e049e8..777f02b3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -109,6 +109,7 @@ export interface DevframeDocksHost { readonly events: EventEmitter<{ 'docks:entry:updated': (entry: DevframeDockUserEntry) => void; 'docks:activate': (activation: DevframeDockActivation) => void; + 'docks:panel:state': (event: DevframeDockPanelStateEvent) => void; }>; register: (_: T, _?: boolean) => { update: (_: Partial) => void; @@ -346,6 +347,18 @@ export type DevframeDockEntryIcon = string | { light: string; dark: string; }; +export type DevframeDockPanelStateEvent = { + type: 'connected'; + sessionId: number; + open: boolean; +} | { + type: 'changed'; + sessionId: number; + open: boolean; +} | { + type: 'disconnected'; + sessionId: number; +}; export type DevframeDockUserEntry = DevframeDockEntryRegistry[keyof DevframeDockEntryRegistry]; export type DevframeMessageAction = DevframeMessageActivateAction | DevframeMessageCommandAction; export type DevframeMessageEntryFrom = 'server' | 'browser'; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 68ad102b..f77cbfb6 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -132,6 +132,21 @@ export declare const hubDocksActivate: { params?: Record; }], Promise>> | undefined; }; +export declare const hubDocksPanelState: { + name: "hub:docks:panel-state"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((open: boolean) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[open: boolean], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}; export declare const hubMessagesAdd: { name: "hub:messages:add"; type?: "action" | undefined; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index e8bd00c5..3c5c1bb3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -14,6 +14,7 @@ export { DevframeMessagesHost } export { DevframeTerminalsHost } export { hubCommandsExecute } export { hubDocksActivate } +export { hubDocksPanelState } export { hubMessagesAdd } export { hubMessagesClear } export { hubMessagesRemove } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 2eb67965..39af4d73 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -29,6 +29,7 @@ export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } export { DevframeDockEntryRegistry } +export { DevframeDockPanelStateEvent } export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } From 652fd8cde5734678137afb312584303ae86e285d Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 22 Aug 2026 13:09:58 +0200 Subject: [PATCH 2/3] docs(hub-ui): explain dock restore trust gate --- packages/hub-ui/src/client/state/context.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 8751d393..827f923e 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -637,6 +637,7 @@ export async function createDocksContext( // the captured session intent. // `switchEntry` then consumes the persisted iframe route when the view boots. const restoreAfterInitialization = async (): Promise => { + // The authorization gate can still clear the live session on reload, so restore only after it settles. await waitUntilTrusted() const restoreDockId = restoreIntent.selectedDockId From 281aecc8e3b2a9924ccf4496272ec36663632aca Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 22 Aug 2026 13:22:37 +0200 Subject: [PATCH 3/3] fix(hub): align dock panel state event name --- docs/content/1.guide/20.events.md | 2 +- packages/hub/src/events.ts | 2 +- packages/hub/src/node/context.ts | 2 +- .../__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts | 2 +- tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/content/1.guide/20.events.md b/docs/content/1.guide/20.events.md index 6e5d4479..ab23f89e 100644 --- a/docs/content/1.guide/20.events.md +++ b/docs/content/1.guide/20.events.md @@ -31,7 +31,7 @@ Each subsystem host emits on `ctx..events`, consumed **inside the sam | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the viewer to switch its active dock — see [Deep Linking](/guide/deep-linking). | -| `hub:docks:panel-state` | `(open) => void` | Report this viewer connection's current dock-panel state. | +| `hub:docks:panel:state` | `(open) => void` | Report this viewer connection's current dock-panel state. | | `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. | | `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). | | `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. | diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index 7f3b21ac..870043a4 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -35,7 +35,7 @@ export const HUB_EVENTS = { /** Server RPC methods a connected client calls (client → server), `hub:` prefix. */ rpc: { docksActivate: 'hub:docks:activate', - docksPanelState: 'hub:docks:panel-state', + docksPanelState: 'hub:docks:panel:state', commandsExecute: 'hub:commands:execute', messagesAdd: 'hub:messages:add', messagesUpdate: 'hub:messages:update', diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index f38a7a21..4fe403d1 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -65,7 +65,7 @@ declare module 'devframe/types' { * * @internal */ - 'hub:docks:panel-state': (open: boolean) => Promise + 'hub:docks:panel:state': (open: boolean) => Promise /** * Invoke a registered server command by id; trailing args are forwarded to * the command's handler. Handled by diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 09ea96a7..fb4525ec 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -25,7 +25,7 @@ export declare const HUB_EVENTS: { }; readonly rpc: { readonly docksActivate: "hub:docks:activate"; - readonly docksPanelState: "hub:docks:panel-state"; + readonly docksPanelState: "hub:docks:panel:state"; readonly commandsExecute: "hub:commands:execute"; readonly messagesAdd: "hub:messages:add"; readonly messagesUpdate: "hub:messages:update"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index f77cbfb6..667980df 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -133,7 +133,7 @@ export declare const hubDocksActivate: { }], Promise>> | undefined; }; export declare const hubDocksPanelState: { - name: "hub:docks:panel-state"; + name: "hub:docks:panel:state"; type?: "action" | undefined; cacheable?: boolean; args?: undefined;