Skip to content
Open
23 changes: 23 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,29 @@ 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`](/references/events#client-context-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) {
const reportPanelState = (panelState: typeof context.panel.state) => {
void context.rpc.call('my-devframe:panel-state', panelState).catch(error => console.error(error))
}

reportPanelState(context.panel.state)
context.panel.events.on(
HUB_EVENTS.client.docksPanelStateChanged,
reportPanelState,
)
}
```

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
2 changes: 1 addition & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ The properties of `DevframeClientContext` — [The client context](/guide/client
| `rpc` | The [RPC client](/guide/client) — server/client functions, shared state. |
| `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). |
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). |
| `panel` | Dock panel state: position, size, drag/resize. |
| `panel` | Current `state`, local `events`, session, position, size, and drag/resize state for the dock panel. |
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. |
| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer (local boot or the hub's [manifest](/guide/hub-initiate#renderer-modules); local wins). `mount()` resolves a `status`: `mounted` (with `dispose`), `missing-renderer`, or `load-error` (with `error`). |
| `when` | The [when-clause](/references/when-clauses) context. |
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
68 changes: 66 additions & 2 deletions 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 @@ -74,12 +75,74 @@ async function flushRestore(): Promise<void> {
}

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

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),
)

panelVisible.value = true
await nextTick()
expect(panelStates).toEqual([{ state: 'open', selectedDockId: 'git' }])
panelVisible.value = false
await nextTick()
panelStates.length = 0

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)
expect.assertions(8)

const { rpc, sharedStates, trust } = createStubRpc()
const executeSetupScriptMock = vi.mocked(executeSetupScript)
executeSetupScriptMock.mockClear()
let setupPanelState: DevframeDockPanelState | undefined
executeSetupScriptMock.mockImplementationOnce(async (_dockEntry, scriptContext) => {
setupPanelState = scriptContext.panel.state
})
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
Expand All @@ -105,6 +168,7 @@ describe('createDocksContext', () => {

expect(context.docks.selected?.id).toBe('git')
expect(session.value.open).toBe(true)
expect(setupPanelState).toEqual({ state: 'open', selectedDockId: 'git' })
expect(executeSetupScriptMock).toHaveBeenCalledOnce()
})

Expand Down
46 changes: 41 additions & 5 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 @@ -269,6 +272,10 @@ export async function createDocksContext(
return false
}

initialRestorePending.value = false
selectedDockId.value = entry.id
sessionStore.value.open = true

// If has import script, run it
if (
(entry.type === 'action')
Expand Down Expand Up @@ -299,9 +306,6 @@ export async function createDocksContext(
if (entry.groupId)
(sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id

initialRestorePending.value = false
selectedDockId.value = entry.id
sessionStore.value.open = true
// Only an iframe dock owns an address-bar route; ViewIframe keeps
// `session.selectedDockRoute` current for it. Clear it for anything else so a stale
// route from a previous iframe isn't persisted against a non-iframe dock.
Expand Down Expand Up @@ -577,6 +581,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,6 +688,23 @@ export async function createDocksContext(
initialRestorePending.value = false
await switchEntry(restoreDockId)
}
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()

docksContextByRpc.set(rpc, 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
Loading
Loading