From 8af8bfed5d7ad4ff8e57a5fa5b2f0d74642c0ff4 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 16 Jul 2026 07:27:38 +0000 Subject: [PATCH] feat(hub): cross-iframe dock activation with terminal session focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/errors/DF8107.md | 30 ++++++++++ docs/guide/hub.md | 27 ++++++++- docs/plugins/terminals.md | 14 +++++ .../hub/src/client/__tests__/host.test.ts | 26 ++++++++- packages/hub/src/client/host.ts | 27 +++++++++ .../hub/src/node/__tests__/context.test.ts | 33 ++++++++++- .../hub/src/node/__tests__/host-docks.test.ts | 32 ++++++++++- .../src/node/__tests__/rpc-builtins.test.ts | 22 ++++++- packages/hub/src/node/context.ts | 32 ++++++++++- packages/hub/src/node/diagnostics.ts | 4 ++ packages/hub/src/node/host-docks.ts | 10 ++++ packages/hub/src/node/rpc-builtins.ts | 23 ++++++++ packages/hub/src/types/docks.ts | 36 ++++++++++++ plugins/terminals/src/client/App.svelte | 57 ++++++++++++++++++- plugins/terminals/src/constants.ts | 11 ++++ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 + .../tsnapi/@devframes/hub/node.snapshot.d.ts | 31 ++++++++++ .../tsnapi/@devframes/hub/node.snapshot.js | 2 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 2 + .../plugin-terminals/constants.snapshot.d.ts | 1 + .../plugin-terminals/constants.snapshot.js | 1 + 21 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 docs/errors/DF8107.md diff --git a/docs/errors/DF8107.md b/docs/errors/DF8107.md new file mode 100644 index 00000000..5357639d --- /dev/null +++ b/docs/errors/DF8107.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# DF8107: Unknown Dock Activation Target + +## Message + +> Dock activation requested for unknown dock id "`{id}`" + +## Cause + +`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. + +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. + +## Fix + +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`: + +```ts +await rpc.call('hub:docks:activate', { + dockId: 'devframes-plugin-terminals', + params: { sessionId }, +}) +``` + +## Source + +- [`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`. diff --git a/docs/guide/hub.md b/docs/guide/hub.md index a83e44bc..a09130e3 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -12,7 +12,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi | Subsystem | Surface | Purpose | |---|---|---| -| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. | +| `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). | | `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. | | `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). | | `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 ## Built-in RPC -Every hub context auto-registers this RPC function so framework kits don't reimplement it: +Every hub context auto-registers these RPC functions so framework kits don't reimplement them: - `hub:commands:execute` — invoke a registered server command by id. `await rpc.call('hub:commands:execute', 'my-tool:do-thing', ...args)`. +- `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). Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface. +## Cross-iframe dock activation + +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. + +```ts +// From inside a mounted devframe's iframe (its own RPC client): +await rpc.call('hub:docks:activate', { + dockId: 'devframes-plugin-terminals', + params: { sessionId }, // opaque bag the target dock interprets +}) +``` + +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. + +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. + +Server-side, the same switch is available as `ctx.docks.activate(dockId, params?)`. + ## Mounting a devframe into a hub `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 | `devframe:docks` shared state | `DevframeDockEntry[]` | Every dock entry the mounted integrations registered. | | `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). | | `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. | +| `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. | | `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. | +| `hub:docks:activate` RPC | `({ dockId, params? }) => void` | Switch the active dock from any client. | -Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. +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. ## Running plugin code in the host page diff --git a/docs/plugins/terminals.md b/docs/plugins/terminals.md index f91e0130..2a5b291b 100644 --- a/docs/plugins/terminals.md +++ b/docs/plugins/terminals.md @@ -58,6 +58,20 @@ Mounted into a hub, the plugin owns PTY/child-process spawning and its own strea 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. +## Focusing a session + +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: + +```ts +// e.g. right after ctx.terminals.startChildProcess(..., { id: sessionId, ... }) +await rpc.call('hub:docks:activate', { + dockId: 'devframes-plugin-terminals', + params: { sessionId }, +}) +``` + +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. + ## Source [`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals) diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index d3472aa7..7460437b 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -33,6 +33,7 @@ function createStubSharedState(initial: T): StubSharedState { function createStubRpc() { const calls: any[][] = [] const states = new Map>() + const definitions = new Map any }>() const rpc = { sharedState: { async get(key: string, options?: { initialValue?: any }) { @@ -47,8 +48,14 @@ function createStubRpc() { return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] } return `rpc:${args[0]}` }, + client: { + definitions, + register(fn: { name: string, type: string, handler?: (...args: any[]) => any }) { + definitions.set(fn.name, fn) + }, + }, } as unknown as DevframeRpcClient - return { rpc, calls, states } + return { rpc, calls, states, definitions } } function iframeEntry(id: string, extra?: Record): DevframeDockEntry { @@ -115,6 +122,23 @@ describe('createDevframeClientHost', () => { host.dispose() }) + it('switches the active dock when the hub broadcasts devframe:docks:activate', async () => { + const { rpc, states, definitions } = createStubRpc() + const host = await createDevframeClientHost({ rpc }) + states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('devframes-plugin-terminals')]) + + // Simulate the hub's server→client broadcast. + const handler = definitions.get('devframe:docks:activate')!.handler! + handler({ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } }) + await vi.waitFor(() => expect(host.context.docks.selectedId).toBe('devframes-plugin-terminals')) + + // Unknown dock ids degrade to a no-op (the previous selection stands). + handler({ dockId: 'ghost' }) + await new Promise(r => setTimeout(r, 0)) + expect(host.context.docks.selectedId).toBe('devframes-plugin-terminals') + host.dispose() + }) + it('executes client commands locally and server commands over hub:commands:execute', async () => { const { rpc, calls } = createStubRpc() const host = await createDevframeClientHost({ rpc }) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 270208d4..ae9ef6bf 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -31,6 +31,7 @@ import { createMessagesClient } from './messages' const DOCKS_STATE_KEY = 'devframe:docks' const COMMANDS_STATE_KEY = 'devframe:commands' const USER_SETTINGS_STATE_KEY = 'devframe:user-settings' +const DOCKS_ACTIVATE_EVENT = 'devframe:docks:activate' export interface DevframeClientHostOptions { /** @@ -124,6 +125,32 @@ export async function createDevframeClientHost( reconcileEntries() disposers.push(docksState.on('updated', reconcileEntries)) + // Honor cross-iframe dock activation: any client (e.g. a mounted devframe in + // its own iframe) can ask the hub to switch this shell's active dock via the + // `hub:docks:activate` RPC, which the hub broadcasts here. `switchEntry` + // ignores ids it doesn't recognize, so an unknown target degrades to a no-op. + // Another consumer sharing this rpc client may have registered the handler + // already — chain onto it rather than replacing it. + const activateHandler = (activation: { dockId?: string } | undefined): void => { + if (activation?.dockId) + void switchEntry(activation.dockId) + } + const existingActivate = rpc.client.definitions.get(DOCKS_ACTIVATE_EVENT) + if (existingActivate) { + const prev = existingActivate.handler + existingActivate.handler = (...args: unknown[]) => { + activateHandler(args[0] as { dockId?: string }) + return prev?.(...args) + } + } + else { + rpc.client.register({ + name: DOCKS_ACTIVATE_EVENT, + type: 'action', + handler: (activation: { dockId?: string }) => activateHandler(activation), + }) + } + if (getDevframeClientContext()) { console.warn( '[@devframes/hub] A client host context is already published on this page — replacing it. ' diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index a8866cd1..12ef9052 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHostContext, startHttpAndWs } from 'devframe/node' import { getInternalContext } from 'devframe/node/hub-internals' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createHubContext } from '../context' function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) { @@ -28,6 +28,37 @@ describe('createHubContext shared state', () => { }) }) +describe('createHubContext dock activation', () => { + it('mirrors an activation into shared state and broadcasts it live', async () => { + const context = await createHubContext({ + cwd: process.cwd(), + mode: 'build', + host: createHost(), + }) + context.docks.register({ + type: 'iframe', + id: 'devframes-plugin-terminals', + title: 'Terminals', + icon: 'ph:terminal-window-duotone', + url: '/__devframes-plugin-terminals/', + }) + + const broadcast = vi.spyOn(context.rpc, 'broadcast').mockResolvedValue() + context.docks.activate('devframes-plugin-terminals', { sessionId: 'sess-1' }) + + const active = await context.rpc.sharedState.get<{ activation: unknown }>('devframe:docks:active') + expect(active.value().activation).toEqual({ + dockId: 'devframes-plugin-terminals', + params: { sessionId: 'sess-1' }, + }) + expect(broadcast).toHaveBeenCalledWith({ + method: 'devframe:docks:activate', + args: [{ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } }], + }) + broadcast.mockRestore() + }) +}) + describe('startHttpAndWs remote endpoint metadata', () => { it('sets and clears the internal websocket endpoint', async () => { const context = await createHostContext({ diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index b52eee77..61753d68 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { REMOTE_CONNECTION_KEY } from 'devframe/constants' import { getInternalContext } from 'devframe/node/hub-internals' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { parseRemoteConnection } from '../../client/remote' import { DevframeDocksHost } from '../host-docks' @@ -190,6 +190,36 @@ describe('devframeDockHost grouping', () => { }) }) +describe('devframeDockHost activate', () => { + it('emits a dock:activate event carrying the id and params', () => { + const host = new DevframeDocksHost(createContext()) + host.register({ type: 'iframe', id: 'terminals', title: 'Terminals', icon: 'ph:terminal-window-duotone', url: '/__terminals/' }) + + const activations: Array<{ dockId: string, params?: Record }> = [] + host.events.on('dock:activate', a => activations.push(a)) + + host.activate('terminals', { sessionId: 'sess-1' }) + expect(activations).toEqual([{ dockId: 'terminals', params: { sessionId: 'sess-1' } }]) + }) + + it('still emits for an unknown dock but warns (DF8107, graceful)', () => { + const host = new DevframeDocksHost(createContext()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const activations: string[] = [] + host.events.on('dock:activate', a => activations.push(a.dockId)) + + host.activate('nope') + expect(activations).toEqual(['nope']) + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0]!.join(' ')).toMatch(/unknown dock id/i) + } + finally { + warn.mockRestore() + } + }) +}) + 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__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index b75922d7..ba3d2043 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -1,6 +1,6 @@ import type { DevframeHubContext } from '../context' import { describe, expect, it, vi } from 'vitest' -import { hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins' +import { hubDocksActivate, hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins' function contextWithSessions(sessions: Map): DevframeHubContext { return { terminals: { sessions } } as unknown as DevframeHubContext @@ -37,3 +37,23 @@ describe('hub terminal write/resize RPC', () => { await expect(writeFn.handler!('nope', 'x')).rejects.toThrow(/not registered/i) }) }) + +describe('hub docks activate RPC', () => { + it('forwards dockId and params to docks.activate', async () => { + const activate = vi.fn() + const ctx = { docks: { activate } } as unknown as DevframeHubContext + + const fn = await hubDocksActivate.setup!(ctx) + await fn.handler!({ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } }) + expect(activate).toHaveBeenCalledWith('devframes-plugin-terminals', { sessionId: 'sess-1' }) + }) + + it('forwards a bare dockId without params', async () => { + const activate = vi.fn() + const ctx = { docks: { activate } } as unknown as DevframeHubContext + + const fn = await hubDocksActivate.setup!(ctx) + await fn.handler!({ dockId: 'devframes-plugin-messages' }) + expect(activate).toHaveBeenCalledWith('devframes-plugin-messages', undefined) + }) +}) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 5e89759b..2ad20594 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -1,7 +1,7 @@ import type { CreateHostContextOptions } from 'devframe/node' import type { DevframeHost, DevframeNodeContext } from 'devframe/types' import type { DevframeCommandsHost } from '../types/commands' -import type { DevframeDocksHost } from '../types/docks' +import type { DevframeDockActivation, DevframeDocksActiveState, DevframeDocksHost } from '../types/docks' import type { JsonRenderer, JsonRenderSpec } from '../types/json-render' import type { DevframeMessagesHost } from '../types/messages' import type { DevframeTerminalsHost } from '../types/terminals' @@ -15,6 +15,17 @@ import { builtinHubRpcDeclarations } from './rpc-builtins' declare module 'devframe/types' { interface DevframeRpcClientFunctions { + /** + * Server→client request to switch the active dock. Broadcast by the hub + * context in response to `ctx.docks.activate()` (driven by the + * `hub:docks:activate` RPC). The client host registers a handler that + * calls its local `switchEntry(dockId)`; the target dock reads + * `activation.params` to react (e.g. focus a session). Do not register + * manually. + * + * @internal + */ + 'devframe:docks:activate': (activation: DevframeDockActivation) => Promise /** * Server→client notification that terminal sessions changed. Broadcast * by the hub context; a hub-aware client re-reads terminal state in @@ -123,6 +134,25 @@ export async function createHubContext(options: CreateHubContextOptions): Promis docks.events.on('dock:entry:updated', refreshDocks) docksSharedState.mutate(() => docks.values()) + // Cross-iframe dock activation. A dock activation is a discrete user intent + // ("go to Terminals now"), so it fires immediately (no debounce, which could + // coalesce two distinct requests) both as a live broadcast — the host shell + // switches its active dock — and into a shared-state slot, so a dock that + // only mounts *because* of the switch still converges on the request. + const activeDockSharedState = await context.rpc.sharedState.get( + 'devframe:docks:active', + { initialValue: { activation: null } }, + ) + docks.events.on('dock:activate', (activation) => { + activeDockSharedState.mutate((state) => { + state.activation = activation + }) + context.rpc.broadcast({ + method: 'devframe:docks:activate', + args: [activation], + }) + }) + const broadcastTerminals = debounce(() => { context.rpc.broadcast({ method: 'devframe:terminals:updated', diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index 65c279d4..d144af21 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -42,6 +42,10 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string, name: string, base: string }) => `The host cannot serve the RPC connection meta for devframe "${p.name}" (id "${p.id}") at "${p.base}" — its \`DevframeHost\` does not implement \`mountConnectionMeta\`.`, fix: 'Implement `mountConnectionMeta(base)` on your DevframeHost so it serves `__connection.json` at each mounted base. Without it, the devframe SPA connects only when it shares an origin with the hub UI (same-origin window inheritance); cross-origin, sandboxed, or directly-opened iframes stay disconnected. Static-snapshot hosts that bake the meta into the served files can implement it as a no-op to acknowledge this intentionally.', }, + DF8107: { + why: (p: { id: string }) => `Dock activation requested for unknown dock id "${p.id}"`, + fix: 'Pass a `dockId` that matches a registered dock entry. The activation is still broadcast, but no viewer will switch to it. Ids are case-sensitive — check for typos, and ensure the target dock is registered before activating it.', + }, DF8200: { why: (p: { id: string }) => `Terminal session with id "${p.id}" already registered`, }, diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 5a5d2465..f926c72c 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -162,6 +162,16 @@ export class DevframeDocksHost implements DevframeDocksHostType { this.events.emit('dock:entry:updated', view) } + activate(dockId: string, params?: Record): void { + // Best-effort: warn (don't throw) when the target isn't a registered dock + // so a typo is observable, but still emit — the client host and each dock + // ignore ids they don't recognize, so a mis-addressed activation is inert + // rather than fatal. + if (!this.views.has(dockId)) + diagnostics.DF8107({ id: dockId }) + this.events.emit('dock:activate', { dockId, params }) + } + private validateGroupMembership(view: DevframeDockUserEntry): void { if (view.groupId === undefined) return diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index 6f409153..00f582a5 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -119,6 +119,28 @@ export const hubTerminalsResize = defineHubRpcFunction({ }), }) +/** + * `hub:docks:activate` — Ask the active viewer to switch its focused dock to + * `dockId`, optionally carrying `params` for the target dock to interpret + * (e.g. `{ sessionId }` for the terminals dock to focus a session). + * + * Any connected client may call it, which is the point: a mounted devframe + * running in its own iframe (on its own RPC client) can steer the host shell's + * dock selection — client-local state it otherwise can't reach. The hub + * broadcasts the request live to connected clients (the host shell switches) + * and mirrors it into the `devframe:docks:active` shared state (a dock that + * mounts in response still converges on it). + */ +export const hubDocksActivate = defineHubRpcFunction({ + name: 'hub:docks:activate', + type: 'action', + setup: context => ({ + async handler(input: { dockId: string, params?: Record }): Promise { + context.docks.activate(input.dockId, input.params) + }, + }), +}) + /** * Framework-neutral RPC declarations auto-registered by * {@link createHubContext}. Provide additional RPCs by passing your own @@ -127,6 +149,7 @@ export const hubTerminalsResize = defineHubRpcFunction({ */ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [ hubCommandsExecute, + hubDocksActivate, hubMessagesAdd, hubMessagesUpdate, hubMessagesRemove, diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 7db778e4..660f2e6c 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -5,6 +5,7 @@ export interface DevframeDocksHost { readonly views: Map readonly events: EventEmitter<{ 'dock:entry:updated': (entry: DevframeDockUserEntry) => void + 'dock:activate': (activation: DevframeDockActivation) => void }> register: (entry: T, force?: boolean) => { @@ -12,6 +13,41 @@ export interface DevframeDocksHost { } update: (entry: DevframeDockUserEntry) => void values: () => DevframeDockEntry[] + + /** + * Request the active viewer switch its focused dock to `dockId`, optionally + * carrying `params` for the target dock to interpret (e.g. a terminals + * session id). + * + * Any connected client may drive this via the `hub:docks:activate` RPC — a + * mounted devframe running in its own iframe can steer the host shell's dock + * selection, which is otherwise client-local. The request is delivered live + * to connected clients (broadcast) and mirrored into the + * `devframe:docks:active` shared state so a dock that mounts in response + * still sees it. Activation is best-effort: unknown dock ids degrade + * gracefully. + */ + activate: (dockId: string, params?: Record) => void +} + +/** + * A request to switch the active dock. `params` is an opaque, serializable + * bag the target dock interprets — the terminals dock reads `params.sessionId` + * to focus a specific session. + */ +export interface DevframeDockActivation { + dockId: string + params?: Record +} + +/** + * Shape of the `devframe:docks:active` shared-state slot — the most recent + * {@link DevframeDockActivation}, or `null` before any activation. Mirrored + * so a dock that mounts in response to an activation can still converge on the + * request instead of missing the live broadcast. + */ +export interface DevframeDocksActiveState { + activation: DevframeDockActivation | null } // Known categories the hub orders by default. Kits may pass their own diff --git a/plugins/terminals/src/client/App.svelte b/plugins/terminals/src/client/App.svelte index b2c60387..1c2d15f3 100644 --- a/plugins/terminals/src/client/App.svelte +++ b/plugins/terminals/src/client/App.svelte @@ -4,7 +4,7 @@ import type { DotState } from './design' import { button, dot, iconButton, nav, navBrand, navTab, tag, toolbar } from './design' import { onMount } from 'svelte' - import { PRESETS_STATE_KEY, SESSIONS_STATE_KEY } from '../constants' + import { DOCKS_ACTIVE_STATE_KEY, PLUGIN_ID, PRESETS_STATE_KEY, SESSIONS_STATE_KEY } from '../constants' import TerminalView from './TerminalView.svelte' const { rpc, autostart } = $props<{ @@ -33,6 +33,28 @@ const activeSession = $derived(sessions.find(s => s.id === activeId) ?? null) + // A focus request that arrived (via the hub's dock-activation slot) before + // its session showed up in the list. Applied one-shot the moment a matching + // session appears, then cleared so the user's own tab clicks stay honored. + let pendingFocusId: string | null = null + + /** + * Focus a session by id, on request from the hub's cross-iframe dock + * activation (e.g. Vite DevTools navigating to the build it just spawned). + * Focuses immediately when the session is already known; otherwise waits for + * it to arrive. An unknown/ended id is a no-op — the default selection + * (most-recent session) stands. + */ + function requestFocus(id: string): void { + if (sessions.some(s => s.id === id)) { + activeId = id + pendingFocusId = null + } + else { + pendingFocusId = id + } + } + function readHashId(): string | null { if (typeof location === 'undefined') return null @@ -61,6 +83,14 @@ } function pickActive(list: TerminalSessionInfo[]): void { + // A queued focus request (from the hub's dock activation) wins over the + // default pick the moment its session lands, then clears so it fires + // exactly once and the user's own tab clicks stay honored. + if (pendingFocusId && list.some(x => x.id === pendingFocusId)) { + activeId = pendingFocusId + pendingFocusId = null + return + } if (activeId && !list.some(x => x.id === activeId)) activeId = null if (!activeId && list.length) { @@ -121,6 +151,31 @@ } }) + // The hub mirrors the most recent dock activation here. When it targets this + // dock and carries a session id, focus that session — this is how a mounted + // devframe (e.g. Vite DevTools) navigates the user straight to a spawned + // build's terminal, whether the dock was already open or mounts in response. + let offActivation: (() => void) | undefined + + function applyActivation(activation: unknown): void { + if (!activation || typeof activation !== 'object') + return + const { dockId, params } = activation as { dockId?: string, params?: Record } + if (dockId !== PLUGIN_ID) + return + const sessionId = params?.sessionId + if (typeof sessionId === 'string') + requestFocus(sessionId) + } + + onMount(() => { + rpc.sharedState.get(DOCKS_ACTIVE_STATE_KEY, { initialValue: { activation: null } }).then((state: any) => { + applyActivation(state.value().activation) + offActivation = state.on('updated', (full: any) => applyActivation(full.activation)) + }) + return () => offActivation?.() + }) + onMount(async () => { let existing: TerminalSessionInfo[] | null = null try { diff --git a/plugins/terminals/src/constants.ts b/plugins/terminals/src/constants.ts index b758cd84..1d19ab2e 100644 --- a/plugins/terminals/src/constants.ts +++ b/plugins/terminals/src/constants.ts @@ -23,6 +23,17 @@ export const SESSIONS_STATE_KEY = 'devframes-plugin-terminals:sessions' /** Shared-state key holding the spawnable command presets. */ export const PRESETS_STATE_KEY = 'devframes-plugin-terminals:presets' +/** + * Shared-state key the hub (`@devframes/hub`) mirrors the most recent dock + * activation into. When a mounted devframe asks the hub to switch to this + * dock — e.g. Vite DevTools navigating to the build it just spawned — the + * request lands here as `{ activation: { dockId, params } }`. The UI reads + * `params.sessionId` off it (when `dockId` is this plugin) to focus a specific + * session, converging even when it mounts *because* of the switch. Kept as a + * literal so the plugin needs no build dependency on the hub. + */ +export const DOCKS_ACTIVE_STATE_KEY = 'devframe:docks:active' + /** Default dev-server port for the standalone CLI. */ export const DEFAULT_PORT = 9011 diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index d365d4ab..65578b82 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -35,11 +35,13 @@ export { DevframeCommandsHostEvents } export { DevframeDiagnosticsDefinition } export { DevframeDiagnosticsHost } export { DevframeDiagnosticsLogger } +export { DevframeDockActivation } export { DevframeDockEntriesGrouped } export { DevframeDockEntry } export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } +export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } export { DevframeDockUserEntry } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index fd949f39..ced3f90c 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -36,6 +36,7 @@ export declare class DevframeDocksHost implements DevframeDocksHost$1 { update: (_: Partial) => void; }; update(_: DevframeDockUserEntry): void; + activate(_: string, _?: Record): void; private validateGroupMembership; private prepareRemoteRegistration; } @@ -105,6 +106,36 @@ export declare const hubCommandsExecute: { __cache?: WeakMap>>> | undefined; __promise?: import("devframe/rpc").Thenable>> | undefined; }; +export declare const hubDocksActivate: { + name: "hub:docks:activate"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable; + }], Promise>>) | undefined; + handler?: ((input: { + dockId: string; + params?: Record; + }) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[input: { + dockId: string; + params?: Record; + }], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap; + }], Promise>>> | undefined; + __promise?: import("devframe/rpc").Thenable; + }], Promise>> | 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 ce61db37..e6a95010 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -27,6 +27,7 @@ export class DevframeDocksHost { resolveDevServerOrigin() {} register(_, _) {} update(_) {} + activate(_, _) {} validateGroupMembership(_) {} prepareRemoteRegistration(_) {} } @@ -80,6 +81,7 @@ export async function mountDevframe(_, _, _) {} // #region Variables export var builtinHubRpcDeclarations /* const */ export var hubCommandsExecute /* const */ +export var hubDocksActivate /* const */ export var hubMessagesAdd /* const */ export var hubMessagesClear /* const */ export var hubMessagesRemove /* const */ diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index fddd2583..19f93f45 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -21,11 +21,13 @@ export { DevframeCommandsHostEvents } export { DevframeDiagnosticsDefinition } export { DevframeDiagnosticsHost } export { DevframeDiagnosticsLogger } +export { DevframeDockActivation } export { DevframeDockEntriesGrouped } export { DevframeDockEntry } export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } +export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } export { DevframeDockUserEntry } diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts index 76cd083c..4b89bc3c 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts @@ -6,6 +6,7 @@ export declare const DEFAULT_COLS: number; export declare const DEFAULT_PORT: number; export declare const DEFAULT_ROWS: number; export declare const DEFAULT_SCROLLBACK: number; +export declare const DOCKS_ACTIVE_STATE_KEY: string; export declare const HUB_TERMINAL_STREAM_CHANNEL: string; export declare const PLUGIN_ID: string; export declare const PRESETS_STATE_KEY: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js index de0d9d3d..63b9b7ea 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js @@ -6,6 +6,7 @@ export var DEFAULT_COLS /* const */ export var DEFAULT_PORT /* const */ export var DEFAULT_ROWS /* const */ export var DEFAULT_SCROLLBACK /* const */ +export var DOCKS_ACTIVE_STATE_KEY /* const */ export var HUB_TERMINAL_STREAM_CHANNEL /* const */ export var PLUGIN_ID /* const */ export var PRESETS_STATE_KEY /* const */