Skip to content
Open
19 changes: 19 additions & 0 deletions docs/content/1.guide/17.client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ The context carries the [RPC client](/guide/client) (`rpc`) and the page's `clie

`getDevframeClientContext()` returns the context anywhere; `undefined` before boot.

### Tracking panel state

`ctx.panel.state` is the current dock-panel snapshot. It contains `state: 'open' | 'closed' | 'hidden'` and includes `selectedDockId` while a dock is selected. Subscribe to `ctx.panel.events` for later changes:

```ts
import type { DockClientScriptContext } from '@devframes/hub/client'
import { HUB_EVENTS } from '@devframes/hub/constants'

export default function setup(context: DockClientScriptContext) {
void context.rpc.call('my-devframe:panel-state', context.panel.state)
context.panel.events.on(
HUB_EVENTS.client.docksPanelStateChanged,
panelState => void context.rpc.call('my-devframe:panel-state', panelState),
)
}
```

The custom RPC keeps node-side reporting opt-in.

### Client-only docks

A client runtime can register a dock local to the host page (unlike [node hub context](/guide/hub) docks synced via `devframe:docks`). `ctx.docks.register(entry)` — e.g. `type: 'custom-render'` with `renderer: { importFrom }` — returns a handle whose `update({ badge })` patches in place (id immutable) and `dispose()` removes it. One sharing a server dock's id overrides it locally; re-registering an owned id throws unless you pass `register(entry, true)`.
Expand Down
14 changes: 12 additions & 2 deletions docs/content/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
title: 'Events Reference'
navigation:
icon: i-lucide-radio-tower
description: 'Devframe carries change notifications across channels of differing direction and reach: a node event bus, server RPC, and server-pushed broadcasts and shared state.'
description: 'Devframe carries change notifications through client contexts, node event buses, RPC, broadcasts, and shared state.'
---

Devframe carries change notifications across channels of differing **direction and reach**: a node event bus, server RPC, and server-pushed broadcasts and shared state.
Devframe carries change notifications through client contexts, node event buses, RPC, broadcasts, and shared state.

Two prefixes mark the wire protocol: `hub:` for hub-layer server RPC (client → server), `devframe:` for the client-facing protocol (server → client). The internal event bus mirrors the subsystem vocabulary (`docks`, `terminals`, `messages`, `commands`) — `docks:activate` fans out to `devframe:docks:activate`.

Each name lives in code: [`HUB_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/hub/src/events.ts) (`@devframes/hub/constants`) backs the hub tables, [`DEVFRAME_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/events.ts) (`devframe/constants`) the core ones.

## Hub events

### Client-context events

Client scripts subscribe to these events on the client context inside the host page.

| Event | Emitter | Payload |
|---|---|---|
| `panel:state:changed` | `ctx.panel.events` | `DevframeDockPanelState` |

`ctx.panel.state` supplies the current snapshot when a client script loads. Later open, close, dock selection, and hub UI provider visibility changes emit `panel:state:changed`. The snapshot contains `state: 'open' | 'closed' | 'hidden'` and an optional `selectedDockId`.

### Internal node event bus

Each subsystem emits on `ctx.<subsystem>.events`, consumed **inside the same node process** by `createHubContext`, which fans them onto the wire.
Expand Down
15 changes: 11 additions & 4 deletions packages/hub-ui/src/client/embedded/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
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'
import { isEmbeddedDockInitiallyVisible, setupEmbeddedVisibility } from './visibility'

