Skip to content

Commit 8af8bfe

Browse files
committed
feat(hub): cross-iframe dock activation with terminal session focus
Let any connected client — including a mounted devframe running in its own iframe on its own RPC client — steer the host shell's active dock, which is otherwise client-local and unreachable from a mounted iframe. The terminals dock reads the activation params to focus a specific session, so a tool can spawn a build and navigate the user straight to its terminal output. Co-authored-by: opencode agent
1 parent 7d31965 commit 8af8bfe

21 files changed

Lines changed: 414 additions & 9 deletions

File tree

docs/errors/DF8107.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8107: Unknown Dock Activation Target
6+
7+
## Message
8+
9+
> Dock activation requested for unknown dock id "`{id}`"
10+
11+
## Cause
12+
13+
`ctx.docks.activate(dockId)` — reached via the `hub:docks:activate` RPC, which lets any connected client (e.g. a mounted devframe in its own iframe) steer the host shell's active dock — was called with a `dockId` that no registered dock entry owns.
14+
15+
This is a warning, not a thrown error: the activation is still broadcast so a dock that registers momentarily later can pick it up, and both the client host and each dock ignore ids they don't recognize. A mis-addressed activation is inert rather than fatal — the warning exists so a typo doesn't fail silently.
16+
17+
## Fix
18+
19+
Pass a `dockId` that matches a registered dock entry. Ids are case-sensitive, so check for typos, and make sure the target dock is registered before activating it. For the terminals dock the id is `devframes-plugin-terminals`:
20+
21+
```ts
22+
await rpc.call('hub:docks:activate', {
23+
dockId: 'devframes-plugin-terminals',
24+
params: { sessionId },
25+
})
26+
```
27+
28+
## Source
29+
30+
- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts)`DevframeDocksHost.activate()` reports this when the requested dock id isn't in `views`.

docs/guide/hub.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi
1212

