diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index a85d8368c..ab89d47a3 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -2,14 +2,14 @@ title: 'Agent-Native Devframe' navigation: icon: i-lucide-bot -description: 'Devframe exposes its browser-side API (RPC functions, resources, shared state) to coding agents over MCP, opt-in per function.' +description: 'Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function.' --- -Devframe exposes its browser-side API (RPC functions, resources, shared state) to coding agents over MCP, opt-in per function. +Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and [WebMCP](#browser-side-tools-over-webmcp) on the browser side, opt-in per function. ## How it works -Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server. +Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server. The same `agent` field on a *client* RPC function surfaces it [over WebMCP](#browser-side-tools-over-webmcp) instead. ## Exposing an RPC function @@ -137,6 +137,29 @@ In `claude_desktop_config.json`: Restart; tools appear in the drawer, resources as `devframe://resource/` / `devframe://state/` URIs. +## Browser-side tools over WebMCP + +The same `agent` signature works on the browser side: a client RPC function (a function the node side calls on the browser, registered on `rpc.client` or through a scoped `client.scope('my-plugin').rpc.register(...)`) carrying an `agent` field is mirrored onto the page's [WebMCP](https://github.com/webmachinelearning/webmcp) model context (`document.modelContext` / `navigator.modelContext`) as a callable tool, so in-page and browser-integrated agents can drive browser-side functionality directly. Wire names, `arg0`/`arg1`/… input schemas, and safety annotations match the MCP projection above. + +```ts +const rpc = await connectDevframe() + +rpc.client.register({ + name: 'my-plugin:highlight-node', + type: 'action', + jsonSerializable: true, + agent: { + description: 'Highlight a node in the open inspector view. Use it to point the user at a finding.', + }, + handler: (id: string) => highlightNode(id), +}) +``` + +`connectDevframe()` wires this on its own when the browser provides a model context; `webmcp: false` keeps the browser side off the WebMCP surface. `registerWebMcpTools(collector)` (from `devframe/client`) applies the same projection to a hand-built collector and returns a dispose that unregisters every tool. + +> [!WARNING] +> WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change. + ## Writing descriptions agents act on Describe *when* to use a tool, not just its return: diff --git a/docs/content/5.add-ons/1.devframes/2.inspect.md b/docs/content/5.add-ons/1.devframes/2.inspect.md index c6befcc7a..83ebb1797 100644 --- a/docs/content/5.add-ons/1.devframes/2.inspect.md +++ b/docs/content/5.add-ons/1.devframes/2.inspect.md @@ -24,6 +24,7 @@ _History panels_ ## What it does - **Functions**: type, flags, JSON Schema, agent exposure; read-only `query` / `static` invokable inline. +- **Client**: the browser side of the connection: client RPC functions (invoked locally in the page) and the page's WebMCP tools, live from the model context's `getTools()` when the browser supports discovery, otherwise projected from `agent`-flagged client functions. - **State**: shared-state keys in a live JSON tree that flashes changes. - **Agent**: tools and resources for agents. - **History**: a timeline of RPC calls and shared-state updates. diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index c7e43f8d0..35cb89e00 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -21,6 +21,7 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c | `wsOptions` | Transport overrides: `onConnected` / `onError` / `onDisconnected` hooks, socket URL. | | `rpcOptions` | Forwarded to `birpc`. | | `connectionMeta` | Descriptor that skips the `__connection.json` fetch. | +| `webmcp` | Mirror `agent`-flagged client RPC functions onto the page's WebMCP model context as tools; `false` opts out. Default `true` (applies only when the browser provides one). See [Agent-Native](/guide/agent-native#browser-side-tools-over-webmcp). | ## RPC client events diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index f16eb5bb5..1add93bc5 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -9,5 +9,6 @@ export * from './rpc-streaming' export { resolveWsUrl, type WsUrlLocation } from './rpc-ws' export * from './scope' export * from './settings' +export * from './webmcp' export const connectDevframe = getDevframeRpcClient diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 694f4ea82..48df62dc4 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -21,6 +21,7 @@ import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' import { createWsRpcClientMode } from './rpc-ws' import { createScopedClientContext } from './scope' +import { registerWebMcpTools } from './webmcp' export interface DevframeRpcContext { /** @@ -99,6 +100,18 @@ export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions sseOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial + /** + * Mirror `agent`-flagged client RPC functions (functions registered on + * `rpc.client` with an `agent` field) onto the page's WebMCP model + * context (`document.modelContext` / `navigator.modelContext`) as + * callable tools, so in-page and browser-integrated agents can invoke + * them; see `registerWebMcpTools`. Applies only when the browser + * provides a model context. Set `false` to keep the browser side off + * the WebMCP surface. + * + * @default true + */ + webmcp?: boolean /** * Reject a pending `rpc.call(...)` if the server hasn't answered within this * many milliseconds, with a {@link DevframeConnectionError} of kind @@ -332,6 +345,8 @@ export async function getDevframeRpcClient( rpc: undefined!, } const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) + // No-op when the browser provides no WebMCP model context. + const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc) async function fetchJsonFromBases(path: string): Promise { const candidates = [ @@ -470,7 +485,10 @@ export async function getDevframeRpcClient( streaming: undefined!, cacheManager, scope: undefined!, - close: () => mode.close?.(), + close: () => { + disposeWebMcp?.() + mode.close?.() + }, } rpc.sharedState = createRpcSharedStateClientHost(rpc) diff --git a/packages/devframe/src/client/webmcp.test.ts b/packages/devframe/src/client/webmcp.test.ts new file mode 100644 index 000000000..885e7aa6f --- /dev/null +++ b/packages/devframe/src/client/webmcp.test.ts @@ -0,0 +1,298 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { ConnectionMeta } from 'devframe/types' +import type { WebMcpModelContext, WebMcpToolDescriptor } from './webmcp' +import { RpcFunctionsCollectorBase } from 'devframe/rpc' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDevframeRpcClient } from './rpc' +import { registerWebMcpTools } from './webmcp' + +/** A Standard Schema that also implements the Standard JSON Schema converter (like zod 4). */ +function withJsonSchema(json: Record): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => json, + output: () => json, + }, + } as StandardSchemaV1['~standard'], + } +} + +/** Spec-shaped model context: unregisters by aborting the passed signal. */ +function createFakeModelContext() { + const tools = new Map() + const modelContext: WebMcpModelContext = { + registerTool(tool, options) { + tools.set(tool.name, tool) + options?.signal?.addEventListener('abort', () => tools.delete(tool.name)) + return Promise.resolve() + }, + } + return { modelContext, tools } +} + +function createCollector() { + return new RpcFunctionsCollectorBase, undefined>(undefined) +} + +describe('registerWebMcpTools', () => { + it('registers only agent-flagged functions, under their wire names', () => { + const collector = createCollector() + collector.register({ + name: 'my-plugin:greet', + jsonSerializable: true, + agent: { description: 'Greet someone by name.' }, + args: [withJsonSchema({ type: 'string' })], + returns: withJsonSchema({ type: 'string' }), + handler: (name: string) => `Hello ${name}`, + }) + collector.register({ + name: 'my-plugin:internal', + handler: () => 'hidden', + }) + + const { modelContext, tools } = createFakeModelContext() + const dispose = registerWebMcpTools(collector, { modelContext }) + + expect([...tools.keys()]).toEqual(['my-plugin_greet']) + const tool = tools.get('my-plugin_greet')! + expect(tool.description).toBe('Greet someone by name.') + expect(tool.inputSchema).toEqual({ + type: 'object', + properties: { arg0: { type: 'string' } }, + required: ['arg0'], + additionalProperties: false, + }) + // `query` (default type) infers read-only. + expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }) + + dispose() + expect(tools.size).toBe(0) + }) + + it('executes with arg0/argN coercion and returns a text result', async () => { + const collector = createCollector() + collector.register({ + name: 'add', + jsonSerializable: true, + agent: { description: 'Add two numbers.' }, + args: [withJsonSchema({ type: 'number' }), withJsonSchema({ type: 'number' })], + returns: withJsonSchema({ type: 'object' }), + handler: (a: number, b: number) => ({ sum: a + b }), + }) + + const { modelContext, tools } = createFakeModelContext() + registerWebMcpTools(collector, { modelContext }) + + const result = await tools.get('add')!.execute({ arg0: 2, arg1: 3 }) + expect(result.isError).toBeUndefined() + expect(JSON.parse(result.content[0]!.text)).toEqual({ sum: 5 }) + }) + + it('surfaces a thrown error as an isError text result', async () => { + const collector = createCollector() + collector.register({ + name: 'boom', + type: 'action', + jsonSerializable: true, + agent: { description: 'Always fails.' }, + handler: () => { + throw new Error('nope') + }, + }) + + const { modelContext, tools } = createFakeModelContext() + registerWebMcpTools(collector, { modelContext }) + + const result = await tools.get('boom')!.execute({}) + expect(result.isError).toBe(true) + expect(result.content[0]!.text).toBe('Error: nope') + }) + + it('follows later register/update calls until disposed', async () => { + const collector = createCollector() + const { modelContext, tools } = createFakeModelContext() + const dispose = registerWebMcpTools(collector, { modelContext }) + expect(tools.size).toBe(0) + + collector.register({ + name: 'greet', + jsonSerializable: true, + agent: { description: 'Greet.' }, + handler: () => 'hi', + }) + expect(tools.has('greet')).toBe(true) + + collector.update({ + name: 'greet', + jsonSerializable: true, + agent: { description: 'Greet politely.' }, + handler: () => 'good day', + }) + expect(tools.get('greet')!.description).toBe('Greet politely.') + const updated = await tools.get('greet')!.execute({}) + expect(updated.content[0]!.text).toBe('good day') + + dispose() + expect(tools.size).toBe(0) + + // Post-dispose registrations no longer reach the model context. + collector.register({ + name: 'late', + jsonSerializable: true, + agent: { description: 'Too late.' }, + handler: () => 'late', + }) + expect(tools.size).toBe(0) + }) + + it('keeps the first registration when two ids sanitize to the same wire name', () => { + // Chromium rejects a duplicate tool name with an InvalidStateError; the + // guard must skip the collision before it reaches the model context. + const { modelContext, tools } = createFakeModelContext() + const strict: WebMcpModelContext = { + registerTool: (tool, options) => { + if (tools.has(tool.name)) + throw new Error('Duplicate tool name') + return modelContext.registerTool(tool, options) + }, + } + const collector = createCollector() + collector.register({ + name: 'my-plugin:greet', + jsonSerializable: true, + agent: { description: 'First.' }, + handler: () => 'first', + }) + collector.register({ + name: 'my-plugin_greet', + jsonSerializable: true, + agent: { description: 'Second, same wire name.' }, + handler: () => 'second', + }) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + registerWebMcpTools(collector, { modelContext: strict }) + expect(tools.size).toBe(1) + expect(tools.get('my-plugin_greet')!.description).toBe('First.') + expect(warn).toHaveBeenCalledOnce() + } + finally { + warn.mockRestore() + } + }) + + it('tolerates a model context that rejects registration (permissions policy)', async () => { + // A rejected registration must not surface as an unhandled rejection. + const collector = createCollector() + collector.register({ + name: 'denied', + jsonSerializable: true, + agent: { description: 'Denied by the frame policy.' }, + handler: () => 'never', + }) + const dispose = registerWebMcpTools(collector, { + modelContext: { registerTool: () => Promise.reject(new Error('NotAllowedError')) }, + }) + await new Promise(resolve => setTimeout(resolve, 0)) + dispose() + }) + + it('unregisters through a legacy handle when registerTool returns one', () => { + const unregister = vi.fn() + const modelContext: WebMcpModelContext = { + registerTool: () => ({ unregister }), + } + const collector = createCollector() + collector.register({ + name: 'legacy', + jsonSerializable: true, + agent: { description: 'Legacy handle.' }, + handler: () => 'ok', + }) + + const dispose = registerWebMcpTools(collector, { modelContext }) + dispose() + expect(unregister).toHaveBeenCalledTimes(1) + }) + + it('is a no-op without a model context', () => { + const collector = createCollector() + collector.register({ + name: 'greet', + jsonSerializable: true, + agent: { description: 'Greet.' }, + handler: () => 'hi', + }) + expect(() => registerWebMcpTools(collector)()).not.toThrow() + }) +}) + +describe('getDevframeRpcClient: WebMCP wiring', () => { + // Minimal fake WebSocket: never opens, so the trust handshake stays + // pending; this suite only exercises the client-collector side. + class FakeWebSocket { + addEventListener(): void {} + removeEventListener(): void {} + send(): void {} + close(): void {} + } + + beforeEach(() => { + vi.stubGlobal('WebSocket', FakeWebSocket) + vi.stubGlobal('location', { + protocol: 'http:', + host: 'localhost:5173', + hostname: 'localhost', + href: 'http://localhost:5173/__foo/index.html', + origin: 'http://localhost:5173', + }) + const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } } + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => served, + }))) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubModelContext() { + const { modelContext, tools } = createFakeModelContext() + vi.stubGlobal('navigator', { userAgent: 'test', modelContext }) + return tools + } + + it('mirrors agent-flagged client registrations onto the page model context; close() unregisters', async () => { + const tools = stubModelContext() + const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false }) + rpc.client.register({ + name: 'my-plugin:get-selection', + jsonSerializable: true, + agent: { description: 'Return the selected node.' }, + handler: () => 'node-1', + }) + expect([...tools.keys()]).toEqual(['my-plugin_get-selection']) + + rpc.close?.() + expect(tools.size).toBe(0) + }) + + it('webmcp: false keeps the browser side off the WebMCP surface', async () => { + const tools = stubModelContext() + const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false, webmcp: false }) + rpc.client.register({ + name: 'my-plugin:get-selection', + jsonSerializable: true, + agent: { description: 'Return the selected node.' }, + handler: () => 'node-1', + }) + expect(tools.size).toBe(0) + }) +}) diff --git a/packages/devframe/src/client/webmcp.ts b/packages/devframe/src/client/webmcp.ts new file mode 100644 index 000000000..eca351ab9 --- /dev/null +++ b/packages/devframe/src/client/webmcp.ts @@ -0,0 +1,228 @@ +import type { RpcFunctionAgentOptions, RpcFunctionDefinitionAnyWithContext, RpcFunctionsCollector, RpcFunctionType } from 'devframe/rpc' +import { getRpcHandler } from 'devframe/rpc' +import { toAgentToolName } from 'devframe/utils/agent-tool-name' +// Pure, browser-safe projections shared with the node-side MCP adapter, so +// the WebMCP surface cannot drift from the MCP one. +import { argsToJsonSchema } from '../adapters/mcp/to-json-schema' +import { coerceAgentPositionalArgs } from '../node/agent-args' + +/** + * Result a WebMCP tool's `execute` resolves with; mirrors the MCP + * `CallToolResult` text shape the [WebMCP](https://github.com/webmachinelearning/webmcp) + * proposal adopts. + */ +export interface WebMcpToolResult { + content: { type: 'text', text: string }[] + isError?: boolean +} + +/** A tool descriptor as passed to {@link WebMcpModelContext.registerTool}. */ +export interface WebMcpToolDescriptor { + name: string + description: string + inputSchema?: unknown + annotations?: { + title?: string + readOnlyHint?: boolean + destructiveHint?: boolean + } + execute: (args: Record) => Promise +} + +/** + * A tool as reported by {@link WebMcpModelContext.getTools}: the + * serializable descriptor fields plus the registering `origin`. The + * browser may attach more members (e.g. the owner `window`); pass the + * object through unchanged to {@link WebMcpModelContext.executeTool}. + */ +export interface WebMcpRegisteredTool { + name: string + description?: string + inputSchema?: unknown + origin?: string +} + +/** + * Structural subset of the experimental WebMCP model context + * (`document.modelContext` / `navigator.modelContext`). The current draft + * unregisters a tool by aborting the passed `AbortSignal` and returns a + * promise; earlier drafts returned a handle with `unregister()`. Typed to + * accept both generations. `getTools` / `executeTool` are the draft's + * discovery/execution surface for in-page agents; absent on older drafts. + */ +export interface WebMcpModelContext { + registerTool: ( + tool: WebMcpToolDescriptor, + options?: { signal?: AbortSignal }, + ) => void | { unregister?: () => void } | Promise + getTools?: (options?: { fromOrigins?: string[] }) => Promise + /** + * The spec draft takes the args as a dictionary; Chromium's current + * build takes (and returns) JSON strings instead, hence the union. + */ + executeTool?: ( + tool: WebMcpRegisteredTool, + args: Record | string, + options?: { signal?: AbortSignal }, + ) => Promise +} + +interface WebMcpModelContextCarrier { + modelContext?: WebMcpModelContext +} + +/** + * The page's WebMCP model context, when the browser (or a polyfill) + * provides one. Checks `document.modelContext` (current draft) first, + * then `navigator.modelContext` (earlier drafts and polyfills). + */ +export function resolveWebMcpModelContext(): WebMcpModelContext | undefined { + // WebMCP is experimental and absent from lib.dom, hence the carriers. + if (typeof document !== 'undefined') { + const context = (document as WebMcpModelContextCarrier).modelContext + if (context) + return context + } + if (typeof navigator !== 'undefined') { + const context = (navigator as WebMcpModelContextCarrier).modelContext + if (context) + return context + } + return undefined +} + +export interface RegisterWebMcpToolsOptions { + /** + * Model context to register tools on. Defaults to the one the page + * provides (see {@link resolveWebMcpModelContext}); when neither is + * given, registration is a no-op. + */ + modelContext?: WebMcpModelContext +} + +/** + * Mirror the `agent`-flagged RPC functions of a browser-side collector + * (`rpc.client`) onto the page's WebMCP model context as callable tools, + * using the same `agent` signature, wire names, and `arg0`/`arg1`/… input + * schema as the node-side MCP adapter. Functions without an `agent` field + * stay unexposed (default-deny), and the tool set follows later + * `register`/`update` calls. Returns a dispose that unregisters every tool. + */ +export function registerWebMcpTools( + clientRpc: RpcFunctionsCollector, + options: RegisterWebMcpToolsOptions = {}, +): () => void { + const resolved = options.modelContext ?? resolveWebMcpModelContext() + if (!resolved) + return () => {} + const modelContext: WebMcpModelContext = resolved + + /** Unregister callbacks keyed by definition name. */ + const registered = new Map void>() + /** Wire name → definition name, to detect sanitization collisions. */ + const wireNames = new Map() + + function register(def: RpcFunctionDefinitionAnyWithContext, agent: RpcFunctionAgentOptions): void { + const name = toAgentToolName(def.name) + const owner = wireNames.get(name) + if (owner && owner !== def.name) { + console.warn(`[devframe] WebMCP tool name "${name}" (from "${def.name}") collides with "${owner}"; keeping the first registration.`) + return + } + + const controller = new AbortController() + const result = modelContext.registerTool({ + name, + description: agent.description, + inputSchema: argsToJsonSchema(def.args), + annotations: { + title: agent.title ?? def.name, + readOnlyHint: resolveSafety(def, agent) === 'read', + destructiveHint: resolveSafety(def, agent) === 'destructive', + }, + execute: args => executeRpcTool(def, clientRpc.context, args), + }, { signal: controller.signal }) + // Registration may reject when the frame's permissions policy denies + // `tools`; the surface is simply unavailable there. + if (result && 'then' in result) + void result.then(() => {}, () => {}) + + wireNames.set(name, def.name) + registered.set(def.name, () => { + controller.abort() + if (result && 'unregister' in result && typeof result.unregister === 'function') + result.unregister() + wireNames.delete(name) + }) + } + + function sync(id?: string): void { + const names = id ? [id] : [...clientRpc.definitions.keys()] + for (const name of names) { + registered.get(name)?.() + registered.delete(name) + const def = clientRpc.definitions.get(name) + const agent = def?.agent as RpcFunctionAgentOptions | undefined + if (def && agent) + register(def, agent) + } + } + + sync() + const unsubscribe = clientRpc.onChanged(id => sync(id)) + + return () => { + unsubscribe() + for (const unregister of registered.values()) + unregister() + registered.clear() + } +} + +function resolveSafety( + def: RpcFunctionDefinitionAnyWithContext, + agent: RpcFunctionAgentOptions, +): 'read' | 'action' | 'destructive' { + if (agent.safety) + return agent.safety + const type: RpcFunctionType = def.type ?? 'query' + return type === 'static' || type === 'query' ? 'read' : 'action' +} + +async function executeRpcTool( + def: RpcFunctionDefinitionAnyWithContext, + context: SetupContext, + args: Record, +): Promise { + try { + const positional = coerceAgentPositionalArgs(args, def.args as readonly unknown[] | undefined, 'wrap') + const handler = await getRpcHandler(def, context) + const result = await handler(...positional) + return { content: [{ type: 'text', text: stringifyResult(result) }] } + } + catch (error) { + return { + isError: true, + content: [{ type: 'text', text: formatError(error) }], + } + } +} + +/** + * Agent-exposed functions are strict-JSON by contract (`jsonSerializable: + * true` is enforced at registration), so plain `JSON.stringify` suffices. + */ +function stringifyResult(value: unknown): string { + if (value === undefined) + return 'undefined' + if (typeof value === 'string') + return value + return JSON.stringify(value, null, 2) +} + +function formatError(error: unknown): string { + if (!(error instanceof Error)) + return String(error) + const cause = error.cause instanceof Error ? ` (cause: ${error.cause.message})` : '' + return `${error.name}: ${error.message}${cause}` +} diff --git a/plugins/inspect/app/App.vue b/plugins/inspect/app/App.vue index e2ec1ccc8..234b46c9c 100644 --- a/plugins/inspect/app/App.vue +++ b/plugins/inspect/app/App.vue @@ -14,6 +14,7 @@ import { connectionTitle, } from '../../../design/design' import AgentSmart from './components/AgentSmart.vue' +import ClientSmart from './components/ClientSmart.vue' import CommandsSmart from './components/CommandsSmart.vue' import FunctionsSmart from './components/FunctionsSmart.vue' import HistorySmart from './components/HistorySmart.vue' @@ -22,7 +23,7 @@ import StateSmart from './components/StateSmart.vue' import { useRefresh } from './composables/refresh' import { connect, connection, isStatic } from './composables/rpc' -type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history' | 'instances' +type Tab = 'functions' | 'client' | 'state' | 'agent' | 'commands' | 'history' | 'instances' const tab = ref('functions') const { refresh, loading } = useRefresh() @@ -37,6 +38,7 @@ const connState = computed(() => connectionState(connection.status)) const allTabs: { value: Tab, label: string, icon: string }[] = [ { value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' }, + { value: 'client', label: 'Client', icon: 'i-ph-browser-duotone' }, { value: 'state', label: 'State', icon: 'i-ph-database-duotone' }, { value: 'agent', label: 'Agent', icon: 'i-ph-robot-duotone' }, { value: 'commands', label: 'Commands', icon: 'i-ph-terminal-window-duotone' }, @@ -109,6 +111,7 @@ function reload(): void {