` renderer
+ * the chat transcript uses, rather than the raw monospace `` it used to be.
+ */
+export function MarkdownPane({
+ body,
+ className,
+}: {
+ body: string
+ className?: string
+}) {
+ return (
+
+ {body}
+
+ )
+}
+
+export function formatRelativeTime(value: string): string {
+ if (!value) return ''
+ const ts = Date.parse(value)
+ if (Number.isNaN(ts)) return value
+ const diffMs = Date.now() - ts
+ if (diffMs < 0) return new Date(ts).toLocaleString()
+ if (diffMs < 60_000) return `${Math.floor(diffMs / 1000)}s ago`
+ if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`
+ if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`
+ if (diffMs < 7 * 86_400_000) return `${Math.floor(diffMs / 86_400_000)}d ago`
+ return new Date(ts).toLocaleDateString()
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes}B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
+}
diff --git a/console/web/src/components/chat/engine/FunctionInfoView.tsx b/console/web/src/components/chat/engine/FunctionInfoView.tsx
new file mode 100644
index 00000000..c02449ba
--- /dev/null
+++ b/console/web/src/components/chat/engine/FunctionInfoView.tsx
@@ -0,0 +1,249 @@
+import { useState } from 'react'
+import {
+ ActionLine,
+ Chip,
+ MetaRow,
+ StatusPill,
+} from '@/components/chat/sandbox/shared'
+import { CodeHighlight } from '@/lib/syntax'
+import { cn } from '@/lib/utils'
+import {
+ type FunctionDetail,
+ functionDetailSchema,
+ functionInfoRequestSchema,
+ safeParseRequest,
+ safeParseResponse,
+} from './parsers'
+
+interface FunctionInfoViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function FunctionInfoView({
+ input,
+ output,
+ running,
+}: FunctionInfoViewProps) {
+ const req = safeParseRequest(functionInfoRequestSchema, input)
+
+ if (running) {
+ return (
+
+
+
+ {req ? (
+
+
+ function
+
+ {req.function_id}
+
+ ) : null}
+
+
+ · inspecting function…
+
+
+ )
+ }
+
+ const detail = safeParseResponse(functionDetailSchema, output)
+ if (!detail) return null
+
+ return (
+
+
+
+
+
+ worker
+
+ {detail.worker_name}
+
+
+
+ triggers
+
+
+ {detail.registered_triggers.length}
+
+
+
+
+
+ {detail.function_id}
+
+
+ {detail.description ? (
+
+ {detail.description}
+
+ ) : null}
+
+
+ {detail.metadata !== undefined ? (
+
+ ) : null}
+
+
+ )
+}
+
+function RegisteredTriggers({
+ triggers,
+}: {
+ triggers: FunctionDetail['registered_triggers']
+}) {
+ if (triggers.length === 0) {
+ return (
+
+ · no registered triggers
+
+ )
+ }
+ return (
+
+
+ registered triggers · {triggers.length}
+
+
+
+ )
+}
+
+function TriggerConfig({ config }: { config: unknown }) {
+ if (config === undefined || config === null) return null
+ if (
+ typeof config === 'object' &&
+ Object.keys(config as object).length === 0
+ ) {
+ return (
+ · no config
+ )
+ }
+ let pretty: string
+ try {
+ pretty = JSON.stringify(config, null, 2)
+ } catch {
+ return null
+ }
+ return (
+
+ )
+}
+
+interface SchemaSectionProps {
+ label: string
+ schema: unknown
+ treatEmptyAsAny?: boolean
+}
+
+function SchemaSection({
+ label,
+ schema,
+ treatEmptyAsAny = true,
+}: SchemaSectionProps) {
+ const [open, setOpen] = useState(false)
+ if (schema === undefined || schema === null) {
+ return (
+
+ {label} · none
+
+ )
+ }
+ const isAny = treatEmptyAsAny && isAnySchema(schema)
+ const pretty = tryStringifyJson(schema)
+ return (
+
+
+ {open && pretty != null ? (
+
+ ) : null}
+
+ )
+}
+
+function isAnySchema(value: unknown): boolean {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+ const obj = value as Record
+ // JsonSchema's `AnyValue` placeholder ships `$schema` + `title: "AnyValue"`
+ // with no constraints, which the harness wraps around `Value` request /
+ // response payloads where the engine has no static schema.
+ if (obj.title === 'AnyValue') return true
+ const keys = Object.keys(obj)
+ if (keys.length === 0) return true
+ if (keys.every((k) => k === '$schema' || k === 'title')) return true
+ return false
+}
+
+function tryStringifyJson(value: unknown): string | null {
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch {
+ return null
+ }
+}
+
+function shortenId(id: string): string {
+ if (id.length <= 14) return id
+ return `${id.slice(0, 8)}…${id.slice(-4)}`
+}
diff --git a/console/web/src/components/chat/engine/FunctionsListView.tsx b/console/web/src/components/chat/engine/FunctionsListView.tsx
new file mode 100644
index 00000000..fdeb715b
--- /dev/null
+++ b/console/web/src/components/chat/engine/FunctionsListView.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from 'react'
+import {
+ type FunctionsListRequest,
+ functionsListRequestSchema,
+ functionsListResponseSchema,
+ safeParseRequest,
+ safeParseResponse,
+} from './parsers'
+import { FilterChip, InternalChip, ListHeader } from './shared'
+
+interface FunctionsListViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function FunctionsListView({
+ input,
+ output,
+ running,
+}: FunctionsListViewProps) {
+ const req = safeParseRequest(functionsListRequestSchema, input)
+
+ if (running) {
+ return (
+
+
}
+ />
+
+ · listing functions…
+
+
+ )
+ }
+
+ const resp = safeParseResponse(functionsListResponseSchema, output)
+ if (!resp) return null
+
+ return (
+
+
}
+ />
+ {resp.functions.length === 0 ? (
+
+ · no functions returned
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function RequestFilters({ req }: { req?: FunctionsListRequest }) {
+ if (!req) return null
+ const chips: ReactNode[] = []
+ if (req.prefix) {
+ chips.push()
+ }
+ if (req.worker) {
+ chips.push()
+ }
+ if (req.search) {
+ chips.push()
+ }
+ if (req.include_internal) {
+ chips.push()
+ }
+ return chips.length > 0 ? <>{chips}> : null
+}
diff --git a/console/web/src/components/chat/engine/RegisteredTriggersListView.tsx b/console/web/src/components/chat/engine/RegisteredTriggersListView.tsx
new file mode 100644
index 00000000..28dd79d8
--- /dev/null
+++ b/console/web/src/components/chat/engine/RegisteredTriggersListView.tsx
@@ -0,0 +1,153 @@
+import type { ReactNode } from 'react'
+import { CodeHighlight } from '@/lib/syntax'
+import {
+ type RegisteredTriggersListRequest,
+ registeredTriggersListRequestSchema,
+ registeredTriggersListResponseSchema,
+ safeParseRequest,
+ safeParseResponse,
+} from './parsers'
+import { FilterChip, InternalChip, ListHeader } from './shared'
+
+interface RegisteredTriggersListViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function RegisteredTriggersListView({
+ input,
+ output,
+ running,
+}: RegisteredTriggersListViewProps) {
+ const req = safeParseRequest(registeredTriggersListRequestSchema, input)
+
+ if (running) {
+ return (
+
+
}
+ />
+
+ · listing registered triggers…
+
+
+ )
+ }
+
+ const resp = safeParseResponse(registeredTriggersListResponseSchema, output)
+ if (!resp) return null
+
+ return (
+
+
}
+ />
+ {resp.registered_triggers.length === 0 ? (
+
+ · no registered triggers returned
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function shortenId(id: string): string {
+ if (id.length <= 14) return id
+ return `${id.slice(0, 8)}…${id.slice(-4)}`
+}
+
+function ConfigSummary({ text }: { text: string }) {
+ if (!text) return null
+ const trimmed = text.trim()
+ if (!trimmed) return null
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+ return (
+
+ )
+ }
+ return (
+
+ {trimmed}
+
+ )
+}
+
+function prettyJsonIfPossible(text: string): string {
+ try {
+ return JSON.stringify(JSON.parse(text), null, 2)
+ } catch {
+ return text
+ }
+}
+
+function RequestFilters({ req }: { req?: RegisteredTriggersListRequest }) {
+ if (!req) return null
+ const chips: ReactNode[] = []
+ if (req.function_id) {
+ chips.push(
+ ,
+ )
+ }
+ if (req.trigger_type) {
+ chips.push(
+ ,
+ )
+ }
+ if (req.worker) {
+ chips.push()
+ }
+ if (req.search) {
+ chips.push()
+ }
+ if (req.include_internal) {
+ chips.push()
+ }
+ return chips.length > 0 ? <>{chips}> : null
+}
diff --git a/console/web/src/components/chat/engine/TriggersListView.tsx b/console/web/src/components/chat/engine/TriggersListView.tsx
new file mode 100644
index 00000000..1085a101
--- /dev/null
+++ b/console/web/src/components/chat/engine/TriggersListView.tsx
@@ -0,0 +1,96 @@
+import type { ReactNode } from 'react'
+import {
+ safeParseRequest,
+ safeParseResponse,
+ type TriggersListRequest,
+ triggersListRequestSchema,
+ triggersListResponseSchema,
+} from './parsers'
+import { FilterChip, InternalChip, ListHeader } from './shared'
+
+interface TriggersListViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function TriggersListView({
+ input,
+ output,
+ running,
+}: TriggersListViewProps) {
+ const req = safeParseRequest(triggersListRequestSchema, input)
+
+ if (running) {
+ return (
+
+
}
+ />
+
+ · listing triggers…
+
+
+ )
+ }
+
+ const resp = safeParseResponse(triggersListResponseSchema, output)
+ if (!resp) return null
+
+ return (
+
+
}
+ />
+ {resp.triggers.length === 0 ? (
+
+ · no triggers returned
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function RequestFilters({ req }: { req?: TriggersListRequest }) {
+ if (!req) return null
+ const chips: ReactNode[] = []
+ if (req.prefix) {
+ chips.push()
+ }
+ if (req.worker) {
+ chips.push()
+ }
+ if (req.search) {
+ chips.push()
+ }
+ if (req.include_internal) {
+ chips.push()
+ }
+ return chips.length > 0 ? <>{chips}> : null
+}
diff --git a/console/web/src/components/chat/engine/WorkerInfoView.tsx b/console/web/src/components/chat/engine/WorkerInfoView.tsx
new file mode 100644
index 00000000..56a5a060
--- /dev/null
+++ b/console/web/src/components/chat/engine/WorkerInfoView.tsx
@@ -0,0 +1,275 @@
+import {
+ ActionLine,
+ Chip,
+ MetaRow,
+ StatusPill,
+} from '@/components/chat/sandbox/shared'
+import {
+ safeParseRequest,
+ safeParseResponse,
+ type WorkerInfoResponse,
+ type WorkerMetrics,
+ workerInfoRequestSchema,
+ workerInfoResponseSchema,
+} from './parsers'
+
+interface WorkerInfoViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function WorkerInfoView({
+ input,
+ output,
+ running,
+}: WorkerInfoViewProps) {
+ const req = safeParseRequest(workerInfoRequestSchema, input)
+
+ if (running) {
+ return (
+
+
+
+ {req ? (
+
+
+ worker
+
+ {req.name}
+
+ ) : null}
+
+
+ · inspecting worker…
+
+
+ )
+ }
+
+ const resp = safeParseResponse(workerInfoResponseSchema, output)
+ if (!resp) return null
+
+ const { worker, functions, trigger_types, registered_triggers } = resp
+
+ return (
+
+
+
+ {worker.runtime ? (
+
+
+ runtime
+
+ {worker.runtime}
+
+ ) : null}
+ {worker.version ? (
+
+
+ version
+
+ {worker.version}
+
+ ) : null}
+ {worker.os ? (
+
+
+ os
+
+ {worker.os}
+
+ ) : null}
+ {worker.internal ? (
+
+
+ internal
+
+
+ ) : null}
+
+
+
+ {worker.name ?? worker.id}
+
+
+ {worker.description ? (
+
+ {worker.description}
+
+ ) : null}
+
+ {worker.latest_metrics ? (
+
+ ) : null}
+
({
+ key: fn.function_id,
+ left: fn.function_id,
+ right: fn.description ?? null,
+ }))}
+ />
+ ({
+ key: t.id,
+ left: t.id,
+ right: t.description,
+ }))}
+ />
+ ({
+ key: t.id,
+ left: `${t.trigger_type} → ${t.function_id}`,
+ right: t.config_summary,
+ }))}
+ />
+
+ )
+}
+
+function IdentityRow({ worker }: { worker: WorkerInfoResponse['worker'] }) {
+ const bits: string[] = []
+ bits.push(`id: ${worker.id}`)
+ if (worker.ip_address) bits.push(worker.ip_address)
+ if (worker.isolation) bits.push(`isolation: ${worker.isolation}`)
+ if (typeof worker.pid === 'number') bits.push(`pid: ${worker.pid}`)
+ bits.push(`active: ${worker.active_invocations}`)
+ bits.push(`fns: ${worker.function_count}`)
+ bits.push(formatConnectedFor(worker.connected_at_ms))
+ return (
+
+ {bits.map((b) => (
+ {b}
+ ))}
+
+ )
+}
+
+function MetricsRow({ metrics }: { metrics: WorkerMetrics }) {
+ const cells: { key: string; label: string; value: string }[] = []
+ if (typeof metrics.memory_heap_used === 'number') {
+ cells.push({
+ key: 'heap',
+ label: 'heap',
+ value: formatBytes(metrics.memory_heap_used),
+ })
+ }
+ if (typeof metrics.memory_rss === 'number') {
+ cells.push({
+ key: 'rss',
+ label: 'rss',
+ value: formatBytes(metrics.memory_rss),
+ })
+ }
+ if (typeof metrics.cpu_percent === 'number') {
+ cells.push({
+ key: 'cpu',
+ label: 'cpu',
+ value: `${metrics.cpu_percent.toFixed(1)}%`,
+ })
+ }
+ if (typeof metrics.event_loop_lag_ms === 'number') {
+ cells.push({
+ key: 'lag',
+ label: 'loop lag',
+ value: `${metrics.event_loop_lag_ms.toFixed(1)}ms`,
+ })
+ }
+ if (typeof metrics.uptime_seconds === 'number') {
+ cells.push({
+ key: 'uptime',
+ label: 'uptime',
+ value: formatUptime(metrics.uptime_seconds),
+ })
+ }
+ if (cells.length === 0) return null
+ return (
+
+ {cells.map((c) => (
+
+
+ {c.label}
+
+ {c.value}
+
+ ))}
+
+ )
+}
+
+interface SubListProps {
+ title: string
+ empty: string
+ items: { key: string; left: string; right: string | null }[]
+}
+
+function SubList({ title, empty, items }: SubListProps) {
+ return (
+
+
+ {title} · {items.length}
+
+ {items.length === 0 ? (
+
+ · {empty}
+
+ ) : (
+
+ {items.map((it) => (
+ -
+
+ {it.left}
+
+ {it.right ? (
+
+ {it.right}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ )
+}
+
+function statusVariant(
+ status: string,
+): 'accent' | 'default' | 'warn' | 'alert' {
+ const s = status.toLowerCase()
+ if (s === 'connected' || s === 'active' || s === 'ready') return 'accent'
+ if (s === 'disconnected' || s === 'stopped' || s === 'down') return 'default'
+ return 'warn'
+}
+
+function formatConnectedFor(connectedAtMs: number): string {
+ const ms = Math.max(0, Date.now() - connectedAtMs)
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s up`
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m up`
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h up`
+ return `${Math.floor(ms / 86_400_000)}d up`
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes}B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
+ if (bytes < 1024 * 1024 * 1024)
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`
+}
+
+function formatUptime(seconds: number): string {
+ if (seconds < 60) return `${seconds}s`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
+ return `${Math.floor(seconds / 86400)}d`
+}
diff --git a/console/web/src/components/chat/engine/WorkersListView.tsx b/console/web/src/components/chat/engine/WorkersListView.tsx
new file mode 100644
index 00000000..a9042f83
--- /dev/null
+++ b/console/web/src/components/chat/engine/WorkersListView.tsx
@@ -0,0 +1,181 @@
+import type { ReactNode } from 'react'
+import {
+ safeParseRequest,
+ safeParseResponse,
+ type WorkerSummary,
+ type WorkersListRequest,
+ workersListRequestSchema,
+ workersListResponseSchema,
+} from './parsers'
+import { FilterChip, ListHeader } from './shared'
+
+interface WorkersListViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function WorkersListView({
+ input,
+ output,
+ running,
+}: WorkersListViewProps) {
+ const req = safeParseRequest(workersListRequestSchema, input)
+
+ if (running) {
+ return (
+
+
}
+ />
+
+ · listing workers…
+
+
+ )
+ }
+
+ const resp = safeParseResponse(workersListResponseSchema, output)
+ if (!resp) return null
+
+ return (
+
+
}
+ />
+ {resp.workers.length === 0 ? (
+
+ · no workers connected
+
+ ) : (
+
+ {resp.workers.map((w) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function WorkerRow({ worker }: { worker: WorkerSummary }) {
+ return (
+
+
+
+ {worker.name ?? worker.id}
+
+
+ {worker.runtime ? (
+
+
+ runtime
+
+ {worker.runtime}
+
+ ) : null}
+ {worker.os ? (
+
+
+ os
+
+ {worker.os}
+
+ ) : null}
+ {worker.isolation ? (
+
+
+ isolation
+
+ {worker.isolation}
+
+ ) : null}
+
+
+ id: {shortenId(worker.id)}
+ {worker.version ? · v{worker.version} : null}
+
+ · {worker.function_count}{' '}
+ {worker.function_count === 1 ? 'fn' : 'fns'}
+
+ {worker.active_invocations > 0 ? (
+
+ · {worker.active_invocations}{' '}
+ active
+
+ ) : null}
+ {worker.ip_address ? · {worker.ip_address} : null}
+ · {formatConnectedFor(worker.connected_at_ms)}
+
+ {worker.description ? (
+
+ {worker.description}
+
+ ) : null}
+
+ )
+}
+
+function StatusBadge({ status }: { status: string }) {
+ const variant = statusToVariant(status)
+ return (
+
+ {status}
+
+ )
+}
+
+function statusToVariant(status: string): string {
+ const s = status.toLowerCase()
+ if (s === 'connected' || s === 'active' || s === 'ready') {
+ return 'text-accent border-accent/40 bg-paper-2'
+ }
+ if (s === 'disconnected' || s === 'stopped' || s === 'down') {
+ return 'text-ink-faint border-rule-2 bg-paper-2'
+ }
+ return 'text-warn border-warn/40 bg-paper-2'
+}
+
+function SmallChip({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function shortenId(id: string): string {
+ if (id.length <= 14) return id
+ return `${id.slice(0, 8)}…${id.slice(-4)}`
+}
+
+function formatConnectedFor(connectedAtMs: number): string {
+ const now = Date.now()
+ const ms = Math.max(0, now - connectedAtMs)
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s up`
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m up`
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h up`
+ return `${Math.floor(ms / 86_400_000)}d up`
+}
+
+function RequestFilters({ req }: { req?: WorkersListRequest }) {
+ if (!req) return null
+ const chips: ReactNode[] = []
+ if (req.runtime) {
+ chips.push()
+ }
+ if (req.status) {
+ chips.push()
+ }
+ if (req.search) {
+ chips.push()
+ }
+ return chips.length > 0 ? <>{chips}> : null
+}
diff --git a/console/web/src/components/chat/engine/WorkersRegisterView.tsx b/console/web/src/components/chat/engine/WorkersRegisterView.tsx
new file mode 100644
index 00000000..662cd4c6
--- /dev/null
+++ b/console/web/src/components/chat/engine/WorkersRegisterView.tsx
@@ -0,0 +1,95 @@
+import {
+ ActionLine,
+ Chip,
+ MetaRow,
+ StatusPill,
+} from '@/components/chat/sandbox/shared'
+import {
+ safeParseRequest,
+ safeParseResponse,
+ workersRegisterRequestSchema,
+ workersRegisterResponseSchema,
+} from './parsers'
+
+interface WorkersRegisterViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function WorkersRegisterView({
+ input,
+ output,
+ running,
+}: WorkersRegisterViewProps) {
+ const req = safeParseRequest(workersRegisterRequestSchema, input)
+ if (!req) return null
+
+ const headerLabel = running
+ ? 'registering…'
+ : (() => {
+ const resp = safeParseResponse(workersRegisterResponseSchema, output)
+ if (resp == null) return 'register failed'
+ return resp.success ? 'registered' : 'register failed'
+ })()
+ const headerVariant: 'accent' | 'warn' | 'default' = running
+ ? 'default'
+ : headerLabel === 'registered'
+ ? 'accent'
+ : 'warn'
+
+ return (
+
+
+
+ {req.runtime ? (
+
+
+ runtime
+
+ {req.runtime}
+
+ ) : null}
+ {req.version ? (
+
+
+ version
+
+ {req.version}
+
+ ) : null}
+ {req.os ? (
+
+
+ os
+
+ {req.os}
+
+ ) : null}
+
+
+
+ {req.name ?? req._caller_worker_id}
+
+
+
+
+ id: {shortenId(req._caller_worker_id)}
+
+ {req.telemetry?.install_kind ? (
+ install: {req.telemetry.install_kind}
+ ) : null}
+ {req.telemetry?.device_id ? (
+
+ device: {shortenId(req.telemetry.device_id)}
+
+ ) : null}
+
+
+ )
+}
+
+function shortenId(id: string): string {
+ if (id.length <= 14) return id
+ return `${id.slice(0, 8)}…${id.slice(-4)}`
+}
diff --git a/console/web/src/components/chat/engine/__tests__/parsers.test.ts b/console/web/src/components/chat/engine/__tests__/parsers.test.ts
new file mode 100644
index 00000000..50a37341
--- /dev/null
+++ b/console/web/src/components/chat/engine/__tests__/parsers.test.ts
@@ -0,0 +1,361 @@
+import { describe, expect, it } from 'vitest'
+import {
+ ENGINE_FUNCTION_IDS,
+ functionDetailSchema,
+ functionInfoRequestSchema,
+ functionsListRequestSchema,
+ functionsListResponseSchema,
+ isEngineListFunction,
+ registeredTriggersListRequestSchema,
+ registeredTriggersListResponseSchema,
+ safeParseRequest,
+ safeParseResponse,
+ triggersListRequestSchema,
+ triggersListResponseSchema,
+ unwrapEnvelope,
+ workerInfoRequestSchema,
+ workerInfoResponseSchema,
+ workersListRequestSchema,
+ workersListResponseSchema,
+ workersRegisterRequestSchema,
+ workersRegisterResponseSchema,
+} from '../parsers'
+
+function wrap(details: T) {
+ return {
+ content: [{ type: 'text', text: JSON.stringify(details) }],
+ details,
+ terminate: false,
+ }
+}
+
+describe('isEngineListFunction', () => {
+ it('matches every id in the explicit allowlist', () => {
+ for (const id of ENGINE_FUNCTION_IDS) {
+ expect(isEngineListFunction(id)).toBe(true)
+ }
+ })
+
+ it('rejects unrelated ids', () => {
+ expect(isEngineListFunction('engine::functions')).toBe(false)
+ expect(isEngineListFunction('sandbox::exec')).toBe(false)
+ expect(isEngineListFunction('engine::triggers::create')).toBe(false)
+ })
+})
+
+describe('engine::functions::list', () => {
+ it('parses an empty request payload', () => {
+ const req = safeParseRequest(functionsListRequestSchema, {})
+ expect(req).toEqual({})
+ })
+
+ it('parses a filter-applied request', () => {
+ const req = safeParseRequest(functionsListRequestSchema, {
+ prefix: 'todo::',
+ include_internal: true,
+ })
+ expect(req).toEqual({ prefix: 'todo::', include_internal: true })
+ })
+
+ it('parses a raw response payload', () => {
+ const resp = safeParseResponse(functionsListResponseSchema, {
+ functions: [
+ {
+ function_id: 'todo::list',
+ worker_name: 'todo-app',
+ description: 'List all todos',
+ },
+ ],
+ })
+ expect(resp?.functions).toHaveLength(1)
+ expect(resp?.functions[0].function_id).toBe('todo::list')
+ })
+
+ it('parses a harness-wrapped response payload', () => {
+ const payload = {
+ functions: [
+ { function_id: 'todo::create', worker_name: 'todo-app' },
+ {
+ function_id: 'todo::delete',
+ worker_name: 'todo-app',
+ description: null,
+ },
+ ],
+ }
+ const resp = safeParseResponse(functionsListResponseSchema, wrap(payload))
+ expect(resp?.functions).toHaveLength(2)
+ })
+
+ it('parses an empty list', () => {
+ expect(
+ safeParseResponse(functionsListResponseSchema, wrap({ functions: [] })),
+ ).toEqual({ functions: [] })
+ })
+})
+
+describe('engine::functions::info', () => {
+ it('parses the request payload', () => {
+ expect(
+ safeParseRequest(functionInfoRequestSchema, {
+ function_id: 'sandbox::fs::write',
+ }),
+ ).toEqual({ function_id: 'sandbox::fs::write' })
+ })
+
+ it('rejects a request missing function_id', () => {
+ expect(safeParseRequest(functionInfoRequestSchema, {})).toBeNull()
+ })
+
+ it('parses a wrapped AnyValue-schema detail (mirrors the screenshot)', () => {
+ const detail = {
+ description: 'Stream-upload a file into a sandbox',
+ function_id: 'sandbox::fs::write',
+ registered_triggers: [],
+ request_schema: {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ title: 'AnyValue',
+ },
+ response_schema: {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ title: 'AnyValue',
+ },
+ worker_name: 'iii-sandbox',
+ }
+ const parsed = safeParseResponse(functionDetailSchema, wrap(detail))
+ expect(parsed?.function_id).toBe('sandbox::fs::write')
+ expect(parsed?.registered_triggers).toEqual([])
+ })
+
+ it('parses a rich payload with registered triggers + schemas', () => {
+ const detail = {
+ function_id: 'todo::create',
+ worker_name: 'todo-app',
+ description: 'Create a new todo',
+ request_schema: { type: 'object' },
+ response_schema: { type: 'object' },
+ registered_triggers: [
+ {
+ id: '8c7e9d12-2a4f-4b5c-9d3e-1f2a3b4c5d6e',
+ trigger_type: 'http',
+ config: { api_path: '/todos', http_method: 'POST' },
+ },
+ ],
+ }
+ const parsed = safeParseResponse(functionDetailSchema, detail)
+ expect(parsed?.registered_triggers).toHaveLength(1)
+ expect(parsed?.registered_triggers[0].trigger_type).toBe('http')
+ })
+
+ it('rejects a payload missing registered_triggers', () => {
+ const parsed = safeParseResponse(functionDetailSchema, {
+ function_id: 'x',
+ worker_name: 'y',
+ })
+ expect(parsed).toBeNull()
+ })
+})
+
+describe('engine::triggers::list', () => {
+ it('parses a wrapped success payload', () => {
+ const payload = {
+ triggers: [
+ { id: 'http', worker_name: 'http', description: 'HTTP API trigger' },
+ {
+ id: 'log',
+ worker_name: 'log',
+ description: 'Log event trigger',
+ },
+ ],
+ }
+ const resp = safeParseResponse(triggersListResponseSchema, wrap(payload))
+ expect(resp?.triggers).toHaveLength(2)
+ })
+
+ it('parses a search-applied request', () => {
+ expect(
+ safeParseRequest(triggersListRequestSchema, { search: 'http' }),
+ ).toEqual({ search: 'http' })
+ })
+
+ it('rejects payloads missing the `triggers` array', () => {
+ const resp = safeParseResponse(triggersListResponseSchema, { foo: [] })
+ expect(resp).toBeNull()
+ })
+})
+
+describe('engine::registered-triggers::list', () => {
+ it('parses a wrapped registered-triggers payload', () => {
+ const payload = {
+ registered_triggers: [
+ {
+ id: '6ebe7d64-3717-4acc-a02d-3f050fb86df2',
+ trigger_type: 'http',
+ function_id: 'todo::html',
+ worker_name: 'todo-app',
+ config_summary: '{"api_path":"/todos/html","http_method":"GET"}',
+ },
+ ],
+ }
+ const resp = safeParseResponse(
+ registeredTriggersListResponseSchema,
+ wrap(payload),
+ )
+ expect(resp?.registered_triggers).toHaveLength(1)
+ })
+
+ it('parses a function_id filter request', () => {
+ expect(
+ safeParseRequest(registeredTriggersListRequestSchema, {
+ function_id: 'todo::html',
+ }),
+ ).toEqual({ function_id: 'todo::html' })
+ })
+})
+
+describe('engine::workers::list', () => {
+ it('parses an empty request payload', () => {
+ expect(safeParseRequest(workersListRequestSchema, {})).toEqual({})
+ })
+
+ it('parses a runtime+status filter', () => {
+ expect(
+ safeParseRequest(workersListRequestSchema, {
+ runtime: 'rust',
+ status: 'connected',
+ }),
+ ).toEqual({ runtime: 'rust', status: 'connected' })
+ })
+
+ it('parses a wrapped success payload', () => {
+ const payload = {
+ workers: [
+ {
+ id: 'w-1',
+ name: 'todo-app',
+ description: null,
+ version: '0.4.7',
+ runtime: 'node',
+ os: 'darwin',
+ status: 'connected',
+ function_count: 6,
+ connected_at_ms: 1_700_000_000_000,
+ active_invocations: 0,
+ isolation: 'process',
+ ip_address: '127.0.0.1',
+ },
+ ],
+ }
+ const parsed = safeParseResponse(workersListResponseSchema, wrap(payload))
+ expect(parsed?.workers).toHaveLength(1)
+ expect(parsed?.workers[0].runtime).toBe('node')
+ })
+
+ it('parses an empty workers list', () => {
+ expect(
+ safeParseResponse(workersListResponseSchema, { workers: [] }),
+ ).toEqual({ workers: [] })
+ })
+})
+
+describe('engine::workers::info', () => {
+ it('parses the request payload', () => {
+ expect(
+ safeParseRequest(workerInfoRequestSchema, { name: 'todo-app' }),
+ ).toEqual({ name: 'todo-app' })
+ })
+
+ it('rejects a request missing name', () => {
+ expect(safeParseRequest(workerInfoRequestSchema, {})).toBeNull()
+ })
+
+ it('parses a wrapped full detail payload', () => {
+ const payload = {
+ worker: {
+ id: 'w-1',
+ name: 'todo-app',
+ description: 'demo',
+ version: '0.4.7',
+ runtime: 'node',
+ os: 'darwin',
+ status: 'connected',
+ function_count: 1,
+ connected_at_ms: 1_700_000_000_000,
+ active_invocations: 0,
+ isolation: 'process',
+ ip_address: null,
+ internal: false,
+ latest_metrics: {
+ memory_heap_used: 1024,
+ cpu_percent: 1.2,
+ timestamp_ms: 1_700_000_001_000,
+ runtime: 'node',
+ },
+ },
+ functions: [{ function_id: 'todo::list', worker_name: 'todo-app' }],
+ trigger_types: [],
+ registered_triggers: [],
+ }
+ const parsed = safeParseResponse(workerInfoResponseSchema, wrap(payload))
+ expect(parsed?.worker.internal).toBe(false)
+ expect(parsed?.functions).toHaveLength(1)
+ expect(parsed?.worker.latest_metrics?.cpu_percent).toBe(1.2)
+ })
+
+ it('parses an internal worker without optional fields', () => {
+ const parsed = safeParseResponse(workerInfoResponseSchema, {
+ worker: {
+ id: 'engine-fns',
+ name: 'iii-engine-functions',
+ description: null,
+ version: null,
+ runtime: 'rust',
+ os: 'darwin',
+ status: 'connected',
+ function_count: 7,
+ connected_at_ms: 0,
+ active_invocations: 0,
+ isolation: 'embedded',
+ ip_address: null,
+ internal: true,
+ },
+ functions: [],
+ trigger_types: [],
+ registered_triggers: [],
+ })
+ expect(parsed?.worker.internal).toBe(true)
+ })
+})
+
+describe('engine::workers::register', () => {
+ it('parses a full request with telemetry', () => {
+ const parsed = safeParseRequest(workersRegisterRequestSchema, {
+ _caller_worker_id: 'w-1',
+ name: 'todo-app',
+ runtime: 'node',
+ version: '0.4.7',
+ os: 'darwin',
+ telemetry: { device_id: 'dev-1', install_kind: 'npm' },
+ })
+ expect(parsed?._caller_worker_id).toBe('w-1')
+ expect(parsed?.telemetry?.install_kind).toBe('npm')
+ })
+
+ it('rejects a request missing the _caller_worker_id', () => {
+ expect(
+ safeParseRequest(workersRegisterRequestSchema, { name: 'no-id' }),
+ ).toBeNull()
+ })
+
+ it('parses the success envelope', () => {
+ expect(
+ safeParseResponse(workersRegisterResponseSchema, wrap({ success: true })),
+ ).toEqual({ success: true })
+ })
+})
+
+describe('unwrapEnvelope re-export', () => {
+ it('peels the harness envelope', () => {
+ const inner = { functions: [] }
+ expect(unwrapEnvelope(wrap(inner))).toEqual(inner)
+ })
+})
diff --git a/console/web/src/components/chat/engine/index.tsx b/console/web/src/components/chat/engine/index.tsx
new file mode 100644
index 00000000..99916212
--- /dev/null
+++ b/console/web/src/components/chat/engine/index.tsx
@@ -0,0 +1,98 @@
+import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView'
+import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers'
+import type { FunctionCallMessage } from '@/types/chat'
+import { FunctionInfoView } from './FunctionInfoView'
+import { FunctionsListView } from './FunctionsListView'
+import { isEngineListFunction, unwrapEnvelope } from './parsers'
+import { RegisteredTriggersListView } from './RegisteredTriggersListView'
+import { TriggersListView } from './TriggersListView'
+import { WorkerInfoView } from './WorkerInfoView'
+import { WorkersListView } from './WorkersListView'
+import { WorkersRegisterView } from './WorkersRegisterView'
+
+/**
+ * Header label for `engine::*::list` ids. Mirrors `SandboxFunctionIdLabel`:
+ * dims the namespace prefix so the tail is readable in the FCM header.
+ */
+export function EngineFunctionIdLabel({ functionId }: { functionId: string }) {
+ if (!functionId.startsWith('engine::')) {
+ return {functionId}
+ }
+ const tail = functionId.slice('engine::'.length)
+ return (
+ <>
+ engine::
+ {tail}
+ >
+ )
+}
+
+function tryRender(message: FunctionCallMessage): React.ReactNode | null {
+ if (!isEngineListFunction(message.functionId)) return null
+ if (message.pendingApproval) return null
+
+ const input = unwrapEnvelope(message.input)
+ const rawOutput = message.output
+ const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined
+ const running = !!message.running
+
+ // Reuse the sandbox error parser for gate/transport-level errors
+ // — the `function_error` envelope is shared infra, not sandbox-specific.
+ const errorDisplay =
+ !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null
+ if (errorDisplay) {
+ return
+ }
+
+ switch (message.functionId) {
+ case 'engine::functions::list':
+ return (
+
+ )
+ case 'engine::functions::info':
+ return (
+
+ )
+ case 'engine::triggers::list':
+ return (
+
+ )
+ case 'engine::registered-triggers::list':
+ return (
+
+ )
+ case 'engine::workers::list':
+ return
+ case 'engine::workers::info':
+ return
+ case 'engine::workers::register':
+ return (
+
+ )
+ default:
+ return null
+ }
+}
+
+/**
+ * Engine list calls are read-only and don't go through the approval gate,
+ * so there's nothing meaningful to preview. Returning `null` lets the
+ * default request JSON pane render if a `pendingApproval` message ever
+ * reaches the renderer.
+ */
+function tryRenderPreview(
+ _message: FunctionCallMessage,
+): React.ReactNode | null {
+ return null
+}
+
+export const EngineToolView = {
+ isEngineListFunction,
+ tryRender,
+ tryRenderRunning: tryRender,
+ tryRenderPreview,
+}
diff --git a/console/web/src/components/chat/engine/parsers.ts b/console/web/src/components/chat/engine/parsers.ts
new file mode 100644
index 00000000..313061fd
--- /dev/null
+++ b/console/web/src/components/chat/engine/parsers.ts
@@ -0,0 +1,258 @@
+/**
+ * Zod schemas + envelope helpers for the three `engine::*::list` catalogue
+ * tools. Mirrors the sandbox `parsers.ts` shape: non-strict schemas (so
+ * unknown fields pass through), a re-exported `unwrapEnvelope`, and
+ * `safeParseRequest` / `safeParseResponse` that unwrap the harness
+ * `{ content, details, terminate }` envelope before parsing.
+ *
+ * Wire source: `motia/engine/src/workers/engine_fn/mod.rs` —
+ * FunctionsListInput / FunctionSummary
+ * TriggersListInput / TriggerTypeSummary
+ * RegisteredTriggersListInput / RegisteredTriggerSummary
+ */
+import { z } from 'zod'
+import { unwrapEnvelope } from '@/components/chat/sandbox/parsers'
+
+export { unwrapEnvelope }
+
+export const ENGINE_FUNCTION_IDS = [
+ 'engine::functions::list',
+ 'engine::functions::info',
+ 'engine::triggers::list',
+ 'engine::registered-triggers::list',
+ 'engine::workers::list',
+ 'engine::workers::info',
+ 'engine::workers::register',
+] as const
+
+export type EngineFunctionId = (typeof ENGINE_FUNCTION_IDS)[number]
+
+const ENGINE_FUNCTION_ID_SET: ReadonlySet = new Set(
+ ENGINE_FUNCTION_IDS,
+)
+
+/**
+ * Predicate for both the list catalogues and the singular `info` lookup.
+ * Name kept for backwards compatibility with the FCM wiring; the family
+ * now covers every renderer in this module.
+ */
+export function isEngineListFunction(id: string): id is EngineFunctionId {
+ return ENGINE_FUNCTION_ID_SET.has(id)
+}
+
+/* ---------------- engine::functions::list ---------------- */
+
+export const functionsListRequestSchema = z.object({
+ search: z.string().optional(),
+ prefix: z.string().optional(),
+ worker: z.string().optional(),
+ include_internal: z.boolean().optional(),
+})
+export type FunctionsListRequest = z.infer
+
+export const functionSummarySchema = z.object({
+ function_id: z.string(),
+ worker_name: z.string(),
+ description: z.string().nullable().optional(),
+})
+export type FunctionSummary = z.infer
+
+export const functionsListResponseSchema = z.object({
+ functions: z.array(functionSummarySchema),
+})
+export type FunctionsListResponse = z.infer
+
+/* ---------------- engine::functions::info ---------------- */
+
+export const functionInfoRequestSchema = z.object({
+ function_id: z.string(),
+})
+export type FunctionInfoRequest = z.infer
+
+/** Inline registered trigger payload from `FunctionDetail`. Different shape
+ * than `RegisteredTriggerSummary` (used by `engine::registered-triggers::list`):
+ * `config` is the raw JSON object, not a stringified `config_summary`. */
+export const registeredTriggerRefSchema = z.object({
+ id: z.string(),
+ trigger_type: z.string(),
+ config: z.unknown(),
+})
+export type RegisteredTriggerRef = z.infer
+
+export const functionDetailSchema = z.object({
+ function_id: z.string(),
+ worker_name: z.string(),
+ description: z.string().nullable().optional(),
+ request_schema: z.unknown().optional(),
+ response_schema: z.unknown().optional(),
+ metadata: z.unknown().optional(),
+ registered_triggers: z.array(registeredTriggerRefSchema),
+})
+export type FunctionDetail = z.infer
+
+/* ---------------- engine::triggers::list ---------------- */
+
+export const triggersListRequestSchema = z.object({
+ search: z.string().optional(),
+ prefix: z.string().optional(),
+ worker: z.string().optional(),
+ include_internal: z.boolean().optional(),
+})
+export type TriggersListRequest = z.infer
+
+export const triggerTypeSummarySchema = z.object({
+ id: z.string(),
+ worker_name: z.string(),
+ description: z.string(),
+})
+export type TriggerTypeSummary = z.infer
+
+export const triggersListResponseSchema = z.object({
+ triggers: z.array(triggerTypeSummarySchema),
+})
+export type TriggersListResponse = z.infer
+
+/* ------------- engine::registered-triggers::list ------------- */
+
+export const registeredTriggersListRequestSchema = z.object({
+ search: z.string().optional(),
+ trigger_type: z.string().optional(),
+ function_id: z.string().optional(),
+ worker: z.string().optional(),
+ include_internal: z.boolean().optional(),
+})
+export type RegisteredTriggersListRequest = z.infer<
+ typeof registeredTriggersListRequestSchema
+>
+
+export const registeredTriggerSummarySchema = z.object({
+ id: z.string(),
+ trigger_type: z.string(),
+ function_id: z.string(),
+ worker_name: z.string(),
+ config_summary: z.string(),
+})
+export type RegisteredTriggerSummary = z.infer<
+ typeof registeredTriggerSummarySchema
+>
+
+export const registeredTriggersListResponseSchema = z.object({
+ registered_triggers: z.array(registeredTriggerSummarySchema),
+})
+export type RegisteredTriggersListResponse = z.infer<
+ typeof registeredTriggersListResponseSchema
+>
+
+/* ---------------- engine::workers::list ---------------- */
+
+export const workersListRequestSchema = z.object({
+ search: z.string().optional(),
+ runtime: z.string().optional(),
+ status: z.string().optional(),
+})
+export type WorkersListRequest = z.infer
+
+export const workerSummarySchema = z.object({
+ id: z.string(),
+ name: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ version: z.string().nullable().optional(),
+ runtime: z.string().nullable().optional(),
+ os: z.string().nullable().optional(),
+ status: z.string(),
+ function_count: z.number(),
+ connected_at_ms: z.number(),
+ active_invocations: z.number(),
+ isolation: z.string().nullable().optional(),
+ ip_address: z.string().nullable().optional(),
+})
+export type WorkerSummary = z.infer
+
+export const workersListResponseSchema = z.object({
+ workers: z.array(workerSummarySchema),
+})
+export type WorkersListResponse = z.infer
+
+/* ---------------- engine::workers::info ---------------- */
+
+export const workerInfoRequestSchema = z.object({
+ name: z.string(),
+})
+export type WorkerInfoRequest = z.infer
+
+export const workerMetricsSchema = z.object({
+ memory_heap_used: z.number().optional(),
+ memory_heap_total: z.number().optional(),
+ memory_rss: z.number().optional(),
+ memory_external: z.number().optional(),
+ cpu_user_micros: z.number().optional(),
+ cpu_system_micros: z.number().optional(),
+ cpu_percent: z.number().optional(),
+ event_loop_lag_ms: z.number().optional(),
+ uptime_seconds: z.number().optional(),
+ timestamp_ms: z.number(),
+ runtime: z.string(),
+})
+export type WorkerMetrics = z.infer
+
+export const workerDetailEnvelopeSchema = workerSummarySchema.extend({
+ pid: z.number().optional(),
+ internal: z.boolean(),
+ latest_metrics: workerMetricsSchema.nullable().optional(),
+})
+export type WorkerDetailEnvelope = z.infer
+
+/** `functions` inside `workers::info` only carries `function_id` +
+ * `worker_name` (no description). The `functionSummarySchema` already
+ * marks `description` optional/nullable, so we reuse it here. */
+export const workerInfoResponseSchema = z.object({
+ worker: workerDetailEnvelopeSchema,
+ functions: z.array(functionSummarySchema),
+ trigger_types: z.array(triggerTypeSummarySchema),
+ registered_triggers: z.array(registeredTriggerSummarySchema),
+})
+export type WorkerInfoResponse = z.infer
+
+/* ---------------- engine::workers::register ---------------- */
+
+export const workerTelemetryMetaSchema = z.object({
+ device_id: z.string().optional(),
+ install_kind: z.string().optional(),
+})
+
+export const workersRegisterRequestSchema = z.object({
+ _caller_worker_id: z.string(),
+ runtime: z.string().nullable().optional(),
+ version: z.string().nullable().optional(),
+ name: z.string().nullable().optional(),
+ os: z.string().nullable().optional(),
+ telemetry: workerTelemetryMetaSchema.nullable().optional(),
+})
+export type WorkersRegisterRequest = z.infer<
+ typeof workersRegisterRequestSchema
+>
+
+export const workersRegisterResponseSchema = z.object({
+ success: z.boolean(),
+})
+export type WorkersRegisterResponse = z.infer<
+ typeof workersRegisterResponseSchema
+>
+
+/* ---------------- generic helpers ---------------- */
+
+export function safeParseRequest(
+ schema: z.ZodType,
+ value: unknown,
+): T | null {
+ const parsed = schema.safeParse(value ?? {})
+ return parsed.success ? parsed.data : null
+}
+
+export function safeParseResponse(
+ schema: z.ZodType,
+ value: unknown,
+): T | null {
+ const parsed = schema.safeParse(unwrapEnvelope(value))
+ return parsed.success ? parsed.data : null
+}
diff --git a/console/web/src/components/chat/engine/shared.tsx b/console/web/src/components/chat/engine/shared.tsx
new file mode 100644
index 00000000..3c9af761
--- /dev/null
+++ b/console/web/src/components/chat/engine/shared.tsx
@@ -0,0 +1,60 @@
+import type { ReactNode } from 'react'
+import { Chip, MetaRow, StatusPill } from '@/components/chat/sandbox/shared'
+
+/**
+ * Header row shared by all three engine list views. Renders a status pill
+ * (count) on the left and a flexible chip row on the right for the active
+ * request filters. Keeps the three list views visually consistent.
+ */
+interface ListHeaderProps {
+ count: number
+ noun: string
+ filters?: ReactNode
+ tone?: 'default' | 'accent' | 'warn'
+}
+
+export function ListHeader({
+ count,
+ noun,
+ filters,
+ tone = 'accent',
+}: ListHeaderProps) {
+ const label =
+ count === 0
+ ? `no ${noun} match`
+ : `${count} ${count === 1 ? noun.replace(/s$/, '') : noun}`
+ const pillVariant: 'default' | 'accent' | 'warn' =
+ count === 0 ? 'warn' : tone === 'accent' ? 'accent' : tone
+ return (
+
+
+ {filters ? (
+ {filters}
+ ) : null}
+
+ )
+}
+
+interface FilterChipProps {
+ label: string
+ value: ReactNode
+}
+
+export function FilterChip({ label, value }: FilterChipProps) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ )
+}
+
+export function InternalChip() {
+ return (
+
+ internal
+
+ )
+}
diff --git a/console/web/src/components/chat/web/FetchView.tsx b/console/web/src/components/chat/web/FetchView.tsx
new file mode 100644
index 00000000..7f8beea1
--- /dev/null
+++ b/console/web/src/components/chat/web/FetchView.tsx
@@ -0,0 +1,366 @@
+import { useState } from 'react'
+import {
+ ActionLine,
+ Chip,
+ MetaRow,
+ StatusPill,
+} from '@/components/chat/sandbox/shared'
+import { JsonHighlight } from '@/lib/syntax'
+import { cn } from '@/lib/utils'
+import {
+ type FetchRequest,
+ type FetchResult,
+ fetchErrorVariantLabel,
+ fetchRequestSchema,
+ fetchResultSchema,
+ safeParseRequest,
+ safeParseResponse,
+} from './parsers'
+
+interface FetchViewProps {
+ input: unknown
+ output: unknown
+ running?: boolean
+}
+
+export function FetchView({ input, output, running }: FetchViewProps) {
+ const req = safeParseRequest(fetchRequestSchema, input)
+ if (!req) return null
+
+ const method = (req.method ?? 'GET').toUpperCase()
+
+ if (running) {
+ return (
+
+
+
+ {method}
+
+
+ {req.url}
+
+
+ · waiting for response…
+
+
+ )
+ }
+
+ const result = safeParseResponse(fetchResultSchema, output)
+ if (!result) return null
+
+ if (!result.ok) {
+ return
+ }
+ return
+}
+
+/** Compact preview rendered while a `web::fetch` call sits in the
+ approval gate. Read-only summary of method + URL + payload counts. */
+export function FetchPreview({ input }: { input: unknown }) {
+ const req = safeParseRequest(fetchRequestSchema, input)
+ if (!req) return null
+ const method = (req.method ?? 'GET').toUpperCase()
+ const headerCount = req.headers ? Object.keys(req.headers).length : 0
+ const hasBody = req.body != null || req.json !== undefined
+ return (
+
+
+
+ {method}
+ {req.response_format ? (
+
+
+ format
+
+ {req.response_format}
+
+ ) : null}
+
+
+ {req.url}
+
+
+
+
+ headers
+
+ {headerCount}
+
+ {hasBody ? (
+
+
+ body
+
+
+ {req.json !== undefined ? 'json' : 'text'}
+
+
+ ) : null}
+ {typeof req.timeout_ms === 'number' ? (
+
+
+ timeout
+
+
+ {req.timeout_ms}ms
+
+
+ ) : null}
+ {req.follow_redirects === false ? (
+
+
+ no-redirect
+
+
+ ) : null}
+
+
+ )
+}
+
+/* ---------------- success pane ---------------- */
+
+interface FetchSuccessPaneProps {
+ req: FetchRequest
+ method: string
+ result: Extract
+}
+
+function FetchSuccessPane({ req, method, result }: FetchSuccessPaneProps) {
+ const contentType = result.headers['content-type']
+ const statusVariant = statusToVariant(result.status)
+ return (
+
+
+
+ {method}
+ {contentType ? (
+
+
+ type
+
+ {contentType.split(';')[0]}
+
+ ) : null}
+
+
+ format
+
+ {result.response_format}
+
+ {result.bytes_truncated ? (
+
+ truncated
+
+ ) : null}
+ {result.redirect_chain && result.redirect_chain.length > 0 ? (
+
+
+ redirects
+
+
+ {result.redirect_chain.length}
+
+
+ ) : null}
+
+
+ {req.url}
+
+ {result.parse_error ? (
+
+ parse error: {result.parse_error}
+
+ ) : null}
+ {result.redirect_chain && result.redirect_chain.length > 0 ? (
+
+ ) : null}
+
+
+