1313
| Subsystem | Surface | Purpose |
1414
|---|---|---|
15-
| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. |
15+
| `ctx.docks` | `register / update / values / activate` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. `activate(dockId, params?)` steers which dock the viewer shows — see [Cross-iframe dock activation](#cross-iframe-dock-activation). |
1616
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. |
1717
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
1818
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
@@ -21,12 +21,31 @@ Plus a `createJsonRenderer(spec)` factory for building remote-UI panels via the
2121

2222
## Built-in RPC
2323

24-
Every hub context auto-registers this RPC function so framework kits don't reimplement it:
24+
Every hub context auto-registers these RPC functions so framework kits don't reimplement them:
2525

2626
- `hub:commands:execute` — invoke a registered server command by id. `await rpc.call('hub:commands:execute', 'my-tool:do-thing', ...args)`.
27+
- `hub:docks:activate` — switch the viewer's active dock. `await rpc.call('hub:docks:activate', { dockId, params })` — see [Cross-iframe dock activation](#cross-iframe-dock-activation).
2728

2829
Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface.
2930

31+
## Cross-iframe dock activation
32+
33+
The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell.
34+
35+
```ts
36+
// From inside a mounted devframe's iframe (its own RPC client):
37+
await rpc.call('hub:docks:activate', {
38+
dockId: 'devframes-plugin-terminals',
39+
params: { sessionId }, // opaque bag the target dock interprets
40+
})
41+
```
42+
43+
The hub broadcasts the request live over `devframe:docks:activate` (the client host calls its local `switchEntry(dockId)`) and mirrors it into the `devframe:docks:active` shared-state slot, so a dock that mounts *because* of the switch still converges on the request instead of missing the broadcast. `params` is an opaque, serializable bag the target dock reads — the [terminals dock](/plugins/terminals#focusing-a-session) reads `params.sessionId` to focus a specific session. Unknown dock ids degrade to a no-op (warned server-side as [DF8107](/errors/DF8107)); the target dock ignores `params` it doesn't recognize.
44+
45+
This is what lets Vite DevTools' Rolldown analyzer spawn a `vite build` via `ctx.terminals.startChildProcess` and then navigate the user straight to that build's terminal session.
46+
47+
Server-side, the same switch is available as `ctx.docks.activate(dockId, params?)`.
48+
3049
## Mounting a devframe into a hub
3150

3251
`mountDevframe(ctx, def)` is the framework-neutral primitive that registers any `DevframeDefinition` as a dock and runs its `setup(ctx)`:
@@ -136,9 +155,11 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys
136155
| `devframe:docks` shared state | `DevframeDockEntry[]` | Every dock entry the mounted integrations registered. |
137156
| `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). |
138157
| `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. |
158+
| `devframe:docks:active` shared state | `DevframeDocksActiveState` | The most recent [dock activation](#cross-iframe-dock-activation) request, so a dock that mounts in response converges on it. |
139159
| `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. |
160+
| `hub:docks:activate` RPC | `({ dockId, params? }) => void` | Switch the active dock from any client. |
140161

141-
Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`.
162+
Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. The client host registers the `devframe:docks:activate` handler for you.
142163

143164
## Running plugin code in the host page
144165

docs/plugins/terminals.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ Mounted into a hub, the plugin owns PTY/child-process spawning and its own strea
5858

5959
A session from `ctx.terminals.startChildProcess()` carries a `getResult()` accessor shaped like `tinyexec`'s `Result``await`able to `{ stdout, stderr, exitCode }` (captured separately from the merged display stream), with live `pid` / `exitCode` / `killed` getters and `kill()` in the meantime. That's the seam for migrating an existing `tinyexec`/`execa`-based "run a subprocess and get its result" API onto the hub's terminals: keep the same calling code, swap the runner for `startChildProcess()`, and the session's output shows up in every hub-aware terminal panel for free.
6060

61+
## Focusing a session
62+
63+
The panel reacts to the hub's [cross-iframe dock activation](/guide/hub#cross-iframe-dock-activation): when an activation targets this dock (`dockId: 'devframes-plugin-terminals'`) and carries a `sessionId`, the panel selects that session. This lets another tool spawn a build and jump the user straight to its output:
64+
65+
```ts
66+
// e.g. right after ctx.terminals.startChildProcess(..., { id: sessionId, ... })
67+
await rpc.call('hub:docks:activate', {
68+
dockId: 'devframes-plugin-terminals',
69+
params: { sessionId },
70+
})
71+
```
72+
73+
It works whether the panel is already open (it reacts to the `devframe:docks:active` shared-state slot) or mounts in response to the switch (it reads the slot on start and converges). Focus is one-shot: an unknown or not-yet-arrived session id waits for that session to appear, and the user's own tab clicks are always honored afterward. A session id that never appears is a no-op — the default selection (most-recent session) stands.
74+
6175
## Source
6276

6377
[`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals)

packages/hub/src/client/__tests__/host.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function createStubSharedState<T>(initial: T): StubSharedState<T> {
3333
function createStubRpc() {
3434
const calls: any[][] = []
3535
const states = new Map<string, StubSharedState<any>>()
36+
const definitions = new Map<string, { name: string, type: string, handler?: (...args: any[]) => any }>()
3637
const rpc = {
3738
sharedState: {
3839
async get(key: string, options?: { initialValue?: any }) {
@@ -47,8 +48,14 @@ function createStubRpc() {
4748
return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] }
4849
return `rpc:${args[0]}`
4950
},
51+
client: {
52+
definitions,
53+
register(fn: { name: string, type: string, handler?: (...args: any[]) => any }) {
54+
definitions.set(fn.name, fn)
55+
},
56+
},
5057
} as unknown as DevframeRpcClient
51-
return { rpc, calls, states }
58+
return { rpc, calls, states, definitions }
5259
}
5360

5461
function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockEntry {
@@ -115,6 +122,23 @@ describe('createDevframeClientHost', () => {
115122
host.dispose()
116123
})
117124

125+
it('switches the active dock when the hub broadcasts devframe:docks:activate', async () => {
126+
const { rpc, states, definitions } = createStubRpc()
127+
const host = await createDevframeClientHost({ rpc })
128+
states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('devframes-plugin-terminals')])
129+
130+
// Simulate the hub's server→client broadcast.
131+
const handler = definitions.get('devframe:docks:activate')!.handler!
132+
handler({ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } })
133+
await vi.waitFor(() => expect(host.context.docks.selectedId).toBe('devframes-plugin-terminals'))
134+
135+
// Unknown dock ids degrade to a no-op (the previous selection stands).
136+
handler({ dockId: 'ghost' })
137+
await new Promise(r => setTimeout(r, 0))
138+
expect(host.context.docks.selectedId).toBe('devframes-plugin-terminals')
139+
host.dispose()
140+
})
141+
118142
it('executes client commands locally and server commands over hub:commands:execute', async () => {
119143
const { rpc, calls } = createStubRpc()
120144
const host = await createDevframeClientHost({ rpc })

packages/hub/src/client/host.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { createMessagesClient } from './messages'
3131
const DOCKS_STATE_KEY = 'devframe:docks'
3232
const COMMANDS_STATE_KEY = 'devframe:commands'
3333
const USER_SETTINGS_STATE_KEY = 'devframe:user-settings'
34+
const DOCKS_ACTIVATE_EVENT = 'devframe:docks:activate'
3435

3536
export interface DevframeClientHostOptions {
3637
/**
@@ -124,6 +125,32 @@ export async function createDevframeClientHost(
124125
reconcileEntries()
125126
disposers.push(docksState.on('updated', reconcileEntries))
126127

128+
// Honor cross-iframe dock activation: any client (e.g. a mounted devframe in
129+
// its own iframe) can ask the hub to switch this shell's active dock via the
130+
// `hub:docks:activate` RPC, which the hub broadcasts here. `switchEntry`
131+
// ignores ids it doesn't recognize, so an unknown target degrades to a no-op.
132+
// Another consumer sharing this rpc client may have registered the handler
133+
// already — chain onto it rather than replacing it.
134+
const activateHandler = (activation: { dockId?: string } | undefined): void => {
135+
if (activation?.dockId)
136+
void switchEntry(activation.dockId)
137+
}
138+
const existingActivate = rpc.client.definitions.get(DOCKS_ACTIVATE_EVENT)
139+
if (existingActivate) {
140+
const prev = existingActivate.handler
141+
existingActivate.handler = (...args: unknown[]) => {
142+
activateHandler(args[0] as { dockId?: string })
143+
return prev?.(...args)
144+
}
145+
}
146+
else {
147+
rpc.client.register({
148+
name: DOCKS_ACTIVATE_EVENT,
149+
type: 'action',
150+
handler: (activation: { dockId?: string }) => activateHandler(activation),
151+
})
152+
}
153+
127154
if (getDevframeClientContext()) {
128155
console.warn(
129156
'[@devframes/hub] A client host context is already published on this page — replacing it. '

packages/hub/src/node/__tests__/context.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { createHostContext, startHttpAndWs } from 'devframe/node'
66
import { getInternalContext } from 'devframe/node/hub-internals'
7-
import { describe, expect, it } from 'vitest'
7+
import { describe, expect, it, vi } from 'vitest'
88
import { createHubContext } from '../context'
99

1010
function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) {
@@ -28,6 +28,37 @@ describe('createHubContext shared state', () => {
2828
})
2929
})
3030

31+
describe('createHubContext dock activation', () => {
32+
it('mirrors an activation into shared state and broadcasts it live', async () => {
33+
const context = await createHubContext({
34+
cwd: process.cwd(),
35+
mode: 'build',
36+
host: createHost(),
37+
})
38+
context.docks.register({
39+
type: 'iframe',
40+
id: 'devframes-plugin-terminals',
41+
title: 'Terminals',
42+
icon: 'ph:terminal-window-duotone',
43+
url: '/__devframes-plugin-terminals/',
44+
})
45+
46+
const broadcast = vi.spyOn(context.rpc, 'broadcast').mockResolvedValue()
47+
context.docks.activate('devframes-plugin-terminals', { sessionId: 'sess-1' })
48+
49+
const active = await context.rpc.sharedState.get<{ activation: unknown }>('devframe:docks:active')
50+
expect(active.value().activation).toEqual({
51+
dockId: 'devframes-plugin-terminals',
52+
params: { sessionId: 'sess-1' },
53+
})
54+
expect(broadcast).toHaveBeenCalledWith({
55+
method: 'devframe:docks:activate',
56+
args: [{ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } }],
57+
})
58+
broadcast.mockRestore()
59+
})
60+
})
61+
3162
describe('startHttpAndWs remote endpoint metadata', () => {
3263
it('sets and clears the internal websocket endpoint', async () => {
3364
const context = await createHostContext({

packages/hub/src/node/__tests__/host-docks.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { REMOTE_CONNECTION_KEY } from 'devframe/constants'
66
import { getInternalContext } from 'devframe/node/hub-internals'
7-
import { describe, expect, it } from 'vitest'
7+
import { describe, expect, it, vi } from 'vitest'
88
import { parseRemoteConnection } from '../../client/remote'
99
import { DevframeDocksHost } from '../host-docks'
1010

@@ -190,6 +190,36 @@ describe('devframeDockHost grouping', () => {
190190
})
191191
})
192192

193+
describe('devframeDockHost activate', () => {
194+
it('emits a dock:activate event carrying the id and params', () => {
195+
const host = new DevframeDocksHost(createContext())
196+
host.register({ type: 'iframe', id: 'terminals', title: 'Terminals', icon: 'ph:terminal-window-duotone', url: '/__terminals/' })
197+
198+
const activations: Array<{ dockId: string, params?: Record<string, unknown> }> = []
199+
host.events.on('dock:activate', a => activations.push(a))
200+
201+
host.activate('terminals', { sessionId: 'sess-1' })
202+
expect(activations).toEqual([{ dockId: 'terminals', params: { sessionId: 'sess-1' } }])
203+
})
204+
205+
it('still emits for an unknown dock but warns (DF8107, graceful)', () => {
206+
const host = new DevframeDocksHost(createContext())
207+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
208+
try {
209+
const activations: string[] = []
210+
host.events.on('dock:activate', a => activations.push(a.dockId))
211+
212+
host.activate('nope')
213+
expect(activations).toEqual(['nope'])
214+
expect(warn).toHaveBeenCalledOnce()
215+
expect(warn.mock.calls[0]!.join(' ')).toMatch(/unknown dock id/i)
216+
}
217+
finally {
218+
warn.mockRestore()
219+
}
220+
})
221+
})
222+
193223
describe('devframeDockHost ~builtin category', () => {
194224
it('returns no docks until an integration registers one', () => {
195225
const host = new DevframeDocksHost(createContext())

packages/hub/src/node/__tests__/rpc-builtins.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DevframeHubContext } from '../context'
22
import { describe, expect, it, vi } from 'vitest'
3-
import { hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins'
3+
import { hubDocksActivate, hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins'
44

55
function contextWithSessions(sessions: Map<string, any>): DevframeHubContext {
66
return { terminals: { sessions } } as unknown as DevframeHubContext
@@ -37,3 +37,23 @@ describe('hub terminal write/resize RPC', () => {
3737
await expect(writeFn.handler!('nope', 'x')).rejects.toThrow(/not registered/i)
3838
})
3939
})
40+
41+
describe('hub docks activate RPC', () => {
42+
it('forwards dockId and params to docks.activate', async () => {
43+
const activate = vi.fn()
44+
const ctx = { docks: { activate } } as unknown as DevframeHubContext
45+
46+
const fn = await hubDocksActivate.setup!(ctx)
47+
await fn.handler!({ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } })
48+
expect(activate).toHaveBeenCalledWith('devframes-plugin-terminals', { sessionId: 'sess-1' })
49+
})
50+
51+
it('forwards a bare dockId without params', async () => {
52+
const activate = vi.fn()
53+
const ctx = { docks: { activate } } as unknown as DevframeHubContext
54+
55+
const fn = await hubDocksActivate.setup!(ctx)
56+
await fn.handler!({ dockId: 'devframes-plugin-messages' })
57+
expect(activate).toHaveBeenCalledWith('devframes-plugin-messages', undefined)
58+
})
59+
})

packages/hub/src/node/context.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CreateHostContextOptions } from 'devframe/node'
22
import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
33
import type { DevframeCommandsHost } from '../types/commands'
4-
import type { DevframeDocksHost } from '../types/docks'
4+
import type { DevframeDockActivation, DevframeDocksActiveState, DevframeDocksHost } from '../types/docks'
55
import type { JsonRenderer, JsonRenderSpec } from '../types/json-render'
66
import type { DevframeMessagesHost } from '../types/messages'
77
import type { DevframeTerminalsHost } from '../types/terminals'
@@ -15,6 +15,17 @@ import { builtinHubRpcDeclarations } from './rpc-builtins'
1515

1616
declare module 'devframe/types' {
1717
interface DevframeRpcClientFunctions {
18+
/**
19+
* Server→client request to switch the active dock. Broadcast by the hub
20+
* context in response to `ctx.docks.activate()` (driven by the
21+
* `hub:docks:activate` RPC). The client host registers a handler that
22+
* calls its local `switchEntry(dockId)`; the target dock reads
23+
* `activation.params` to react (e.g. focus a session). Do not register
24+
* manually.
25+
*
26+
* @internal
27+
*/
28+
'devframe:docks:activate': (activation: DevframeDockActivation) => Promise<void>
1829
/**
1930
* Server→client notification that terminal sessions changed. Broadcast
2031
* by the hub context; a hub-aware client re-reads terminal state in
@@ -123,6 +134,25 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
123134
docks.events.on('dock:entry:updated', refreshDocks)
124135
docksSharedState.mutate(() => docks.values())
125136

137+
// Cross-iframe dock activation. A dock activation is a discrete user intent
138+
// ("go to Terminals now"), so it fires immediately (no debounce, which could
139+
// coalesce two distinct requests) both as a live broadcast — the host shell
140+
// switches its active dock — and into a shared-state slot, so a dock that
141+
// only mounts *because* of the switch still converges on the request.
142+
const activeDockSharedState = await context.rpc.sharedState.get<DevframeDocksActiveState>(
143+
'devframe:docks:active',
144+
{ initialValue: { activation: null } },
145+
)
146+
docks.events.on('dock:activate', (activation) => {
147+
activeDockSharedState.mutate((state) => {
148+
state.activation = activation
149+
})
150+
context.rpc.broadcast({
151+
method: 'devframe:docks:activate',
152+
args: [activation],
153+
})
154+
})
155+
126156
const broadcastTerminals = debounce(() => {
127157
context.rpc.broadcast({
128158
method: 'devframe:terminals:updated',

0 commit comments

Comments
 (0)