From 713b6684dcc986a474d301b8aeb8517724db63e3 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Fri, 21 Aug 2026 18:28:19 +0200 Subject: [PATCH 1/8] feat(hub): expose panel session lifecycle events --- docs/content/8.references/3.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/8.references/3.events.md b/docs/content/8.references/3.events.md index 473bc115..6ea1dec1 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -21,15 +21,19 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod |---|---|---|---| | `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`; the terminals devframe | `DevframeTerminalSession` | | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | 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 hub UI provider 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 e40743d7..2507f506 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' @@ -640,12 +640,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, ]) @@ -661,7 +662,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 ff4c9aea..dfc8f1d0 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 { createDevframeClientRuntime } from '../host' @@ -67,6 +68,26 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientRuntime', () => { + it('reports its initial panel state and later open-state assignments', async () => { + expect.assertions(3) + + const { rpc, calls } = createStubRpc() + const host = await createDevframeClientRuntime({ 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 createDevframeClientRuntime({ rpc }) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 1bc8b685..735553c7 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 createDevframeClientRuntime( ...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 createDevframeClientRuntime( ) } setDevframeClientContext(context) + sendPanelState(panel.session.open) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -549,7 +554,10 @@ export async function createDevframeClientRuntime( // ── 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 a9fce268..86ad652d 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 e1dd1032..6e01e782 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 1905ec37..cd39fb65 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 f8e4f384..cffc9ebe 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 42fca8c4..a9343afe 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -246,6 +246,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 0f1b65ec..6f908298 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 96010d69..bd665758 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 7dacb3d30c2987052330f43b036542ce310db6e6 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 22 Aug 2026 13:09:58 +0200 Subject: [PATCH 2/8] 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 2507f506..256b8b18 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -640,6 +640,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 d0c651a50184e8cbce659f1fa90825498a4cd950 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 22 Aug 2026 13:22:37 +0200 Subject: [PATCH 3/8] fix(hub): align dock panel state event name --- docs/content/8.references/3.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/8.references/3.events.md b/docs/content/8.references/3.events.md index 6ea1dec1..055b658f 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -33,7 +33,7 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod | Method | Signature | Purpose | |---|---|---| | `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the hub UI provider 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 86ad652d..ed55eead 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; From 078fbc0deb1dba0a9337bbc1ceba867ae76a0da0 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 10:57:02 +0200 Subject: [PATCH 4/8] feat(hub): report extensible panel state --- docs/content/8.references/3.events.md | 4 +- packages/hub-ui/src/client/embedded/index.ts | 10 ++++- .../src/client/embedded/visibility.test.ts | 42 +++++++++++++++++++ .../hub-ui/src/client/embedded/visibility.ts | 10 +++-- .../hub-ui/src/client/state/context.test.ts | 32 ++++++++++---- packages/hub-ui/src/client/state/context.ts | 12 +++++- .../hub/src/client/__tests__/host.test.ts | 25 +++++++---- .../src/client/__tests__/panel-state.test.ts | 20 +++++++++ packages/hub/src/client/host.ts | 40 ++++++++++++++---- packages/hub/src/client/panel-state.ts | 5 ++- .../hub/src/node/__tests__/host-docks.test.ts | 20 +++++---- .../hub/src/node/__tests__/initiate.test.ts | 18 ++++---- .../src/node/__tests__/rpc-builtins.test.ts | 4 +- packages/hub/src/node/context.ts | 2 +- packages/hub/src/node/panel-state.ts | 26 ++++++++---- packages/hub/src/node/rpc-builtins.ts | 5 ++- packages/hub/src/types/docks.ts | 10 ++++- .../@devframes/hub/client.snapshot.d.ts | 2 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 12 +++--- .../tsnapi/@devframes/hub/node.snapshot.d.ts | 10 ++--- .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + 21 files changed, 232 insertions(+), 78 deletions(-) create mode 100644 packages/hub-ui/src/client/embedded/visibility.test.ts create mode 100644 packages/hub/src/client/__tests__/panel-state.test.ts diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 055b658f..d02e3a4a 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -26,14 +26,14 @@ Each subsystem emits on `ctx..events`, consumed **inside the same nod | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; the messages devframe | 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. +`docks:panel:state` emits `connected` with the first reported panel snapshot, `changed` when its state or selected dock changes, and `disconnected` when the reporting RPC connection closes. A snapshot contains `state: 'open' | 'closed' | 'hidden'` and an optional `selectedDockId`. 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 hub UI provider 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` | `(panelState: DevframeDockPanelState) => void` | Report this viewer connection's current dock-panel state and selected dock. | | `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/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index 26802d36..d42acb93 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -1,6 +1,7 @@ import type { DockPanelStorage, DockSessionStorage } from '@devframes/hub/client' import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client' import { useLocalStorage, useSessionStorage } from '@vueuse/core' +import { ref } from 'vue' import { applyPrimaryColor, setBranding } from '../state/branding' import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks' import { setupEmbeddedVisibility } from './visibility' @@ -74,8 +75,9 @@ async function mountDock(): Promise { // carried by the connection we just established above. const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {}) + const panelVisible = ref() const { createDocksContext } = await import('../state/context') - const context = await createDocksContext('embedded', rpc, state, session) + const context = await createDocksContext('embedded', rpc, state, session, panelVisible) setDevframeClientContext(context) const { DockEmbedded } = await import('../components/DockEmbedded') @@ -97,8 +99,12 @@ async function mountDock(): Promise { show: () => { if (dockEl && !dockEl.isConnected) document.body.appendChild(dockEl) + panelVisible.value = true + }, + hide: () => { + dockEl?.remove() + panelVisible.value = false }, - hide: () => dockEl?.remove(), }, ) } diff --git a/packages/hub-ui/src/client/embedded/visibility.test.ts b/packages/hub-ui/src/client/embedded/visibility.test.ts new file mode 100644 index 00000000..ef0e60fc --- /dev/null +++ b/packages/hub-ui/src/client/embedded/visibility.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HUB_UI_HIDE_EVENT } from '../constants' +import { setupEmbeddedVisibility } from './visibility' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('setupEmbeddedVisibility', () => { + it('reports the initial hidden state and later reveal and conceal transitions', () => { + expect.assertions(5) + + const listeners = new Map() + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.set(type, listener) + }), + }) + const show = vi.fn() + const hide = vi.fn() + + setupEmbeddedVisibility('hidden', 'Devframe', { show, hide }) + + expect(hide).toHaveBeenCalledOnce() + expect(show).not.toHaveBeenCalled() + + const preventDefault = vi.fn() + listeners.get('keydown')!({ + shiftKey: true, + altKey: true, + ctrlKey: false, + metaKey: false, + code: 'KeyD', + preventDefault, + } as unknown as KeyboardEvent) + expect(preventDefault).toHaveBeenCalledOnce() + expect(show).toHaveBeenCalledOnce() + + listeners.get(HUB_UI_HIDE_EVENT)!({} as Event) + expect(hide).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/hub-ui/src/client/embedded/visibility.ts b/packages/hub-ui/src/client/embedded/visibility.ts index 7dd01403..211c7197 100644 --- a/packages/hub-ui/src/client/embedded/visibility.ts +++ b/packages/hub-ui/src/client/embedded/visibility.ts @@ -95,10 +95,14 @@ export function setupEmbeddedVisibility( handlers.hide() } - if (shown) + if (shown) { handlers.show() - else if (mode === 'passive') - printHint(label) + } + else { + handlers.hide() + if (mode === 'passive') + printHint(label) + } // Shift+Alt+D toggles the dock — the always-available "summon" chord. window.addEventListener('keydown', (e) => { diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 9b4bbae0..5741255f 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -75,36 +75,54 @@ async function flushRestore(): Promise { } describe('createDocksContext', () => { - it('reports the restored panel state and later open-state transitions', async () => { - expect.assertions(4) + it('reports restored, selected, hidden, and closed panel snapshots', async () => { + expect.assertions(8) const { rpc, sharedStates, trust } = createStubRpc() + const panelVisible = ref() const session = ref({ open: true, selectedDockId: 'git', selectedDockRoute: null, }) - await createDocksContext('embedded', rpc, undefined, session) + await createDocksContext('embedded', rpc, undefined, session, panelVisible) trust() sharedStates.get('devframe:docks')!.push([gitEntry]) sharedStates.get('devframe:dock-renderers')!.push({}) await flushRestore() + expect(rpc.call).not.toHaveBeenCalled() + + panelVisible.value = true + await nextTick() 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) + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + + session.value.selectedDockId = '~settings' + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: '~settings' }) + + panelVisible.value = false + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden', selectedDockId: '~settings' }) session.value.open = false + session.value.selectedDockId = null + await nextTick() + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' }) + + panelVisible.value = true await nextTick() - expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false) + expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }) + panelVisible.value = true session.value.open = false await nextTick() - expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call).toHaveBeenCalledTimes(5) }) it('mounts a restored dock once after all initial server state arrives', async () => { diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 256b8b18..65d706ff 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -23,6 +23,7 @@ export async function createDocksContext( rpc: DevframeRpcClient, panelStore?: Ref, sessionStore?: Ref, + panelVisible: Ref = ref(true), ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -666,8 +667,15 @@ export async function createDocksContext( const reportPanelStateAfterInitialization = async (): Promise => { await restoreAfterInitialization() watch( - () => sessionStore.value.open, - open => void reportDockPanelState(rpc, open).catch(() => {}), + [panelVisible, () => sessionStore.value.open, selectedDockId], + ([visible, open, currentSelectedDockId]) => { + if (visible === undefined) + return + void reportDockPanelState(rpc, { + state: visible ? (open ? 'open' : 'closed') : 'hidden', + ...(currentSelectedDockId !== null ? { selectedDockId: currentSelectedDockId } : {}), + }).catch(() => {}) + }, { immediate: true }, ) } diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index dfc8f1d0..0a2262c8 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -68,23 +68,32 @@ function groupEntry(id: string, extra?: Record): DevframeDockEn } describe('createDevframeClientRuntime', () => { - it('reports its initial panel state and later open-state assignments', async () => { - expect.assertions(3) + it('reports complete panel snapshots and coalesces related session changes', async () => { + expect.assertions(4) - const { rpc, calls } = createStubRpc() + const { rpc, calls, states } = createStubRpc() const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' }) - expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]]) + expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]]) + states.get('devframe:docks')!.push([iframeEntry('one')]) host.context.panel.session.open = true - host.context.panel.session.open = true + const switched = host.context.docks.switchEntry('one') + await switched expect(calls).toEqual([ - [HUB_EVENTS.rpc.docksPanelState, false], - [HUB_EVENTS.rpc.docksPanelState, true], + [HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }], + [HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'one' }], ]) + host.context.panel.session.open = true + host.context.panel.session.selectedDockId = 'one' + await Promise.resolve() + expect(calls).toHaveLength(2) + host.context.panel.session.open = false - expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false]) + const cleared = host.context.docks.switchEntry(null) + await cleared + expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }]) host.dispose() }) diff --git a/packages/hub/src/client/__tests__/panel-state.test.ts b/packages/hub/src/client/__tests__/panel-state.test.ts new file mode 100644 index 00000000..91ed29f4 --- /dev/null +++ b/packages/hub/src/client/__tests__/panel-state.test.ts @@ -0,0 +1,20 @@ +import type { DevframeRpcClient } from 'devframe/client' +import { describe, expect, it, vi } from 'vitest' +import { HUB_EVENTS } from '../../events' +import { reportDockPanelState } from '../panel-state' + +describe('reportDockPanelState', () => { + it('reports the complete panel snapshot through the hub RPC', async () => { + expect.assertions(1) + + const call = vi.fn(async () => {}) + const rpc = { call } as unknown as DevframeRpcClient + + await reportDockPanelState(rpc, { state: 'open', selectedDockId: 'git' }) + + expect(call).toHaveBeenCalledWith( + HUB_EVENTS.rpc.docksPanelState, + { state: 'open', selectedDockId: 'git' }, + ) + }) +}) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 735553c7..b3257ec1 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -20,6 +20,7 @@ import type { DockClientType, DockEntryState, DocksEntriesContext, + DockSessionStorage, DocksPanelContext, WhenClauseContext, } from './docks' @@ -125,6 +126,7 @@ export async function createDevframeClientRuntime( ): Promise { const clientType: DockClientType = options.clientType ?? 'standalone' const rpc = options.rpc ?? await connectDevframe(options.connect) + let disposed = false // Set by createRenderersContext(); teardown disposes every live mount. let mountedRenderers: Set<() => void> | undefined @@ -155,10 +157,24 @@ export async function createDevframeClientRuntime( ...options.categoryOrder, } - const sendPanelState = (open: boolean): void => { - void reportDockPanelState(rpc, open).catch(() => {}) + const reportPanelState = (session: DockSessionStorage): void => { + void reportDockPanelState(rpc, { + state: session.open ? 'open' : 'closed', + ...(session.selectedDockId !== null ? { selectedDockId: session.selectedDockId } : {}), + }).catch(() => {}) } - const panel = createPanelContext(clientType, sendPanelState) + let panelStateReportPending = false + const schedulePanelStateReport = (session: DockSessionStorage): void => { + if (panelStateReportPending) + return + panelStateReportPending = true + queueMicrotask(() => { + panelStateReportPending = false + if (!disposed) + reportPanelState(session) + }) + } + const panel = createPanelContext(clientType, schedulePanelStateReport) const docks = createDocksContext() const commands = createCommandsContext() const renderers = createRenderersContext() @@ -229,7 +245,7 @@ export async function createDevframeClientRuntime( ) } setDevframeClientContext(context) - sendPanelState(panel.session.open) + reportPanelState(panel.session) const loadedScripts = new Set() if (loadScriptsEnabled) { @@ -240,6 +256,7 @@ export async function createDevframeClientRuntime( return { context, dispose() { + disposed = true for (const off of disposers.splice(0)) off() for (const disposeAdapter of frameNavAdapters.values()) disposeAdapter() frameNavAdapters.clear() @@ -556,7 +573,7 @@ export async function createDevframeClientRuntime( function createPanelContext( clientType: DockClientType, - onOpenChange: (open: boolean) => void, + onSessionChange: (session: DockSessionStorage) => void, ): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', @@ -568,6 +585,7 @@ function createPanelContext( inactiveTimeout: 0, } let open = clientType === 'standalone' + let selectedDockId: string | null = null const session: DocksPanelContext['session'] = { get open() { return open @@ -576,9 +594,17 @@ function createPanelContext( if (nextOpen === open) return open = nextOpen - onOpenChange(open) + onSessionChange(session) + }, + get selectedDockId() { + return selectedDockId + }, + set selectedDockId(nextSelectedDockId) { + if (nextSelectedDockId === selectedDockId) + return + selectedDockId = nextSelectedDockId + onSessionChange(session) }, - selectedDockId: null, selectedDockRoute: null, } return { diff --git a/packages/hub/src/client/panel-state.ts b/packages/hub/src/client/panel-state.ts index 130207ca..d0542830 100644 --- a/packages/hub/src/client/panel-state.ts +++ b/packages/hub/src/client/panel-state.ts @@ -1,10 +1,11 @@ import type { DevframeRpcClient } from 'devframe/client' +import type { DevframeDockPanelState } from '../types/docks' 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, + panelState: DevframeDockPanelState, ): Promise { - await rpc.call(HUB_EVENTS.rpc.docksPanelState, open) + await rpc.call(HUB_EVENTS.rpc.docksPanelState, panelState) } diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index e65973e9..197a2447 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -231,13 +231,15 @@ describe('devframeDockHost panel state', () => { 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) + updateDockPanelState(host, 11, { state: 'closed' }) + updateDockPanelState(host, 11, { state: 'closed' }) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'inspect' }) expect(events).toEqual([ - { type: 'connected', sessionId: 11, open: false }, - { type: 'changed', sessionId: 11, open: true }, + { type: 'connected', sessionId: 11, state: 'closed' }, + { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'git' }, + { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'inspect' }, ]) }) @@ -248,15 +250,15 @@ describe('devframeDockHost panel state', () => { const events: DevframeDockPanelStateEvent[] = [] host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - updateDockPanelState(host, 11, true) - updateDockPanelState(host, 12, false) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) + updateDockPanelState(host, 12, { state: 'hidden' }) 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: 'connected', sessionId: 11, state: 'open', selectedDockId: 'git' }, + { type: 'connected', sessionId: 12, state: 'hidden' }, { type: 'disconnected', sessionId: 11 }, ]) }) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 6e01e782..b7cd0af9 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -180,16 +180,16 @@ describe('initHub', () => { 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) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open', selectedDockId: 'git' }) + await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'closed' }) + await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'hidden' }) expect(lifecycleEvents).toHaveLength(3) - expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', open: true }) + expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', state: 'open', selectedDockId: 'git' }) 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[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, state: 'closed' }) + expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', state: 'hidden' }) expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId) firstClient.close() @@ -201,9 +201,9 @@ describe('initHub', () => { const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`) clients.push(reconnectedClient) - await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, true) + await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, { state: 'open' }) - expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', open: true }) + expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', state: 'open' }) expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId) } finally { diff --git a/packages/hub/src/node/__tests__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index 40cd54cf..cc348d40 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -159,9 +159,9 @@ describe('hub docks panel-state RPC', () => { } as unknown as DevframeHubContext const fn = await hubDocksPanelState.setup!(ctx) - await fn.handler!(true) + await fn.handler!({ state: 'open', selectedDockId: 'git' }) expect(getCurrentRpcSession).toHaveBeenCalledOnce() - expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, open: true }]) + expect(lifecycleEvents).toEqual([{ type: 'connected', sessionId: 73, state: 'open', selectedDockId: 'git' }]) }) }) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 4fe403d1..4660b15c 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': (panelState: import('../types/docks').DevframeDockPanelState) => 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/panel-state.ts b/packages/hub/src/node/panel-state.ts index a0743f22..9b1ba270 100644 --- a/packages/hub/src/node/panel-state.ts +++ b/packages/hub/src/node/panel-state.ts @@ -1,12 +1,12 @@ -import type { DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' +import type { DevframeDockPanelState, DevframeDockPanelStateEvent, DevframeDocksHost } from '../types/docks' import { HUB_EVENTS } from '../events' -const dockPanelStates = new WeakMap>() +const dockPanelStates = new WeakMap>() export function updateDockPanelState( docks: DevframeDocksHost, sessionId: number, - open: boolean, + panelState: DevframeDockPanelState, ): void { let sessionStates = dockPanelStates.get(docks) if (!sessionStates) { @@ -14,14 +14,22 @@ export function updateDockPanelState( dockPanelStates.set(docks, sessionStates) } - const previousOpen = sessionStates.get(sessionId) - if (previousOpen === open) + const previousState = sessionStates.get(sessionId) + if ( + previousState?.state === panelState.state + && previousState.selectedDockId === panelState.selectedDockId + ) { return + } - sessionStates.set(sessionId, open) - const event: DevframeDockPanelStateEvent = previousOpen === undefined - ? { type: 'connected', sessionId, open } - : { type: 'changed', sessionId, open } + const currentState: DevframeDockPanelState = { + state: panelState.state, + ...(panelState.selectedDockId !== undefined ? { selectedDockId: panelState.selectedDockId } : {}), + } + sessionStates.set(sessionId, currentState) + const event: DevframeDockPanelStateEvent = previousState === undefined + ? { type: 'connected', sessionId, ...currentState } + : { type: 'changed', sessionId, ...currentState } docks.events.emit(HUB_EVENTS.bus.docksPanelState, event) } diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index cffc9ebe..c88431a5 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -1,4 +1,5 @@ import type { RpcFunctionDefinitionAny } from 'devframe/rpc' +import type { DevframeDockPanelState } from '../types/docks' import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../types/messages' import type { DevframeChildProcessTerminalSession, @@ -226,10 +227,10 @@ export const hubDocksPanelState = defineHubRpcFunction({ name: HUB_EVENTS.rpc.docksPanelState, type: 'action', setup: context => ({ - async handler(open: boolean): Promise { + async handler(panelState: DevframeDockPanelState): Promise { const session = context.rpc.getCurrentRpcSession() if (session) - updateDockPanelState(context.docks, session.meta.id, open) + updateDockPanelState(context.docks, session.meta.id, panelState) }, }), }) diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index d68f09bc..cef050cf 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -30,10 +30,16 @@ export interface DevframeDocksHost { activate: (dockId: string, params?: Record) => void } +/** Current state reported by one dock-panel viewer. */ +export interface DevframeDockPanelState { + state: 'open' | 'closed' | 'hidden' + selectedDockId?: string +} + /** 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: 'connected', sessionId: number } & DevframeDockPanelState) + | ({ type: 'changed', sessionId: number } & DevframeDockPanelState) | { type: 'disconnected', sessionId: number } /** diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index a9343afe..d843f0da 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -246,7 +246,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 reportDockPanelState(_: DevframeRpcClient, _: DevframeDockPanelState): Promise; export declare function resolveClientModuleSpecifier(_: string, _?: { resolveClientModule?: (_: string) => string | undefined; template?: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index bd665758..8005cd17 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -101,6 +101,10 @@ export interface DevframeDockEntryRegistry { 'group': DevframeViewGroup; '~builtin': DevframeViewBuiltin; } +export interface DevframeDockPanelState { + state: 'open' | 'closed' | 'hidden'; + selectedDockId?: string; +} export interface DevframeDocksActiveState { activation: DevframeDockActivation | null; } @@ -347,15 +351,13 @@ export type DevframeDockEntryIcon = string | { light: string; dark: string; }; -export type DevframeDockPanelStateEvent = { +export type DevframeDockPanelStateEvent = ({ type: 'connected'; sessionId: number; - open: boolean; -} | { +} & DevframeDockPanelState) | ({ type: 'changed'; sessionId: number; - open: boolean; -} | { +} & DevframeDockPanelState) | { type: 'disconnected'; sessionId: number; }; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 667980df..c1859bc4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -140,12 +140,12 @@ export declare const hubDocksPanelState: { 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; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((panelState: DevframeDockPanelState) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[panelState: DevframeDockPanelState], Promise, DevframeHubContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>>> | undefined; - __promise?: import("devframe/rpc").Thenable>> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }; export declare const hubMessagesAdd: { name: "hub:messages:add"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 39af4d73..b496606d 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 { DevframeDockPanelState } export { DevframeDockPanelStateEvent } export { DevframeDocksActiveState } export { DevframeDocksHost } From bb71e9c99dc710713c068c1549e193ad5bcc5351 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:15:59 +0200 Subject: [PATCH 5/8] refactor(hub): clarify panel state snapshots --- packages/hub-ui/src/client/state/context.ts | 28 +++++++++++++++++---- packages/hub/src/client/host.ts | 15 ++++++++--- packages/hub/src/node/panel-state.ts | 5 +--- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 65d706ff..bf54cc3c 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -1,4 +1,4 @@ -import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' +import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' @@ -18,6 +18,26 @@ import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDo import { executeSetupScript } from './setup-script' const docksContextByRpc = new WeakMap() + +function createDockPanelState( + visible: boolean, + open: boolean, + selectedDockId: string | null, +): DevframeDockPanelState { + let state: DevframeDockPanelState['state'] + if (!visible) + state = 'hidden' + else if (open) + state = 'open' + else + state = 'closed' + + const panelState: DevframeDockPanelState = { state } + if (selectedDockId !== null) + panelState.selectedDockId = selectedDockId + return panelState +} + export async function createDocksContext( clientType: 'embedded' | 'standalone', rpc: DevframeRpcClient, @@ -671,10 +691,8 @@ export async function createDocksContext( ([visible, open, currentSelectedDockId]) => { if (visible === undefined) return - void reportDockPanelState(rpc, { - state: visible ? (open ? 'open' : 'closed') : 'hidden', - ...(currentSelectedDockId !== null ? { selectedDockId: currentSelectedDockId } : {}), - }).catch(() => {}) + const panelState = createDockPanelState(visible, open, currentSelectedDockId) + void reportDockPanelState(rpc, panelState).catch(() => {}) }, { immediate: true }, ) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index b3257ec1..124ac19e 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -10,6 +10,7 @@ import type { ClientScriptEntry, DevframeDockEntriesGrouped, DevframeDockEntry, + DevframeDockPanelState, DevframeViewIframe, } from '../types/docks' import type { DevframeDocksUserSettings } from '../types/settings' @@ -158,10 +159,7 @@ export async function createDevframeClientRuntime( } const reportPanelState = (session: DockSessionStorage): void => { - void reportDockPanelState(rpc, { - state: session.open ? 'open' : 'closed', - ...(session.selectedDockId !== null ? { selectedDockId: session.selectedDockId } : {}), - }).catch(() => {}) + void reportDockPanelState(rpc, createDockPanelState(session)).catch(() => {}) } let panelStateReportPending = false const schedulePanelStateReport = (session: DockSessionStorage): void => { @@ -618,6 +616,15 @@ function createPanelContext( } } +function createDockPanelState(session: DockSessionStorage): DevframeDockPanelState { + const panelState: DevframeDockPanelState = { + state: session.open ? 'open' : 'closed', + } + if (session.selectedDockId !== null) + panelState.selectedDockId = session.selectedDockId + return panelState +} + function groupByCategory(entries: DevframeDockEntry[], categoryOrder: Record): DevframeDockEntriesGrouped { // Index registered groups so a member whose `groupId` resolves takes its // OUTER bucket from the group's category, not its own (which becomes the diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts index 9b1ba270..1d82a691 100644 --- a/packages/hub/src/node/panel-state.ts +++ b/packages/hub/src/node/panel-state.ts @@ -22,10 +22,7 @@ export function updateDockPanelState( return } - const currentState: DevframeDockPanelState = { - state: panelState.state, - ...(panelState.selectedDockId !== undefined ? { selectedDockId: panelState.selectedDockId } : {}), - } + const currentState: DevframeDockPanelState = { ...panelState } sessionStates.set(sessionId, currentState) const event: DevframeDockPanelStateEvent = previousState === undefined ? { type: 'connected', sessionId, ...currentState } From 6acd97f6d7a4777d00109dbd5080914ab055fd8c Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:29:11 +0200 Subject: [PATCH 6/8] chore(examples): log dock panel state updates --- examples/hub-vite/vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts index abbd1b9e..57ab81ae 100644 --- a/examples/hub-vite/vite.config.ts +++ b/examples/hub-vite/vite.config.ts @@ -1,5 +1,7 @@ import type { DevframeHubContext } from '@devframes/hub/node' +import { styleText } from 'node:util' import { defineHubRpcFunction } from '@devframes/hub' +import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import createA11yDevframe, { a11yPageScriptBundlePath } from '@devframes/plugin-a11y' @@ -156,6 +158,10 @@ export default defineConfig({ // into this host's own bundle. renderers: [jsonRenderUiRenderer()], configure: async (context) => { + context.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { + console.info(styleText('cyan', '🔄 [hub-vite]'), 'Dock panel state updated', event) + }) + // Seed a sample command directly on the hub so the UI shows something // even without any plugged-in devframes. context.commands.register({ From 87bf65dcbfde9b98f9ba466babb91e3f3a27f90c Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:45:01 +0200 Subject: [PATCH 7/8] fix(examples): log embedded panel state updates --- examples/hub-vite-minimal/vite.config.ts | 6 ++++++ examples/hub-vite/vite.config.ts | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/hub-vite-minimal/vite.config.ts b/examples/hub-vite-minimal/vite.config.ts index 0259e8ae..eb99334a 100644 --- a/examples/hub-vite-minimal/vite.config.ts +++ b/examples/hub-vite-minimal/vite.config.ts @@ -1,6 +1,8 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' +import { styleText } from 'node:util' import { createUi } from '@devframes/hub-ui' +import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { createA11yDevframe } from '@devframes/plugin-a11y' import { createAssetsDevframe } from '@devframes/plugin-assets' @@ -78,6 +80,10 @@ export default defineConfig({ // the same contract. renderers: [jsonRenderUiRenderer()], configure(ctx) { + ctx.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { + console.info(styleText('cyan', '🔄 [hub-vite-minimal]'), 'Dock panel state updated', event) + }) + ctx.docks.register(jsonRenderDock) }, // Gate with devframe's interactive OTP (the default): the hub prints a diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts index 57ab81ae..abbd1b9e 100644 --- a/examples/hub-vite/vite.config.ts +++ b/examples/hub-vite/vite.config.ts @@ -1,7 +1,5 @@ import type { DevframeHubContext } from '@devframes/hub/node' -import { styleText } from 'node:util' import { defineHubRpcFunction } from '@devframes/hub' -import { HUB_EVENTS } from '@devframes/hub/constants' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import createA11yDevframe, { a11yPageScriptBundlePath } from '@devframes/plugin-a11y' @@ -158,10 +156,6 @@ export default defineConfig({ // into this host's own bundle. renderers: [jsonRenderUiRenderer()], configure: async (context) => { - context.docks.events.on(HUB_EVENTS.bus.docksPanelState, (event) => { - console.info(styleText('cyan', '🔄 [hub-vite]'), 'Dock panel state updated', event) - }) - // Seed a sample command directly on the hub so the UI shows something // even without any plugged-in devframes. context.commands.register({ From 390bae404ffe2c9e9d2dc54a91e9c94d10e6eff1 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 13:56:39 +0200 Subject: [PATCH 8/8] fix(hub): normalize dock panel selection --- packages/hub/src/node/__tests__/host-docks.test.ts | 4 +++- packages/hub/src/node/panel-state.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index 197a2447..b2c367bc 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -231,15 +231,17 @@ describe('devframeDockHost panel state', () => { const events: DevframeDockPanelStateEvent[] = [] host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event)) - updateDockPanelState(host, 11, { state: 'closed' }) + updateDockPanelState(host, 11, { state: 'closed', selectedDockId: undefined }) updateDockPanelState(host, 11, { state: 'closed' }) updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'git' }) updateDockPanelState(host, 11, { state: 'open', selectedDockId: 'inspect' }) + updateDockPanelState(host, 11, { state: 'open', selectedDockId: undefined }) expect(events).toEqual([ { type: 'connected', sessionId: 11, state: 'closed' }, { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'git' }, { type: 'changed', sessionId: 11, state: 'open', selectedDockId: 'inspect' }, + { type: 'changed', sessionId: 11, state: 'open' }, ]) }) diff --git a/packages/hub/src/node/panel-state.ts b/packages/hub/src/node/panel-state.ts index 1d82a691..9bcef2d1 100644 --- a/packages/hub/src/node/panel-state.ts +++ b/packages/hub/src/node/panel-state.ts @@ -8,6 +8,10 @@ export function updateDockPanelState( sessionId: number, panelState: DevframeDockPanelState, ): void { + const currentState: DevframeDockPanelState = typeof panelState.selectedDockId === 'string' + ? { state: panelState.state, selectedDockId: panelState.selectedDockId } + : { state: panelState.state } + let sessionStates = dockPanelStates.get(docks) if (!sessionStates) { sessionStates = new Map() @@ -16,13 +20,12 @@ export function updateDockPanelState( const previousState = sessionStates.get(sessionId) if ( - previousState?.state === panelState.state - && previousState.selectedDockId === panelState.selectedDockId + previousState?.state === currentState.state + && previousState.selectedDockId === currentState.selectedDockId ) { return } - const currentState: DevframeDockPanelState = { ...panelState } sessionStates.set(sessionId, currentState) const event: DevframeDockPanelStateEvent = previousState === undefined ? { type: 'connected', sessionId, ...currentState }