/**
* The floating-dock bootstrap the hub serves at `<base>embedded.js` — load
Expand Down Expand Up @@ -74,8 +75,10 @@ async function mountDock(): Promise<void> {
// carried by the connection we just established above.
const branding = setBranding(rpc.connectionMeta.configs?.ui?.branding || {})

const embeddedVisibility = rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal'
const panelVisible = ref(isEmbeddedDockInitiallyVisible(embeddedVisibility))
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')
Expand All @@ -91,14 +94,18 @@ async function mountDock(): Promise<void> {
// Reveal policy: `normal` appends now; `passive`/`hidden` wait for the
// Shift+Alt+D reveal (the element is built and ready, just detached).
setupEmbeddedVisibility(
rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal',
embeddedVisibility,
branding.productName,
{
show: () => {
if (dockEl && !dockEl.isConnected)
document.body.appendChild(dockEl)
panelVisible.value = true
},
hide: () => {
dockEl?.remove()
panelVisible.value = false
},
hide: () => dockEl?.remove(),
},
)
}
Expand Down
6 changes: 5 additions & 1 deletion packages/hub-ui/src/client/embedded/visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export interface EmbeddedVisibilityHandlers {
hide: () => void
}

export function isEmbeddedDockInitiallyVisible(mode: EmbeddedVisibility): boolean {
return mode === 'normal' || (mode === 'passive' && readPersistedReveal())
}

/**
* Drive the embedded dock's reveal lifecycle for the resolved
* {@link EmbeddedVisibility} mode: decide whether to show on boot, wire the
Expand All @@ -73,7 +77,7 @@ export function setupEmbeddedVisibility(
label: string,
handlers: EmbeddedVisibilityHandlers,
): void {
let shown = mode === 'normal' || (mode === 'passive' && readPersistedReveal())
let shown = isEmbeddedDockInitiallyVisible(mode)

function reveal(): void {
if (shown)
Expand Down
56 changes: 55 additions & 1 deletion packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DevframeDockEntry, DevframeDockPanelState } 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'
Expand Down Expand Up @@ -71,9 +72,62 @@ async function flushRestore(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
await nextTick()
await Promise.resolve()
await Promise.resolve()
}

describe('createDocksContext', () => {
it('exposes restored panel state and emits selected, hidden, and closed changes', async () => {
expect.assertions(8)

const { rpc, sharedStates, trust } = createStubRpc()
const panelVisible = ref(false)
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
selectedDockRoute: null,
})
const context = await createDocksContext('embedded', rpc, undefined, session, panelVisible)
const panelStates: DevframeDockPanelState[] = []
context.panel.events.on(
HUB_EVENTS.client.docksPanelStateChanged,
panelState => panelStates.push(panelState),
)

trust()
sharedStates.get('devframe:docks')!.push([gitEntry])
sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()
expect(context.panel.state).toEqual({ state: 'hidden', selectedDockId: 'git' })
expect(panelStates).toEqual([])

panelVisible.value = true
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'git' })

session.value.selectedDockId = '~settings'
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: '~settings' })

panelVisible.value = false
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'hidden', selectedDockId: '~settings' })

session.value.open = false
session.value.selectedDockId = null
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'hidden' })

panelVisible.value = true
await nextTick()
expect(panelStates.at(-1)).toEqual({ state: 'closed' })

panelVisible.value = true
session.value.open = false
await nextTick()
expect(panelStates).toHaveLength(5)
})

it('mounts a restored dock once after all initial server state arrives', async () => {
expect.assertions(7)

Expand Down
43 changes: 40 additions & 3 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage } from '@devframes/hub/client'
import type { DevframeClientCommand, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub'
import type { CommandsContext, DevframeClientContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext, DockSessionStorage, DocksPanelEvents } from '@devframes/hub/client'
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 { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants'
import { DEVFRAME_EVENTS } from 'devframe/constants'
import { createEventEmitter } from 'devframe/utils/events'
import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants'
import { useBranding } from './branding'
Expand All @@ -23,6 +24,7 @@ export async function createDocksContext(
rpc: DevframeRpcClient,
panelStore?: Ref<DockPanelStorage>,
sessionStore?: Ref<DockSessionStorage>,
panelVisible: Ref<boolean> = ref(true),
): Promise<DocksContext> {
if (docksContextByRpc.has(rpc)) {
return docksContextByRpc.get(rpc)!
Expand Down Expand Up @@ -172,6 +174,7 @@ export async function createDocksContext(
}

panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE())
const panelEvents = createEventEmitter<DocksPanelEvents>()
let docksContext: DocksContext

let _settingsStorePromise: Promise<SharedState<DevframeDocksUserSettings>> | undefined
Expand Down Expand Up @@ -577,6 +580,21 @@ export async function createDocksContext(

docksContext = reactive({
panel: {
get state() {
let state: DevframeDockPanelState['state']
if (!panelVisible.value)
state = 'hidden'
else if (sessionStore.value.open)
state = 'open'
else
state = 'closed'

const panelState: DevframeDockPanelState = { state }
if (selectedDockId.value !== null)
panelState.selectedDockId = selectedDockId.value
return panelState
},
events: markRaw(panelEvents),
store: panelStore,
session: sessionStore,
isDragging: false,
Expand Down Expand Up @@ -669,7 +687,26 @@ export async function createDocksContext(
initialRestorePending.value = false
await switchEntry(restoreDockId)
}
void restoreAfterInitialization()
const startPanelStateEvents = (): void => {
let previousPanelState = docksContext.panel.state
watch(
[panelVisible, () => sessionStore.value.open, selectedDockId],
() => {
const panelState = docksContext.panel.state
if (
panelState.state === previousPanelState.state
&& panelState.selectedDockId === previousPanelState.selectedDockId
) {
return
}

previousPanelState = panelState
panelEvents.emit(HUB_EVENTS.client.docksPanelStateChanged, panelState)
},
{ flush: 'post' },
)
}
void restoreAfterInitialization().then(startPanelStateEvents, startPanelStateEvents)

docksContextByRpc.set(rpc, docksContext)
return docksContext
Expand Down
38 changes: 37 additions & 1 deletion packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { DevframeRpcClient } from 'devframe/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { DevframeDockEntry } from '../../types/docks'
import type { DevframeDockEntry, DevframeDockPanelState } 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'

Expand Down Expand Up @@ -67,6 +68,41 @@ function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEn
}

describe('createDevframeClientRuntime', () => {
it('exposes panel state and emits coalesced changes', async () => {
expect.assertions(6)

const { rpc, states } = createStubRpc()
const host = await createDevframeClientRuntime({ rpc, clientType: 'embedded' })
const panelStates: DevframeDockPanelState[] = []

expect(host.context.panel.state).toEqual({ state: 'closed' })
host.context.panel.events.on(
HUB_EVENTS.client.docksPanelStateChanged,
panelState => panelStates.push(panelState),
)

states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')])
host.context.panel.session.open = true
const switched = host.context.docks.switchEntry('one')
await switched
expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'one' }])
expect(host.context.panel.state).toEqual({ state: 'open', selectedDockId: 'one' })

host.context.panel.session.open = true
host.context.panel.session.selectedDockId = 'one'
await Promise.resolve()
expect(panelStates).toHaveLength(1)

await host.context.docks.switchEntry('two')
expect(panelStates.at(-1)).toEqual({ state: 'open', selectedDockId: 'two' })

host.context.panel.session.open = false
const cleared = host.context.docks.switchEntry(null)
await cleared
expect(panelStates.at(-1)).toEqual({ state: 'closed' })
host.dispose()
})

it('publishes the global client context with the full surface', async () => {
const { rpc } = createStubRpc()
const host = await createDevframeClientRuntime({ rpc })
Expand Down
10 changes: 9 additions & 1 deletion packages/hub/src/client/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { EventEmitter } from 'devframe/types'
import type { SharedState } from 'devframe/utils/shared-state'
import type { WhenContext } from 'devframe/utils/when'
import type { DevframeClientCommand, DevframeCommandEntry, DevframeCommandKeybinding } from '../types/commands'
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockUserEntry } from '../types/docks'
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockPanelState, DevframeDockUserEntry } from '../types/docks'
import type { DevframeDocksUserSettings } from '../types/settings'
import type { DockRenderersContext } from './renderers'

Expand Down Expand Up @@ -125,7 +125,15 @@ export interface WhenClauseContext {

export type DevframeClientContext = DocksContext

export interface DocksPanelEvents {
'panel:state:changed': (state: DevframeDockPanelState) => void
}

export interface DocksPanelContext {
/** The current panel state snapshot. */
readonly state: DevframeDockPanelState
/** Subscribe to panel state changes after the current snapshot. */
readonly events: EventEmitter<DocksPanelEvents>
store: DockPanelStorage
/**
* Per-tab session UI state — whether the panel is open, which dock is
Expand Down
Loading
Loading