browser
+{countLabel} sessions
+select a session
+
+
{renderWithHighlight(tree, '\\[ref=[^\\]]*\\]', {
isRegex: true,
@@ -75,7 +69,7 @@ export function SnapshotView({ output }: { output: unknown }) {
{res.truncated ? : null}
- {res.url}
+ {res.url}
@@ -95,7 +89,7 @@ export function SessionStartView({ output }: { output: unknown }) {
{res.headless ? 'headless' : 'headful'}
- {res.url}
+ {res.url}
)
@@ -126,25 +120,20 @@ export function SessionListView({ output }: { output: unknown }) {
/>
{res.sessions.length === 0 ? (
-
- · no live sessions
-
+ · no live sessions
) : (
-
+
{res.sessions.map((s) => (
-
-
+
+
{s.session_id}
- {s.url}
-
+ {s.url}
+
{s.headless ? 'headless' : 'headful'}
-
+
{s.console_entries} logs
@@ -172,7 +161,7 @@ export function NavigateView({ output }: { output: unknown }) {
{res.title ? {truncate(res.title, 60)} : null}
- {res.url}
+ {res.url}
)
@@ -204,10 +193,10 @@ export function ActView({
variant={res.ok ? 'accent' : 'alert'}
/>
{req?.action ? {req.action} : null}
- {req?.ref ? {req.ref} : null}
+ {req?.ref ? {req.ref} : null}
{req?.key ? {req.key} : null}
{req?.x != null && req?.y != null ? (
-
+
{Math.round(req.x)},{Math.round(req.y)}
) : null}
@@ -243,7 +232,7 @@ export function HistoryView({
) : null}
- {res.url}
+ {res.url}
)
@@ -261,20 +250,15 @@ const readInputSchema = z.object({
export function ConsoleEntryRow({ entry }: { entry: BrowserConsoleEntry }) {
return (
-
-
- {formatTime(entry.timestamp)}
-
-
+
+ {formatTime(entry.timestamp)}
+
{entry.level}
-
+
{entry.text}
{entry.source ? (
- · {entry.source}
+ · {entry.source}
) : null}
@@ -301,17 +285,13 @@ export function ConsoleReadView({
{req?.level ? {req.level} : null}
{req?.pattern ? /{req.pattern}/ : null}
{res.dropped > 0 ? (
-
- {res.dropped} dropped
-
+ {res.dropped} dropped
) : null}
{res.entries.length === 0 ? (
-
- · no matching console entries
-
+ · no matching console entries
) : (
-
+
{res.entries.map((entry) => (
))}
@@ -323,25 +303,17 @@ export function ConsoleReadView({
export function NetworkEntryRow({ entry }: { entry: BrowserNetworkEntry }) {
return (
- -
+
-
{entry.status ?? (entry.failed ? 'err' : '...')}
- {entry.method}
-
+ {entry.method}
+
{entry.url}
{entry.error ? (
- · {entry.error}
+ · {entry.error}
) : null}
@@ -366,21 +338,17 @@ export function NetworkReadView({
variant={res.entries.length > 0 ? 'accent' : 'default'}
/>
{req?.failed_only ? (
- failed only
+ failed only
) : null}
{req?.pattern ? /{req.pattern}/ : null}
{res.dropped > 0 ? (
-
- {res.dropped} dropped
-
+ {res.dropped} dropped
) : null}
{res.entries.length === 0 ? (
-
- · no matching requests
-
+ · no matching requests
) : (
-
+
{res.entries.map((entry) => (
))}
@@ -402,29 +370,24 @@ export function StylesReadView({ output }: { output: unknown }) {
label={`${res.properties.length} properties`}
variant="accent"
/>
- {res.ref}
+ {res.ref}
-
-
+
+
{res.properties.map((prop) => (
-
-
+
+
{prop.name}
- {prop.value}
+ {prop.value}
))}
{res.inline_style ? (
-
- style="{res.inline_style}"
-
+ style="{res.inline_style}"
) : null}
)
@@ -453,16 +416,14 @@ export function StylesWriteView({
label={res.ok ? 'applied' : 'failed'}
variant={res.ok ? 'accent' : 'alert'}
/>
- {req?.ref ? {req.ref} : null}
+ {req?.ref ? {req.ref} : null}
{req?.property ? (
{req.property}: {req.value ?? ''}
) : null}
-
- style="{res.inline_style}"
-
+ style="{res.inline_style}"
)
}
@@ -490,25 +451,25 @@ export function DomReadView({ output }: { output: unknown }) {
{res.truncated ? : null}
-
+
{rows.map(({ node, depth }) => (
{node.tag === '#text' ? (
-
+
"{truncate(node.text ?? '', 80)}"
) : (
-
+
{elementLabel(node.tag, node.id, node.classes)}
)}{' '}
- [{node.ref}]
+ [{node.ref}]
{node.child_count > node.children.length ? (
-
+
{' '}
+{node.child_count - node.children.length} more
@@ -544,25 +505,19 @@ export function EvaluateView({
{req?.expression ? (
- {truncate(req.expression, 200)}
+ {truncate(req.expression, 200)}
) : null}
{res.ok ? (
res.value === undefined ? (
-
- · undefined
-
+ · undefined
) : (
-
-
+
+
)
) : (
-
- {res.error ?? 'evaluation failed'}
-
+ {res.error ?? 'evaluation failed'}
)}
)
diff --git a/console/web/src/components/chat/browser/index.tsx b/browser/ui/src/function-trigger-message/index.tsx
similarity index 60%
rename from console/web/src/components/chat/browser/index.tsx
rename to browser/ui/src/function-trigger-message/index.tsx
index c39c4ed92..d405de7fe 100644
--- a/console/web/src/components/chat/browser/index.tsx
+++ b/browser/ui/src/function-trigger-message/index.tsx
@@ -1,14 +1,26 @@
-import { SquareArrowOutUpRight } from 'lucide-react'
-import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView'
-import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers'
-import { hashForBrowserSession } from '@/hooks/use-hash-route'
+/**
+ * Injected function-trigger renderer for every `browser::*` call —
+ * registered through `host.functionTriggers`, so it dispatches BEFORE the
+ * console's built-in families and owns how browser calls render in chat and
+ * in the traces span tab. Ported from the console's `components/chat/browser`
+ * family; the "open in browser tab" link now points at the injected page
+ * (`#/ext/browser`), and shared-infra errors render through the ported
+ * `InfraErrorView` instead of the sandbox family.
+ */
+
+import {
+ type FunctionTriggerMessage,
+ type FunctionTriggerRenderer,
+ type Host,
+ JsonHighlight,
+} from '@iii-dev/console-ui'
import {
browserSessionIdFromCall,
isBrowserFunction,
parseScreenshotOutput,
-} from '@/lib/browser'
-import { JsonHighlight } from '@/lib/syntax'
-import type { FunctionTriggerMessage } from '@/types/chat'
+} from '../lib/browser'
+import { InfraErrorView, parseInfraErrorDisplay } from '../lib/errors'
+import { ExternalLink } from '../lib/icons'
import {
ActView,
ConsoleReadView,
@@ -26,19 +38,22 @@ import {
} from './BrowserViews'
import { decodeBrowserResult } from './parsers'
+/** The injected page's route — where "open in browser tab" navigates. */
+const BROWSER_PAGE_HASH = '#/ext/browser'
+
/**
* Header label for `browser::*` ids: dims the namespace prefix so the op
- * (`navigate`, `act`, …) reads clearly. Mirrors `StateFunctionIdLabel`.
+ * (`navigate`, `act`, …) reads clearly.
*/
-export function BrowserFunctionIdLabel({ functionId }: { functionId: string }) {
+function FunctionIdLabel({ functionId }: { functionId: string }) {
if (!functionId.startsWith('browser::')) {
- return {functionId}
+ return {functionId}
}
const tail = functionId.slice('browser::'.length)
return (
<>
- browser::
- {tail}
+ browser::
+ {tail}
>
)
}
@@ -55,11 +70,11 @@ function ScreenshotBody({ output }: { output: unknown }) {
const screenshot = parseScreenshotOutput(output)
if (!screenshot?.dataUrl) return null
return (
-
+
)
@@ -109,61 +124,51 @@ function renderBody(message: FunctionTriggerMessage): React.ReactNode | null {
/**
* Terminal view for a `browser::*` call: the owning session with an
- * "open in browser tab" affordance (routes to `#/browser/`),
- * then the per-function pretty body. Unknown or unparseable payloads fall
- * back to the decoded result as clamped JSON; the raw json tab keeps the
- * untouched request/response panes.
+ * "open in browser tab" affordance (routes to `#/ext/browser`), then the
+ * per-function pretty body. Unknown or unparseable payloads fall back to the
+ * decoded result as clamped JSON.
*/
function BrowserCallView({ message }: { message: FunctionTriggerMessage }) {
const sessionId = browserSessionIdFromCall(message.input, message.output)
const running = !!message.running
const body = !running && message.output != null ? renderBody(message) : null
- // Fallback decode only when no pretty body renders (the normal case has a
- // body, so the full envelope parse is skipped there).
const fallback =
!body && message.output != null ? decodeBrowserResult(message.output) : null
return (
-
-
+
+
{sessionId ? (
-
- session {sessionId}
+
+ session {sessionId}
) : (
- browser
+ browser
)}
{sessionId ? (
-
-
+
+
open in browser tab
) : null}
{running && message.output == null ? (
-
- running...
-
+ running...
) : body ? (
body
) : fallback != null ? (
-
+
) : (
-
- no result
-
+ no result
)}
)
}
-function tryRender(message: FunctionTriggerMessage): React.ReactNode | null {
+function renderCall(message: FunctionTriggerMessage): React.ReactNode | null {
if (!isBrowserFunction(message.functionId)) return null
if (message.pendingApproval) return null
@@ -173,23 +178,20 @@ function tryRender(message: FunctionTriggerMessage): React.ReactNode | null {
// envelopes). Browser success payloads never look denial-shaped: results
// are `ok`-flagged structs or `{content, details}` envelopes.
const errorDisplay =
- !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null
+ !running && rawOutput != null ? parseInfraErrorDisplay(rawOutput) : null
if (errorDisplay) {
- return
+ return
}
return
}
-/** `browser::*` calls have no bespoke pending preview. */
-function tryRenderPreview(
- _message: FunctionTriggerMessage,
-): React.ReactNode | null {
- return null
-}
-
-export const BrowserToolView = {
- isBrowserFunction,
- tryRender,
- tryRenderRunning: tryRender,
- tryRenderPreview,
+export function createBrowserRenderer(_host: Host): FunctionTriggerRenderer {
+ return {
+ id: 'browser/page.js#calls',
+ isMatch: isBrowserFunction,
+ tryRender: (message) => renderCall(message),
+ tryRenderRunning: (message) => renderCall(message),
+ tryRenderPreview: () => null,
+ FunctionIdLabel,
+ }
}
diff --git a/console/web/src/components/chat/browser/parsers.ts b/browser/ui/src/function-trigger-message/parsers.ts
similarity index 99%
rename from console/web/src/components/chat/browser/parsers.ts
rename to browser/ui/src/function-trigger-message/parsers.ts
index eed9f4627..973449f3b 100644
--- a/console/web/src/components/chat/browser/parsers.ts
+++ b/browser/ui/src/function-trigger-message/parsers.ts
@@ -5,7 +5,7 @@ import {
networkReadSchema,
sessionInfoSchema,
sessionStartSchema,
-} from '@/lib/browser'
+} from '../lib/browser'
/**
* Zod schemas + decode helpers for the `browser::*` chat views.
diff --git a/console/web/src/lib/browser.ts b/browser/ui/src/lib/browser.ts
similarity index 85%
rename from console/web/src/lib/browser.ts
rename to browser/ui/src/lib/browser.ts
index 2492e3601..d73cd97f0 100644
--- a/console/web/src/lib/browser.ts
+++ b/browser/ui/src/lib/browser.ts
@@ -1,13 +1,15 @@
+import type { ExtensionIii } from '@iii-dev/console-ui'
import { z } from 'zod'
-import { getIiiClient } from '@/lib/iii-client'
/**
- * Control plane for the optional `browser` worker: typed wrappers over its
- * session surface (start / list / stop / navigate / screenshot / act /
- * console / network / pick) plus parsers and formatting helpers for the
- * Browser page, the chat function-trigger view, and the pick-to-chat flow.
- * Everything here is gated by the caller on browser presence
- * (`use-browser-status`).
+ * Control plane for the `browser` worker's own injected UI: typed wrappers
+ * over its session surface (start / list / stop / navigate / screenshot / act
+ * / console / network / pick) plus parsers and formatting helpers for the
+ * page, the chat function-trigger views, and the pick-to-clipboard flow.
+ *
+ * Every call goes through the tab's `host.iii` client (an `ExtensionIii`),
+ * passed in by the page — there is no module-level singleton in injected UI.
+ * Wire source: workers/browser/src/functions/*.rs (verbatim ids + payloads).
*/
export const BROWSER_SESSIONS_START_FUNCTION_ID = 'browser::sessions::start'
@@ -34,7 +36,7 @@ export const BROWSER_NETWORK_EVENT_TRIGGER = 'browser::network-event'
export const BROWSER_PICKED_TRIGGER = 'browser::picked'
/** Stream the worker pushes live viewport frames onto (group = session id).
- * The console subscribes with a `type:'stream'` trigger instead of polling. */
+ * The page subscribes with a `type:'stream'` trigger instead of polling. */
export const BROWSER_FRAMES_STREAM = 'browser:frames'
/** Session lifecycle trigger types the sessions rail re-reads on. */
@@ -390,9 +392,9 @@ export function pickedSelector(element: BrowserPickedElement): string {
}
/**
- * Compact text block handed to the chat composer for a picked element:
- * one summary line, the url, recent console errors when present, and the
- * ref the agent can use directly. Never includes outer_html.
+ * Compact text block copied to the clipboard for a picked element: one
+ * summary line, the url, recent console errors when present, and the ref the
+ * agent can use directly. Never includes outer_html.
*/
export function formatPickedElement(evt: BrowserPickedEvent): string {
const el = evt.element
@@ -414,12 +416,10 @@ export function formatPickedElement(evt: BrowserPickedEvent): string {
return lines.join('\n')
}
-export async function listBrowserSessions(): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(
- BROWSER_SESSIONS_LIST_FUNCTION_ID,
- {},
- )
+export async function listBrowserSessions(
+ iii: ExtensionIii,
+): Promise {
+ const res = await iii.trigger(BROWSER_SESSIONS_LIST_FUNCTION_ID, {})
const parsed = sessionListSchema.safeParse(res)
if (!parsed.success) return []
return (parsed.data.sessions ?? [])
@@ -431,10 +431,10 @@ export async function listBrowserSessions(): Promise {
}
export async function startBrowserSession(
+ iii: ExtensionIii,
url?: string,
): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(
+ const res = await iii.trigger(
BROWSER_SESSIONS_START_FUNCTION_ID,
url ? { url } : {},
)
@@ -442,29 +442,31 @@ export async function startBrowserSession(
return parsed.success ? parsed.data : null
}
-export async function stopBrowserSession(sessionId: string): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_SESSIONS_STOP_FUNCTION_ID, {
+export async function stopBrowserSession(
+ iii: ExtensionIii,
+ sessionId: string,
+): Promise {
+ await iii.trigger(BROWSER_SESSIONS_STOP_FUNCTION_ID, {
session_id: sessionId,
})
}
export async function navigateBrowser(
+ iii: ExtensionIii,
sessionId: string,
url: string,
): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_NAVIGATE_FUNCTION_ID, {
+ await iii.trigger(BROWSER_NAVIGATE_FUNCTION_ID, {
session_id: sessionId,
url,
})
}
export async function takeBrowserScreenshot(
+ iii: ExtensionIii,
sessionId: string,
): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(BROWSER_SCREENSHOT_FUNCTION_ID, {
+ const res = await iii.trigger(BROWSER_SCREENSHOT_FUNCTION_ID, {
session_id: sessionId,
})
return parseScreenshotOutput(res)
@@ -476,13 +478,13 @@ export interface BrowserClickOptions {
}
export async function clickBrowserAt(
+ iii: ExtensionIii,
sessionId: string,
x: number,
y: number,
options: BrowserClickOptions = {},
): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_ACT_FUNCTION_ID, {
+ await iii.trigger(BROWSER_ACT_FUNCTION_ID, {
session_id: sessionId,
action: 'click',
x,
@@ -493,13 +495,13 @@ export async function clickBrowserAt(
}
export async function scrollBrowserAt(
+ iii: ExtensionIii,
sessionId: string,
x: number,
y: number,
deltaY: number,
): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_ACT_FUNCTION_ID, {
+ await iii.trigger(BROWSER_ACT_FUNCTION_ID, {
session_id: sessionId,
action: 'scroll',
x,
@@ -509,11 +511,11 @@ export async function scrollBrowserAt(
}
export async function typeBrowserText(
+ iii: ExtensionIii,
sessionId: string,
text: string,
): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_ACT_FUNCTION_ID, {
+ await iii.trigger(BROWSER_ACT_FUNCTION_ID, {
session_id: sessionId,
action: 'type',
text,
@@ -521,11 +523,11 @@ export async function typeBrowserText(
}
export async function pressBrowserKey(
+ iii: ExtensionIii,
sessionId: string,
key: string,
): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_ACT_FUNCTION_ID, {
+ await iii.trigger(BROWSER_ACT_FUNCTION_ID, {
session_id: sessionId,
action: 'press',
key,
@@ -533,12 +535,12 @@ export async function pressBrowserKey(
}
export async function hintBrowserPick(
+ iii: ExtensionIii,
sessionId: string,
x: number,
y: number,
): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(BROWSER_PICK_HINT_FUNCTION_ID, {
+ const res = await iii.trigger(BROWSER_PICK_HINT_FUNCTION_ID, {
session_id: sessionId,
x,
y,
@@ -557,16 +559,20 @@ const frameSchema = z.object({
})
export type BrowserFrame = z.infer
-export async function startBrowserScreencast(sessionId: string): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_SCREENCAST_START_FUNCTION_ID, {
+export async function startBrowserScreencast(
+ iii: ExtensionIii,
+ sessionId: string,
+): Promise {
+ await iii.trigger(BROWSER_SCREENCAST_START_FUNCTION_ID, {
session_id: sessionId,
})
}
-export async function stopBrowserScreencast(sessionId: string): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_SCREENCAST_STOP_FUNCTION_ID, {
+export async function stopBrowserScreencast(
+ iii: ExtensionIii,
+ sessionId: string,
+): Promise {
+ await iii.trigger(BROWSER_SCREENCAST_STOP_FUNCTION_ID, {
session_id: sessionId,
})
}
@@ -576,11 +582,11 @@ export async function stopBrowserScreencast(sessionId: string): Promise {
* poll fast. `frame` is absent while `sinceFrame` is still the newest seq.
*/
export async function readBrowserFrame(
+ iii: ExtensionIii,
sessionId: string,
sinceFrame?: number,
): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(BROWSER_FRAME_FUNCTION_ID, {
+ const res = await iii.trigger(BROWSER_FRAME_FUNCTION_ID, {
session_id: sessionId,
...(sinceFrame != null ? { since_frame: sinceFrame } : {}),
})
@@ -596,11 +602,11 @@ export interface BrowserConsoleReadOptions {
}
export async function readBrowserConsole(
+ iii: ExtensionIii,
sessionId: string,
options: BrowserConsoleReadOptions = {},
): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(BROWSER_CONSOLE_READ_FUNCTION_ID, {
+ const res = await iii.trigger(BROWSER_CONSOLE_READ_FUNCTION_ID, {
session_id: sessionId,
...(options.pattern ? { pattern: options.pattern } : {}),
...(options.level ? { level: options.level } : {}),
@@ -619,11 +625,11 @@ export interface BrowserNetworkReadOptions {
}
export async function readBrowserNetwork(
+ iii: ExtensionIii,
sessionId: string,
options: BrowserNetworkReadOptions = {},
): Promise {
- const client = await getIiiClient()
- const res = await client.trigger(BROWSER_NETWORK_READ_FUNCTION_ID, {
+ const res = await iii.trigger(BROWSER_NETWORK_READ_FUNCTION_ID, {
session_id: sessionId,
...(options.pattern ? { pattern: options.pattern } : {}),
...(options.failedOnly ? { failed_only: true } : {}),
@@ -634,9 +640,11 @@ export async function readBrowserNetwork(
return parsed.success ? parsed.data : null
}
-export async function startBrowserPick(sessionId: string): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_PICK_START_FUNCTION_ID, {
+export async function startBrowserPick(
+ iii: ExtensionIii,
+ sessionId: string,
+): Promise {
+ await iii.trigger(BROWSER_PICK_START_FUNCTION_ID, {
session_id: sessionId,
})
}
@@ -645,21 +653,23 @@ export async function startBrowserPick(sessionId: string): Promise {
* Deterministic (same getNodeForLocation hit-test as the hover hint), unlike
* a synthesized click through DevTools inspect mode. */
export async function resolveBrowserPick(
+ iii: ExtensionIii,
sessionId: string,
x: number,
y: number,
): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_PICK_RESOLVE_FUNCTION_ID, {
+ await iii.trigger(BROWSER_PICK_RESOLVE_FUNCTION_ID, {
session_id: sessionId,
x,
y,
})
}
-export async function stopBrowserPick(sessionId: string): Promise {
- const client = await getIiiClient()
- await client.trigger(BROWSER_PICK_STOP_FUNCTION_ID, {
+export async function stopBrowserPick(
+ iii: ExtensionIii,
+ sessionId: string,
+): Promise {
+ await iii.trigger(BROWSER_PICK_STOP_FUNCTION_ID, {
session_id: sessionId,
})
}
diff --git a/browser/ui/src/lib/cn.ts b/browser/ui/src/lib/cn.ts
new file mode 100644
index 000000000..9f5134af4
--- /dev/null
+++ b/browser/ui/src/lib/cn.ts
@@ -0,0 +1,9 @@
+/**
+ * Minimal class-name joiner for the injected UI. The console's Tailwind
+ * `cn` (clsx + tailwind-merge) is not available here — injected UI ships its
+ * own scoped stylesheet with semantic classes, so there are no conflicting
+ * utility classes to merge. This just filters falsy values and space-joins.
+ */
+export function cn(...parts: Array): string {
+ return parts.filter(Boolean).join(' ')
+}
diff --git a/browser/ui/src/lib/errors.tsx b/browser/ui/src/lib/errors.tsx
new file mode 100644
index 000000000..e211838fe
--- /dev/null
+++ b/browser/ui/src/lib/errors.tsx
@@ -0,0 +1,377 @@
+/**
+ * Shared-infra error parsing + view for `browser::*` chat cards. Ported from
+ * the console's sandbox `parsers.ts` + `ErrorView.tsx` (the console rendered
+ * browser errors through `parseSandboxErrorDisplay` / `SandboxErrorView`).
+ * Normalises every failure shape the console may receive — flat Stripe-style
+ * wire errors, harness `{ content, details }` wrappers, denial envelopes,
+ * function_error envelopes, dispatch-policy denials, and JSON embedded in
+ * strings — into one display union. The exec-stream (S200) special case from
+ * the sandbox view is dropped: browser errors never carry an ExecResponse.
+ * Tailwind utility classes are replaced with scoped `br-ui-*` classes.
+ */
+
+import { Badge } from '@iii-dev/console-ui'
+import { z } from 'zod'
+
+const infraErrorWireSchema = z.object({
+ type: z.string(),
+ code: z.string().regex(/^S\d{3}$/),
+ message: z.string(),
+ docs_url: z.string().optional(),
+ retryable: z.boolean().optional(),
+ fix: z.unknown().optional(),
+ fix_note: z.string().nullable().optional(),
+})
+export type InfraErrorWire = z.infer
+
+const denialEnvelopeSchema = z.object({
+ schema_version: z.number().optional(),
+ status: z.string().optional(),
+ denied_by: z.string().optional(),
+ function_id: z.string().optional(),
+ reason: z.string().optional(),
+})
+type DenialEnvelopeWire = z.infer
+
+const functionErrorEnvelopeSchema = z.object({
+ kind: z.string(),
+ message: z.string(),
+ details: z.unknown().optional(),
+ content: z.array(z.unknown()).optional(),
+})
+
+export interface InfraInvocationError {
+ title: string
+ message: string
+ functionId?: string
+ deniedBy?: string
+ reason?: string
+ detailText?: string
+}
+
+export interface InfraDispatchDenial {
+ functionId?: string
+ namespace?: string
+ message: string
+}
+
+export type InfraErrorDisplay =
+ | { variant: 'wire'; error: InfraErrorWire }
+ | { variant: 'invocation'; error: InfraInvocationError }
+ | { variant: 'dispatch-denied'; error: InfraDispatchDenial }
+
+/** `{ content: [...], details }` harness result envelope → details. */
+function unwrapEnvelope(value: unknown): unknown {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
+ const obj = value as Record
+ if (Array.isArray(obj.content) && 'details' in obj) return obj.details
+ return value
+}
+
+function contentBlocksText(content: unknown): string | undefined {
+ if (!Array.isArray(content)) return undefined
+ const parts: string[] = []
+ for (const block of content) {
+ if (!block || typeof block !== 'object') continue
+ const obj = block as Record
+ if (
+ obj.type === 'text' &&
+ typeof obj.text === 'string' &&
+ obj.text.length > 0
+ ) {
+ parts.push(obj.text)
+ }
+ }
+ return parts.length > 0 ? parts.join('\n') : undefined
+}
+
+function extractFirstJsonObject(text: string): unknown | null {
+ const start = text.indexOf('{')
+ if (start === -1) return null
+ let depth = 0
+ for (let i = start; i < text.length; i++) {
+ const ch = text[i]
+ if (ch === '{') depth++
+ else if (ch === '}') {
+ depth--
+ if (depth === 0) {
+ try {
+ return JSON.parse(text.slice(start, i + 1))
+ } catch {
+ return null
+ }
+ }
+ }
+ }
+ return null
+}
+
+function tryParseWire(value: unknown): InfraErrorWire | null {
+ const parsed = infraErrorWireSchema.safeParse(unwrapEnvelope(value))
+ if (parsed.success) return parsed.data
+
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+ const obj = value as Record
+ if (
+ obj.error === 'handler_error' ||
+ (typeof obj.code === 'string' && /^S\d{3}$/.test(obj.code))
+ ) {
+ const { error: _tag, ...rest } = obj
+ const tagged = infraErrorWireSchema.safeParse(rest)
+ if (tagged.success) return tagged.data
+ }
+ return null
+}
+
+function tryParseDenial(value: unknown): DenialEnvelopeWire | null {
+ const parsed = denialEnvelopeSchema.safeParse(unwrapEnvelope(value))
+ if (!parsed.success) return null
+ if (parsed.data.status !== 'denied' && !parsed.data.denied_by) return null
+ return parsed.data
+}
+
+function denialToInvocation(
+ denial: DenialEnvelopeWire,
+ fallbackMessage?: string,
+ detailText?: string,
+): InfraInvocationError {
+ const deniedBy = denial.denied_by
+ const title =
+ deniedBy === 'gate_unavailable'
+ ? 'Gate unavailable'
+ : deniedBy === 'permissions'
+ ? 'Permission denied'
+ : deniedBy === 'user'
+ ? 'Denied by user'
+ : denial.status === 'denied'
+ ? 'Denied'
+ : 'Trigger failed'
+ const message =
+ denial.reason ?? fallbackMessage ?? 'The browser trigger could not complete.'
+ return {
+ title,
+ message,
+ functionId: denial.function_id,
+ deniedBy,
+ reason: denial.reason,
+ detailText,
+ }
+}
+
+function invocationFromFunctionError(
+ envelope: z.infer,
+): InfraInvocationError | null {
+ const detailText = contentBlocksText(envelope.content)
+ const denial =
+ envelope.details != null ? tryParseDenial(envelope.details) : null
+ if (denial) {
+ return denialToInvocation(denial, envelope.message, detailText)
+ }
+ return {
+ title: 'Trigger failed',
+ message: envelope.message,
+ detailText,
+ }
+}
+
+function collectErrorCandidates(value: unknown): unknown[] {
+ const seen = new Set()
+ const out: unknown[] = []
+ const push = (candidate: unknown) => {
+ if (seen.has(candidate)) return
+ seen.add(candidate)
+ out.push(candidate)
+ }
+
+ push(value)
+ push(unwrapEnvelope(value))
+
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ const obj = value as Record
+ if (obj.error && typeof obj.error === 'object') {
+ const err = obj.error as Record
+ push(err)
+ if ('details' in err) push(err.details)
+ if (typeof err.message === 'string') push(err.message)
+ const text = contentBlocksText(err.content)
+ if (text) push(text)
+ }
+ }
+
+ return out
+}
+
+const DISPATCH_DENIAL_RE = /not permitted by this agent.?s dispatch policy/i
+const DISPATCH_FN_RE = /function\s+([A-Za-z0-9_:-]+)\s+is not permitted/i
+
+function candidateText(candidate: unknown): string | undefined {
+ if (typeof candidate === 'string') return candidate
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
+ const msg = (candidate as Record).message
+ if (typeof msg === 'string') return msg
+ }
+ return undefined
+}
+
+function parseDispatchDenial(value: unknown): InfraDispatchDenial | null {
+ for (const candidate of collectErrorCandidates(value)) {
+ const text = candidateText(candidate)
+ if (!text || !DISPATCH_DENIAL_RE.test(text)) continue
+ const functionId = text.match(DISPATCH_FN_RE)?.[1]
+ const namespace = functionId?.includes('::')
+ ? functionId.split('::')[0]
+ : undefined
+ return { functionId, namespace, message: text }
+ }
+ return null
+}
+
+export function parseInfraErrorDisplay(
+ value: unknown,
+): InfraErrorDisplay | null {
+ const candidates = collectErrorCandidates(value)
+
+ const dispatchDenial = parseDispatchDenial(value)
+ if (dispatchDenial) {
+ return { variant: 'dispatch-denied', error: dispatchDenial }
+ }
+
+ for (const candidate of candidates) {
+ const wire = tryParseWire(candidate)
+ if (wire) return { variant: 'wire', error: wire }
+
+ if (typeof candidate === 'string') {
+ const embedded = extractFirstJsonObject(candidate)
+ if (embedded != null) {
+ const wireFromText = tryParseWire(embedded)
+ if (wireFromText) return { variant: 'wire', error: wireFromText }
+ }
+ }
+ }
+
+ for (const candidate of candidates) {
+ const denial = tryParseDenial(candidate)
+ if (denial) {
+ return { variant: 'invocation', error: denialToInvocation(denial) }
+ }
+ }
+
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ const parsed = functionErrorEnvelopeSchema.safeParse(
+ (value as Record).error,
+ )
+ if (parsed.success && parsed.data.kind === 'function_error') {
+ const invocation = invocationFromFunctionError(parsed.data)
+ if (invocation) {
+ return { variant: 'invocation', error: invocation }
+ }
+ }
+ }
+
+ return null
+}
+
+function WireErrorView({ error }: { error: InfraErrorWire }) {
+ return (
+
+
+
+ {error.code}
+
+ {error.type}
+ {error.retryable === true ? (
+
+ retryable
+
+ ) : null}
+
+
+ {error.message}
+
+ {error.fix_note ? (
+ {error.fix_note}
+ ) : null}
+ {error.docs_url ? (
+
+ docs ↗
+
+ ) : null}
+
+ )
+}
+
+function InvocationErrorView({ error }: { error: InfraInvocationError }) {
+ const badge = error.deniedBy ?? 'error'
+ const showDetailText =
+ error.detailText &&
+ error.detailText !== error.message &&
+ error.detailText !== error.reason
+ return (
+
+
+
+ {badge}
+
+ {error.title}
+
+ {error.functionId ? (
+
+ function {error.functionId}
+
+ ) : null}
+
+ {error.message}
+
+ {showDetailText ? (
+
+ {error.detailText}
+
+ ) : null}
+
+ )
+}
+
+function DispatchDeniedView({ denial }: { denial: InfraDispatchDenial }) {
+ const fn = denial.functionId
+ return (
+
+
+
+ denied
+
+ dispatch policy
+
+ {fn ? (
+
+ blocked {fn}
+
+ ) : null}
+
+ This agent's allow-list doesn't cover{' '}
+ {fn ? {fn} : 'this function'}. Grant
+ it where the agent is defined (a workflow node's{' '}
+ agent.functions / the def's{' '}
+ default_functions, or a session's{' '}
+ options.functions.allow).
+
+
+ {denial.message}
+
+
+ )
+}
+
+export function InfraErrorView({ display }: { display: InfraErrorDisplay }) {
+ if (display.variant === 'wire') {
+ return
+ }
+ if (display.variant === 'dispatch-denied') {
+ return
+ }
+ return
+}
diff --git a/browser/ui/src/lib/events.ts b/browser/ui/src/lib/events.ts
new file mode 100644
index 000000000..f4d433674
--- /dev/null
+++ b/browser/ui/src/lib/events.ts
@@ -0,0 +1,191 @@
+import type { Host } from '@iii-dev/console-ui'
+import { useEffect, useId, useRef, useState } from 'react'
+import { BROWSER_LIFECYCLE_TRIGGERS } from './browser'
+
+/**
+ * Browser-local bindings to the browser worker's custom trigger types and
+ * screencast stream, ported to the injected-UI `host` surface. Each binding
+ * is `host.iii.on(fnId)` plus `host.iii.registerTrigger` targeting
+ * `::` (the SDK registers the handler under the same
+ * namespaced id, so they match). The handler base ids carry the `iii::`
+ * prefix so per-event invocations stay span-suppressed and out of the trace
+ * feed; the per-mount `instanceId` keeps two hook instances from colliding.
+ *
+ * Every binding is GC'd with the tab and unregistered on unmount, so the
+ * injected UI's subscriptions die and revive with the page script.
+ */
+
+const LIFECYCLE_FN = 'iii::browser-ui::lifecycle'
+
+export interface UseBrowserLifecycleEventsOptions {
+ host: Host
+ /** Only subscribe while the page is live. */
+ enabled: boolean
+ onEvent: () => void
+}
+
+export interface BrowserLifecycleSubscription {
+ /**
+ * True once all three trigger bindings registered. While false (worker
+ * absent, SDK failure) callers fall back to polling.
+ */
+ bound: boolean
+}
+
+/**
+ * Page-scoped feed of the session lifecycle trigger types
+ * (session-started / session-stopped / navigated), for surfaces that re-read
+ * the session list on any change.
+ */
+export function useBrowserLifecycleEvents(
+ opts: UseBrowserLifecycleEventsOptions,
+): BrowserLifecycleSubscription {
+ const { host, enabled } = opts
+ const onEventRef = useRef(opts.onEvent)
+ onEventRef.current = opts.onEvent
+
+ const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '')
+ const [bound, setBound] = useState(false)
+
+ useEffect(() => {
+ if (!enabled) {
+ setBound(false)
+ return
+ }
+ const offs: Array<() => void> = []
+ let registered = 0
+ for (const triggerType of BROWSER_LIFECYCLE_TRIGGERS) {
+ const suffix = triggerType.replace(/[^a-zA-Z0-9]/g, '-')
+ const localFnId = `${LIFECYCLE_FN}::${suffix}::${instanceId}`
+ try {
+ offs.push(
+ host.iii.on(localFnId, () => {
+ onEventRef.current()
+ }),
+ )
+ offs.push(
+ host.iii.registerTrigger({
+ type: triggerType,
+ function_id: `${localFnId}::${host.iii.browserId}`,
+ config: {},
+ }),
+ )
+ registered += 1
+ } catch {
+ // Worker absent or trigger type unregistered; drop the binding.
+ }
+ }
+ setBound(registered === BROWSER_LIFECYCLE_TRIGGERS.length)
+
+ return () => {
+ setBound(false)
+ for (const off of offs) off()
+ }
+ }, [host, enabled, instanceId])
+
+ return { bound }
+}
+
+export interface UseBrowserSessionEventOptions {
+ host: Host
+ enabled: boolean
+ /** Trigger type to bind (e.g. `browser::console-event`). */
+ triggerType: string
+ /** Session the binding filters to (worker-side `session_id` filter). */
+ sessionId: string | null
+ /** Base id for this binding's browser-local handler. */
+ fnId: string
+ onEvent: (payload: unknown) => void
+}
+
+/**
+ * One session-filtered binding to a browser trigger type (console-event,
+ * network-event, or picked). Rebinds when the session changes and
+ * unregisters on unmount.
+ */
+export function useBrowserSessionEvent(
+ opts: UseBrowserSessionEventOptions,
+): void {
+ const { host, enabled, triggerType, sessionId, fnId } = opts
+ const onEventRef = useRef(opts.onEvent)
+ onEventRef.current = opts.onEvent
+
+ const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '')
+
+ useEffect(() => {
+ if (!enabled || !sessionId) return
+ const offs: Array<() => void> = []
+ const localFnId = `${fnId}::${instanceId}`
+ try {
+ offs.push(
+ host.iii.on(localFnId, (payload: unknown) => {
+ onEventRef.current(payload)
+ }),
+ )
+ offs.push(
+ host.iii.registerTrigger({
+ type: triggerType,
+ function_id: `${localFnId}::${host.iii.browserId}`,
+ config: { session_id: sessionId },
+ }),
+ )
+ } catch {
+ // Worker absent or trigger type unregistered; drop the binding.
+ }
+
+ return () => {
+ for (const off of offs) off()
+ }
+ }, [host, enabled, triggerType, sessionId, fnId, instanceId])
+}
+
+export interface UseBrowserStreamOptions {
+ host: Host
+ enabled: boolean
+ /** iii stream name to subscribe to. */
+ streamName: string
+ /** Stream group (the session id for per-session streams). */
+ groupId: string | null
+ /** Base id for this binding's browser-local handler. */
+ fnId: string
+ onFrame: (payload: unknown) => void
+}
+
+/**
+ * Subscribe to an iii stream (`type:'stream'`) for a session, the same
+ * engine-pushes / client-appends pattern the Traces view uses. Rebinds when
+ * the stream group (session) changes and unregisters on unmount.
+ */
+export function useBrowserStream(opts: UseBrowserStreamOptions): void {
+ const { host, enabled, streamName, groupId, fnId } = opts
+ const onFrameRef = useRef(opts.onFrame)
+ onFrameRef.current = opts.onFrame
+
+ const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '')
+
+ useEffect(() => {
+ if (!enabled || !groupId) return
+ const offs: Array<() => void> = []
+ const localFnId = `${fnId}::${instanceId}`
+ try {
+ offs.push(
+ host.iii.on(localFnId, (payload: unknown) => {
+ onFrameRef.current(payload)
+ }),
+ )
+ offs.push(
+ host.iii.registerTrigger({
+ type: 'stream',
+ function_id: `${localFnId}::${host.iii.browserId}`,
+ config: { stream_name: streamName, group_id: groupId },
+ }),
+ )
+ } catch {
+ // Stream not available; the seed read is the fallback.
+ }
+
+ return () => {
+ for (const off of offs) off()
+ }
+ }, [host, enabled, streamName, groupId, fnId, instanceId])
+}
diff --git a/browser/ui/src/lib/format.ts b/browser/ui/src/lib/format.ts
new file mode 100644
index 000000000..939b7a51d
--- /dev/null
+++ b/browser/ui/src/lib/format.ts
@@ -0,0 +1,20 @@
+/**
+ * Pure formatting helpers for the browser UI. Ported from the console's
+ * sandbox `format.ts` — only the pieces the session rail needs.
+ */
+
+/** Unix seconds → short relative time ("3m ago", "2d ago"). */
+export function formatMtime(unixSecs: number, now = Date.now()): string {
+ if (!unixSecs || unixSecs <= 0) return '—'
+ const deltaSecs = Math.max(0, Math.floor(now / 1000 - unixSecs))
+ if (deltaSecs < 60) return deltaSecs <= 1 ? 'just now' : `${deltaSecs}s ago`
+ const mins = Math.floor(deltaSecs / 60)
+ if (mins < 60) return `${mins}m ago`
+ const hours = Math.floor(mins / 60)
+ if (hours < 24) return `${hours}h ago`
+ const days = Math.floor(hours / 24)
+ if (days < 30) return `${days}d ago`
+ const months = Math.floor(days / 30)
+ if (months < 12) return `${months}mo ago`
+ return `${Math.floor(months / 12)}y ago`
+}
diff --git a/browser/ui/src/lib/highlight.tsx b/browser/ui/src/lib/highlight.tsx
new file mode 100644
index 000000000..dcba623c1
--- /dev/null
+++ b/browser/ui/src/lib/highlight.tsx
@@ -0,0 +1,79 @@
+/**
+ * Best-effort match highlighting for the snapshot a11y tree ([ref=eN]
+ * handles). Ported from the console's sandbox `highlight.tsx`; the match
+ * span carries the scoped `br-ui-hl` class instead of Tailwind utilities.
+ */
+import type { ReactNode } from 'react'
+
+export interface HighlightOptions {
+ /** Treat `query` as a regex; false = literal substring match. */
+ isRegex: boolean
+ ignoreCase: boolean
+}
+
+export function renderWithHighlight(
+ line: string,
+ query: string,
+ { isRegex, ignoreCase }: HighlightOptions,
+): ReactNode {
+ if (!query) return line
+ if (isRegex) {
+ let re: RegExp | null = null
+ try {
+ re = new RegExp(query, ignoreCase ? 'gi' : 'g')
+ } catch {
+ re = null
+ }
+ if (re) return highlightRegex(line, re)
+ }
+ return highlightSubstring(line, query, ignoreCase)
+}
+
+function highlightRegex(line: string, re: RegExp): ReactNode {
+ const parts: ReactNode[] = []
+ let last = 0
+ let n = 0
+ for (const hit of line.matchAll(re)) {
+ const start = hit.index ?? 0
+ const text = hit[0]
+ if (text.length === 0) continue
+ if (start > last) parts.push(line.slice(last, start))
+ parts.push(
+
+ {text}
+ ,
+ )
+ last = start + text.length
+ n++
+ if (n > 200) break
+ }
+ if (last < line.length) parts.push(line.slice(last))
+ return parts
+}
+
+function highlightSubstring(
+ line: string,
+ query: string,
+ ignoreCase: boolean,
+): ReactNode {
+ const needle = ignoreCase ? query.toLowerCase() : query
+ const hay = ignoreCase ? line.toLowerCase() : line
+ const parts: ReactNode[] = []
+ let i = 0
+ let n = 0
+ while (i < line.length) {
+ const j = hay.indexOf(needle, i)
+ if (j === -1) {
+ parts.push(line.slice(i))
+ break
+ }
+ if (j > i) parts.push(line.slice(i, j))
+ parts.push(
+
+ {line.slice(j, j + query.length)}
+ ,
+ )
+ i = j + query.length
+ }
+ return parts
+}
diff --git a/browser/ui/src/lib/icons.tsx b/browser/ui/src/lib/icons.tsx
new file mode 100644
index 000000000..b3a3eff66
--- /dev/null
+++ b/browser/ui/src/lib/icons.tsx
@@ -0,0 +1,126 @@
+/**
+ * Small inline SVG icon set — the injected page can't pull in lucide-react
+ * (nothing is bundled but zod), so the handful of glyphs the ported
+ * components used are hand-drawn here. Every icon inherits `currentColor` and
+ * takes a numeric `size` (Tailwind `w-*`/`h-*` classes don't apply in
+ * injected UI). `className` is accepted so components like the console's
+ * `EmptyState`, which pass one, type-check.
+ */
+
+import type { CSSProperties, ReactNode } from 'react'
+
+export interface IconProps {
+ size?: number
+ className?: string
+ style?: CSSProperties
+ 'aria-hidden'?: boolean
+ 'aria-label'?: string
+}
+
+function Svg({
+ size = 14,
+ children,
+ className,
+ style,
+ 'aria-hidden': ariaHidden,
+ 'aria-label': ariaLabel,
+ ...rest
+}: IconProps & { children: ReactNode }) {
+ // Decorative by default. A caller passing aria-label opts into a labeled,
+ // non-hidden icon (role=img so it reads as a graphic); an explicit
+ // aria-hidden always wins.
+ const labeled = ariaLabel != null
+ return (
+
+ )
+}
+
+export function Globe(props: IconProps) {
+ return (
+
+ )
+}
+
+export function Plus(props: IconProps) {
+ return (
+
+ )
+}
+
+export function RefreshCw(props: IconProps) {
+ return (
+
+ )
+}
+
+export function AlertCircle(props: IconProps) {
+ return (
+
+ )
+}
+
+export function Crosshair(props: IconProps) {
+ return (
+
+ )
+}
+
+export function Square(props: IconProps) {
+ return (
+
+ )
+}
+
+export function X(props: IconProps) {
+ return (
+
+ )
+}
+
+export function ExternalLink(props: IconProps) {
+ return (
+
+ )
+}
diff --git a/browser/ui/src/lib/shared.tsx b/browser/ui/src/lib/shared.tsx
new file mode 100644
index 000000000..5e5b6e80e
--- /dev/null
+++ b/browser/ui/src/lib/shared.tsx
@@ -0,0 +1,55 @@
+/**
+ * Terminal-card primitives shared by the `browser::*` function-trigger views.
+ * Ported from the console's sandbox `shared.tsx`; Tailwind utility classes are
+ * replaced with scoped `br-ui-*` classes (see styles.css) and `Badge` comes
+ * from the console's shared component library.
+ */
+
+import { Badge } from '@iii-dev/console-ui'
+import type { ReactNode } from 'react'
+import { cn } from './cn'
+
+export function Chip({
+ children,
+ className,
+}: {
+ children: ReactNode
+ className?: string
+}) {
+ return {children}
+}
+
+export function MetaRow({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export function StatusPill({
+ label,
+ variant = 'default',
+}: {
+ label: string
+ variant?: 'default' | 'warn' | 'alert' | 'accent'
+}) {
+ return (
+
+ {label}
+
+ )
+}
+
+export function ActionLine({
+ symbol,
+ children,
+ tone = 'accent',
+}: {
+ symbol: string
+ children: ReactNode
+ tone?: 'accent' | 'warn' | 'ink'
+}) {
+ return (
+
+ {symbol}
+ {children}
+
+ )
+}
diff --git a/console/web/src/pages/Browser/components/ConsolePanel.tsx b/browser/ui/src/page/ConsolePanel.tsx
similarity index 76%
rename from console/web/src/pages/Browser/components/ConsolePanel.tsx
rename to browser/ui/src/page/ConsolePanel.tsx
index 5b3437b9c..22a619ce1 100644
--- a/console/web/src/pages/Browser/components/ConsolePanel.tsx
+++ b/browser/ui/src/page/ConsolePanel.tsx
@@ -1,14 +1,14 @@
+import { Input, type Host } from '@iii-dev/console-ui'
import { useEffect, useRef, useState } from 'react'
-import { ConsoleEntryRow } from '@/components/chat/browser/BrowserViews'
-import { Input } from '@/components/ui/Input'
-import { useBrowserSessionEvent } from '@/hooks/use-browser-events'
import {
BROWSER_CONSOLE_EVENT_TRIGGER,
type BrowserConsoleEntry,
errorMessage,
parseConsoleEvent,
readBrowserConsole,
-} from '@/lib/browser'
+} from '../lib/browser'
+import { useBrowserSessionEvent } from '../lib/events'
+import { ConsoleEntryRow } from '../function-trigger-message/BrowserViews'
/**
* Live console feed for the selected session: seeded from
@@ -23,7 +23,7 @@ const SEED_LIMIT = 200
const MAX_ENTRIES = 500
const PATTERN_DEBOUNCE_MS = 300
-const CONSOLE_FEED_FN = 'console::browser-console-feed'
+const CONSOLE_FEED_FN = 'iii::browser-ui::console-feed'
function matchesPattern(text: string, pattern: string): boolean {
if (!pattern) return true
@@ -35,11 +35,12 @@ function matchesPattern(text: string, pattern: string): boolean {
}
interface ConsolePanelProps {
+ host: Host
sessionId: string
enabled: boolean
}
-export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) {
+export function ConsolePanel({ host, sessionId, enabled }: ConsolePanelProps) {
const [pattern, setPattern] = useState('')
const [debouncedPattern, setDebouncedPattern] = useState('')
const [entries, setEntries] = useState([])
@@ -62,7 +63,7 @@ export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) {
let cancelled = false
lastSeqRef.current = 0
setEntries([])
- void readBrowserConsole(sessionId, {
+ void readBrowserConsole(host.iii, sessionId, {
pattern: debouncedPattern || undefined,
limit: SEED_LIMIT,
})
@@ -80,9 +81,10 @@ export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) {
return () => {
cancelled = true
}
- }, [enabled, sessionId, debouncedPattern])
+ }, [host, enabled, sessionId, debouncedPattern])
useBrowserSessionEvent({
+ host,
enabled,
triggerType: BROWSER_CONSOLE_EVENT_TRIGGER,
sessionId,
@@ -98,32 +100,30 @@ export function ConsolePanel({ sessionId, enabled }: ConsolePanelProps) {
})
return (
-
-
+
+
{dropped > 0 ? (
-
+
{dropped} older entries dropped from the buffer
) : null}
{error ? (
- {error}
+ {error}
) : entries.length === 0 ? (
-
- no console entries yet
-
+ no console entries yet
) : (
// Column-reverse with the newest entry first in the DOM pins the
// scroll position to the bottom, terminal-style.
-
+
{[...entries].reverse().map((entry) => (
))}
diff --git a/console/web/src/pages/Browser/components/NetworkPanel.tsx b/browser/ui/src/page/NetworkPanel.tsx
similarity index 59%
rename from console/web/src/pages/Browser/components/NetworkPanel.tsx
rename to browser/ui/src/page/NetworkPanel.tsx
index c328137ac..73a446fe7 100644
--- a/console/web/src/pages/Browser/components/NetworkPanel.tsx
+++ b/browser/ui/src/page/NetworkPanel.tsx
@@ -1,5 +1,5 @@
+import type { Host } from '@iii-dev/console-ui'
import { useEffect, useRef, useState } from 'react'
-import { useBrowserSessionEvent } from '@/hooks/use-browser-events'
import {
BROWSER_NETWORK_EVENT_TRIGGER,
type BrowserNetworkEntry,
@@ -7,8 +7,9 @@ import {
formatTime,
parseNetworkEvent,
readBrowserNetwork,
-} from '@/lib/browser'
-import { cn } from '@/lib/utils'
+} from '../lib/browser'
+import { cn } from '../lib/cn'
+import { useBrowserSessionEvent } from '../lib/events'
/**
* Network feed for the selected session: seeded from `browser::network::read`,
@@ -20,14 +21,15 @@ import { cn } from '@/lib/utils'
const SEED_LIMIT = 200
const MAX_ENTRIES = 500
-const NETWORK_FEED_FN = 'console::browser-network-feed'
+const NETWORK_FEED_FN = 'iii::browser-ui::network-feed'
interface NetworkPanelProps {
+ host: Host
sessionId: string
enabled: boolean
}
-export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) {
+export function NetworkPanel({ host, sessionId, enabled }: NetworkPanelProps) {
const [failedOnly, setFailedOnly] = useState(false)
const [entries, setEntries] = useState([])
const [dropped, setDropped] = useState(0)
@@ -41,7 +43,7 @@ export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) {
let cancelled = false
lastSeqRef.current = 0
setEntries([])
- void readBrowserNetwork(sessionId, { failedOnly, limit: SEED_LIMIT })
+ void readBrowserNetwork(host.iii, sessionId, { failedOnly, limit: SEED_LIMIT })
.then((res) => {
if (cancelled || !res) return
setEntries(res.entries)
@@ -56,9 +58,10 @@ export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) {
return () => {
cancelled = true
}
- }, [enabled, sessionId, failedOnly])
+ }, [host, enabled, sessionId, failedOnly])
useBrowserSessionEvent({
+ host,
enabled,
triggerType: BROWSER_NETWORK_EVENT_TRIGGER,
sessionId,
@@ -74,67 +77,49 @@ export function NetworkPanel({ sessionId, enabled }: NetworkPanelProps) {
})
return (
-
-
+
+
{dropped > 0 ? (
-
+
{dropped} older requests dropped from the buffer
) : null}
{error ? (
- {error}
+ {error}
) : entries.length === 0 ? (
-
+
{failedOnly ? 'no failed requests' : 'no requests yet'}
) : (
-
+
{[...entries].reverse().map((entry) => (
- -
-
+
-
+
{formatTime(entry.timestamp)}
{entry.status ?? (entry.failed ? 'err' : '...')}
-
- {entry.method}
-
-
+ {entry.method}
+
{entry.url}
{entry.error ? (
- · {entry.error}
+ · {entry.error}
) : null}
{entry.mime_type ? (
-
- {entry.mime_type}
-
+ {entry.mime_type}
) : null}
))}
diff --git a/browser/ui/src/page/SessionRail.tsx b/browser/ui/src/page/SessionRail.tsx
new file mode 100644
index 000000000..c688f2d59
--- /dev/null
+++ b/browser/ui/src/page/SessionRail.tsx
@@ -0,0 +1,70 @@
+import type { BrowserSessionInfo } from '../lib/browser'
+import { cn } from '../lib/cn'
+import { formatMtime } from '../lib/format'
+import { Globe } from '../lib/icons'
+
+/**
+ * Left rail: one row per live Chromium session. Selection is page-local
+ * state (the injected page owns its own routing), so a click just flips the
+ * selected id.
+ */
+
+interface SessionRailProps {
+ sessions: BrowserSessionInfo[]
+ selectedId: string | null
+ onSelect: (sessionId: string) => void
+}
+
+function hostOf(url: string): string {
+ try {
+ const parsed = new URL(url)
+ return parsed.host || url
+ } catch {
+ return url
+ }
+}
+
+export function SessionRail({
+ sessions,
+ selectedId,
+ onSelect,
+}: SessionRailProps) {
+ return (
+
+ )
+}
diff --git a/console/web/src/pages/Browser/components/SessionView.tsx b/browser/ui/src/page/SessionView.tsx
similarity index 64%
rename from console/web/src/pages/Browser/components/SessionView.tsx
rename to browser/ui/src/page/SessionView.tsx
index ebdeb4cea..ddb34137f 100644
--- a/console/web/src/pages/Browser/components/SessionView.tsx
+++ b/browser/ui/src/page/SessionView.tsx
@@ -1,9 +1,5 @@
-import { Crosshair, Square, X } from 'lucide-react'
+import { Button, type Host, Input, Tabs, TabsContent, TabsList, TabsTrigger } from '@iii-dev/console-ui'
import { useCallback, useEffect, useRef, useState } from 'react'
-import { Button } from '@/components/ui/Button'
-import { Input } from '@/components/ui/Input'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs'
-import { useBrowserSessionEvent } from '@/hooks/use-browser-events'
import {
BROWSER_PICKED_TRIGGER,
type BrowserClickOptions,
@@ -23,24 +19,29 @@ import {
stopBrowserPick,
stopBrowserSession,
typeBrowserText,
-} from '@/lib/browser'
-import { insertIntoComposer } from '@/lib/composer-insert'
-import { cn } from '@/lib/utils'
-import { useLiveFrames } from '../hooks/useLiveFrames'
+} from '../lib/browser'
+import { cn } from '../lib/cn'
+import { useBrowserSessionEvent } from '../lib/events'
+import { Crosshair, Square, X } from '../lib/icons'
import { ConsolePanel } from './ConsolePanel'
import { NetworkPanel } from './NetworkPanel'
+import { useLiveFrames } from './useLiveFrames'
import { Viewport } from './Viewport'
/**
- * Everything for one selected session: the URL bar, pick-to-chat toggle,
- * stop control, the screencast-fed viewport, and the console / network
- * tabs.
+ * Everything for one selected session: the URL bar, pick-to-clipboard toggle,
+ * stop control, the screencast-fed viewport, and the console / network tabs.
+ *
+ * Pick-to-chat became pick-to-clipboard: the injected UI has no composer slot
+ * (host.composer is unimplemented), so a picked element's summary is copied
+ * to the clipboard for the user to paste into chat.
*/
-const PICKED_FN = 'console::browser-picked'
+const PICKED_FN = 'iii::browser-ui::picked'
const TYPE_FLUSH_MS = 200
interface SessionViewProps {
+ host: Host
session: BrowserSessionInfo
enabled: boolean
onSessionsRefresh: () => void
@@ -48,6 +49,7 @@ interface SessionViewProps {
}
export function SessionView({
+ host,
session,
enabled,
onSessionsRefresh,
@@ -55,7 +57,7 @@ export function SessionView({
}: SessionViewProps) {
const sessionId = session.session_id
const [picking, setPicking] = useState(false)
- const live = useLiveFrames(sessionId, enabled)
+ const live = useLiveFrames(host, sessionId, enabled)
const [actionError, setActionError] = useState(null)
const runAction = useCallback(async (action: () => Promise) => {
@@ -90,13 +92,13 @@ export function SessionView({
// `host:port` like localhost:3000 is not a scheme and must get https://.
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) url = `https://${url}`
void runAction(async () => {
- await navigateBrowser(sessionId, url)
+ await navigateBrowser(host.iii, sessionId, url)
onSessionsRefresh()
})
- }, [urlDraft, sessionId, runAction, onSessionsRefresh])
+ }, [host, urlDraft, sessionId, runAction, onSessionsRefresh])
- // Pick-to-chat. The worker auto-exits inspect mode after one pick, so a
- // received event only flips local state; explicit toggles and unmounts
+ // Pick-to-clipboard. The worker auto-exits inspect mode after one pick, so
+ // a received event only flips local state; explicit toggles and unmounts
// send pick::stop.
const [lastPicked, setLastPicked] = useState(null)
const pickingRef = useRef(false)
@@ -108,22 +110,22 @@ export function SessionView({
setActionError(null)
return () => {
if (pickingRef.current) {
- void stopBrowserPick(sessionId).catch(() => {})
+ void stopBrowserPick(host.iii, sessionId).catch(() => {})
}
}
- }, [sessionId])
+ }, [host, sessionId])
const togglePick = useCallback(() => {
if (picking) {
setPicking(false)
- void runAction(() => stopBrowserPick(sessionId))
+ void runAction(() => stopBrowserPick(host.iii, sessionId))
return
}
void runAction(async () => {
- await startBrowserPick(sessionId)
+ await startBrowserPick(host.iii, sessionId)
setPicking(true)
})
- }, [picking, sessionId, runAction])
+ }, [host, picking, sessionId, runAction])
useEffect(() => {
if (!picking) return
@@ -131,13 +133,14 @@ export function SessionView({
if (e.key !== 'Escape') return
e.preventDefault()
setPicking(false)
- void stopBrowserPick(sessionId).catch(() => {})
+ void stopBrowserPick(host.iii, sessionId).catch(() => {})
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
- }, [picking, sessionId])
+ }, [host, picking, sessionId])
useBrowserSessionEvent({
+ host,
enabled: enabled && picking,
triggerType: BROWSER_PICKED_TRIGGER,
sessionId,
@@ -146,16 +149,20 @@ export function SessionView({
const evt = parsePickedEvent(payload)
if (!evt || evt.session_id !== sessionId) return
setLastPicked(evt)
- insertIntoComposer(formatPickedElement(evt))
+ // No composer slot in injected UI: copy the summary for the user to
+ // paste into chat.
+ void navigator.clipboard
+ ?.writeText(formatPickedElement(evt))
+ .catch(() => {})
setPicking(false)
},
})
const handleClickAt = useCallback(
(x: number, y: number, options?: BrowserClickOptions) => {
- void runAction(() => clickBrowserAt(sessionId, x, y, options))
+ void runAction(() => clickBrowserAt(host.iii, sessionId, x, y, options))
},
- [sessionId, runAction],
+ [host, sessionId, runAction],
)
// Pick-mode click: resolve the element at the exact clicked point (the
@@ -163,16 +170,16 @@ export function SessionView({
// what gets picked.
const handlePickAt = useCallback(
(x: number, y: number) => {
- void runAction(() => resolveBrowserPick(sessionId, x, y))
+ void runAction(() => resolveBrowserPick(host.iii, sessionId, x, y))
},
- [sessionId, runAction],
+ [host, sessionId, runAction],
)
const handleScrollAt = useCallback(
(x: number, y: number, deltaY: number) => {
- void runAction(() => scrollBrowserAt(sessionId, x, y, deltaY))
+ void runAction(() => scrollBrowserAt(host.iii, sessionId, x, y, deltaY))
},
- [sessionId, runAction],
+ [host, sessionId, runAction],
)
// Printable characters batch into one type act per idle window; a special
@@ -190,8 +197,8 @@ export function SessionView({
const flushTypeBuffer = useCallback(() => {
const text = takeTypeBuffer()
if (!text) return
- void runAction(() => typeBrowserText(sessionId, text))
- }, [takeTypeBuffer, sessionId, runAction])
+ void runAction(() => typeBrowserText(host.iii, sessionId, text))
+ }, [host, takeTypeBuffer, sessionId, runAction])
const handleTextInput = useCallback(
(text: string) => {
@@ -206,17 +213,16 @@ export function SessionView({
(key: string) => {
const text = takeTypeBuffer()
void runAction(async () => {
- if (text) await typeBrowserText(sessionId, text)
- await pressBrowserKey(sessionId, key)
+ if (text) await typeBrowserText(host.iii, sessionId, text)
+ await pressBrowserKey(host.iii, sessionId, key)
})
},
- [takeTypeBuffer, sessionId, runAction],
+ [host, takeTypeBuffer, sessionId, runAction],
)
// Flush any buffered text before the session changes or the component
// unmounts, so keystrokes typed against one session are sent to that
- // session rather than dropped (or leaking into the next one). The cleanup
- // captures the flush bound to the session that received the text.
+ // session rather than dropped (or leaking into the next one).
useEffect(() => {
return () => {
flushTypeBuffer()
@@ -224,24 +230,21 @@ export function SessionView({
}, [flushTypeBuffer])
const requestHint = useCallback(
- (x: number, y: number) => hintBrowserPick(sessionId, x, y),
- [sessionId],
+ (x: number, y: number) => hintBrowserPick(host.iii, sessionId, x, y),
+ [host, sessionId],
)
const handleStop = useCallback(() => {
void runAction(async () => {
- await stopBrowserSession(sessionId)
+ await stopBrowserSession(host.iii, sessionId)
onSessionsRefresh()
onStopped()
})
- }, [sessionId, runAction, onSessionsRefresh, onStopped])
+ }, [host, sessionId, runAction, onSessionsRefresh, onStopped])
return (
-
-
+
+
{
if (e.key === 'Enter') submitUrl()
}}
- className="h-8 text-[12px] flex-1 min-w-0"
+ className="br-ui-url-input"
/>
-
{lastPicked ? (
-
-
- picked
-
+
+ picked
-
- {lastPicked.element.ref}
-
-
+ {lastPicked.element.ref}
+
{pickedSelector(lastPicked.element)}
setLastPicked(null)}
aria-label="dismiss picked element"
- className="text-ink-faint hover:text-accent transition-colors shrink-0"
+ className="br-ui-picked-x"
>
-
- sent to the chat composer
-
+ copied to clipboard
) : null}
{actionError ? (
-
- {actionError}
-
+ {actionError}
) : null}
-
-
+
+
console
network
-
-
+
+
-
-
+
+
diff --git a/console/web/src/pages/Browser/components/Viewport.tsx b/browser/ui/src/page/Viewport.tsx
similarity index 85%
rename from console/web/src/pages/Browser/components/Viewport.tsx
rename to browser/ui/src/page/Viewport.tsx
index 10366ce3d..03a7a4a1d 100644
--- a/console/web/src/pages/Browser/components/Viewport.tsx
+++ b/browser/ui/src/page/Viewport.tsx
@@ -3,22 +3,21 @@ import {
type BrowserClickOptions,
type BrowserPickHint,
elementLabel,
-} from '@/lib/browser'
-import { cn } from '@/lib/utils'
-import type { LiveFrame } from '../hooks/useLiveFrames'
+} from '../lib/browser'
+import { cn } from '../lib/cn'
+import type { LiveFrame } from './useLiveFrames'
/**
* The session viewport: the latest screencast frame scaled to fit the pane
* (aspect preserved, centered, letterboxed), acting as a real browser
* surface. Mouse and keyboard map from the rendered image rect to
- * page-viewport space (the frame's width/height against
- * `getBoundingClientRect` at event time), so the mapping survives any pane
- * size: clicks, double clicks, right clicks, wheel scroll, and (while the
- * surface is focused) typing and special keys all forward as
- * `browser::act`. Events in the letterbox margin outside the image do
- * nothing. In pick mode the page is in inspect mode: the forwarded click
- * resolves the pick, and a throttled `browser::pick::hint` drives the
- * client-drawn hover highlight over the image.
+ * page-viewport space, so the mapping survives any pane size: clicks, double
+ * clicks, right clicks, wheel scroll, and (while the surface is focused)
+ * typing and special keys all forward as `browser::act`. Events in the
+ * letterbox margin outside the image do nothing. In pick mode the page is in
+ * inspect mode: the forwarded click resolves the pick, and a throttled
+ * `browser::pick::hint` drives the client-drawn hover highlight over the
+ * image.
*/
const HINT_INTERVAL_MS = 120
@@ -306,12 +305,7 @@ export function Viewport({
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
- className={cn(
- 'relative flex-1 min-h-0 min-w-0 overflow-hidden bg-panel p-3',
- 'flex items-center justify-center',
- picking ? 'cursor-crosshair' : 'cursor-default',
- 'outline-none focus-visible:ring-1 focus-visible:ring-ring',
- )}
+ className={cn('br-ui-vp', picking && 'is-picking')}
>
{frame ? (
) : (
-
+
{error
? `live view failed: ${error}`
: loading
@@ -336,7 +327,7 @@ export function Viewport({
{hint ? (
= 22 ? 'bottom-full mb-0.5' : 'top-full mt-0.5',
+ 'br-ui-vp-hint-label',
+ hint.top >= 22 ? 'above' : 'below',
)}
>
- {hint.label}
- {hint.dims}
+ {hint.label}
+ {hint.dims}
) : null}
diff --git a/browser/ui/src/page/index.tsx b/browser/ui/src/page/index.tsx
new file mode 100644
index 000000000..2e35580c7
--- /dev/null
+++ b/browser/ui/src/page/index.tsx
@@ -0,0 +1,158 @@
+import {
+ Button,
+ EmptyState,
+ type Host,
+ StatusDot,
+ StatusPanel,
+} from '@iii-dev/console-ui'
+import { useEffect, useMemo, useState } from 'react'
+import { errorMessage, startBrowserSession } from '../lib/browser'
+import { cn } from '../lib/cn'
+import { AlertCircle, Globe, Plus, RefreshCw } from '../lib/icons'
+import { SessionRail } from './SessionRail'
+import { SessionView } from './SessionView'
+import { useBrowserSessionsLive } from './useBrowserSessionsLive'
+
+/**
+ * The browser page (#/ext/browser): the browser worker's live surface — a
+ * session rail, a screencast-fed viewport, and console/network feeds for the
+ * selected session, so a user can watch what an agent is doing in a Chromium
+ * session and pick elements into the clipboard.
+ *
+ * The host only mounts this page while the browser worker is connected, so
+ * there is no presence gate here (worker disconnect disposes the script and
+ * drops the nav entry). Session selection is page-local state — the injected
+ * page owns its own view, and the host owns the `#/ext/browser` route.
+ */
+
+export function BrowserPage({ host }: { host: Host }) {
+ const { sessions, loading, error, live, refresh } = useBrowserSessionsLive(
+ host,
+ true,
+ )
+
+ const [selectedId, setSelectedId] = useState(null)
+ const selected = useMemo(
+ () => sessions.find((s) => s.session_id === selectedId) ?? null,
+ [sessions, selectedId],
+ )
+
+ // Auto-select the first session when nothing (or a stopped session) is
+ // selected, and clear a selection whose session is gone.
+ useEffect(() => {
+ if (loading) return
+ if (selectedId && sessions.some((s) => s.session_id === selectedId)) return
+ const next = sessions[0]?.session_id ?? null
+ if (next !== selectedId) setSelectedId(next)
+ }, [loading, sessions, selectedId])
+
+ const [starting, setStarting] = useState(false)
+ const [startError, setStartError] = useState(null)
+ const handleNewSession = async () => {
+ if (starting) return
+ setStarting(true)
+ try {
+ const started = await startBrowserSession(host.iii)
+ setStartError(null)
+ refresh()
+ if (started) setSelectedId(started.session_id)
+ } catch (err) {
+ setStartError(errorMessage(err))
+ } finally {
+ setStarting(false)
+ }
+ }
+
+ const countLabel = loading ? '...' : String(sessions.length)
+
+ return (
+
+
+
+ browser
+ {countLabel} sessions
+
+
+
+
+ {live ? 'live' : 'polling'}
+
+
+
+ refresh
+
+ void handleNewSession()}
+ disabled={starting}
+ >
+
+ {starting ? 'starting...' : 'new session'}
+
+
+
+
+ {error ? (
+
+ }
+ headline="failed to load browser sessions"
+ detail={error}
+ />
+
+ ) : !loading && sessions.length === 0 ? (
+
+ {startError ? (
+ }
+ headline="could not start a session"
+ detail={startError}
+ />
+ ) : null}
+
+
+ ) : (
+
+
+ {selected ? (
+ setSelectedId(null)}
+ />
+ ) : (
+
+ select a session
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/console/web/src/pages/Browser/hooks/useBrowserSessionsLive.ts b/browser/ui/src/page/useBrowserSessionsLive.ts
similarity index 71%
rename from console/web/src/pages/Browser/hooks/useBrowserSessionsLive.ts
rename to browser/ui/src/page/useBrowserSessionsLive.ts
index 0e81972b6..37cf76ec9 100644
--- a/console/web/src/pages/Browser/hooks/useBrowserSessionsLive.ts
+++ b/browser/ui/src/page/useBrowserSessionsLive.ts
@@ -1,17 +1,18 @@
+import type { Host } from '@iii-dev/console-ui'
import { useCallback, useEffect, useState } from 'react'
-import { useBrowserLifecycleEvents } from '@/hooks/use-browser-events'
import {
type BrowserSessionInfo,
errorMessage,
listBrowserSessions,
-} from '@/lib/browser'
+} from '../lib/browser'
+import { useBrowserLifecycleEvents } from '../lib/events'
/**
- * Live session feed for the Browser page: `browser::sessions::list`,
- * re-read on session-started / session-stopped / navigated. While the event
- * bindings are unavailable (SDK hiccup, races around worker restart) a
- * modest poll keeps the rail honest, skipped entirely while the tab is
- * hidden so a backgrounded console never hammers the engine.
+ * Live session feed for the browser page: `browser::sessions::list`, re-read
+ * on session-started / session-stopped / navigated. While the event bindings
+ * are unavailable (SDK hiccup, races around worker restart) a modest poll
+ * keeps the rail honest, skipped entirely while the tab is hidden so a
+ * backgrounded console never hammers the engine.
*/
export const BROWSER_SESSIONS_POLL_MS = 10_000
@@ -25,7 +26,10 @@ export interface BrowserSessionsLive {
refresh: () => void
}
-export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive {
+export function useBrowserSessionsLive(
+ host: Host,
+ enabled: boolean,
+): BrowserSessionsLive {
const [sessions, setSessions] = useState([])
const [loading, setLoading] = useState(enabled)
const [error, setError] = useState(null)
@@ -34,6 +38,7 @@ export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive {
const refresh = useCallback(() => setToken((t) => t + 1), [])
const { bound } = useBrowserLifecycleEvents({
+ host,
enabled,
onEvent: refresh,
})
@@ -48,7 +53,7 @@ export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive {
let cancelled = false
void (async () => {
try {
- const next = await listBrowserSessions()
+ const next = await listBrowserSessions(host.iii)
if (cancelled) return
setSessions(next)
setError(null)
@@ -62,7 +67,7 @@ export function useBrowserSessionsLive(enabled: boolean): BrowserSessionsLive {
return () => {
cancelled = true
}
- }, [enabled, token])
+ }, [host, enabled, token])
useEffect(() => {
if (!enabled || bound) return
diff --git a/console/web/src/pages/Browser/hooks/useLiveFrames.ts b/browser/ui/src/page/useLiveFrames.ts
similarity index 87%
rename from console/web/src/pages/Browser/hooks/useLiveFrames.ts
rename to browser/ui/src/page/useLiveFrames.ts
index 32972d929..4ca628297 100644
--- a/console/web/src/pages/Browser/hooks/useLiveFrames.ts
+++ b/browser/ui/src/page/useLiveFrames.ts
@@ -1,5 +1,5 @@
+import type { Host } from '@iii-dev/console-ui'
import { useEffect, useRef, useState } from 'react'
-import { useBrowserStream } from '@/hooks/use-browser-events'
import {
BROWSER_FRAMES_STREAM,
extractStreamFrame,
@@ -7,7 +7,8 @@ import {
startBrowserScreencast,
stopBrowserScreencast,
takeBrowserScreenshot,
-} from '@/lib/browser'
+} from '../lib/browser'
+import { useBrowserStream } from '../lib/events'
/**
* Live view for the selected session, fed by the worker's screencast stream:
@@ -36,6 +37,7 @@ export interface LiveViewState {
}
export function useLiveFrames(
+ host: Host,
sessionId: string | null,
enabled: boolean,
): LiveViewState {
@@ -60,7 +62,7 @@ export function useLiveFrames(
// Retain the start so teardown can wait for it to settle before stopping;
// otherwise a late start could reactivate the screencast after cleanup.
- const started = startBrowserScreencast(sessionId)
+ const started = startBrowserScreencast(host.iii, sessionId)
void (async () => {
try {
@@ -68,7 +70,9 @@ export function useLiveFrames(
} catch {
// Older worker without the screencast surface: one screenshot so the
// viewport is not blank.
- const shot = await takeBrowserScreenshot(sessionId).catch(() => null)
+ const shot = await takeBrowserScreenshot(host.iii, sessionId).catch(
+ () => null,
+ )
if (!cancelled && shot?.dataUrl) {
setFrame({
dataUrl: shot.dataUrl,
@@ -81,7 +85,7 @@ export function useLiveFrames(
if (cancelled) return
// Immediate first paint: the stream only delivers frames produced after
// the subscription, so read the current one once.
- const seed = await readBrowserFrame(sessionId).catch(() => null)
+ const seed = await readBrowserFrame(host.iii, sessionId).catch(() => null)
if (cancelled || !seed?.frame) return
if (seed.frame_seq > lastSeqRef.current) {
lastSeqRef.current = seed.frame_seq
@@ -100,16 +104,17 @@ export function useLiveFrames(
// overtaken by an in-flight start reactivating the screencast.
void started
.catch(() => {})
- .then(() => stopBrowserScreencast(sessionId))
+ .then(() => stopBrowserScreencast(host.iii, sessionId))
.catch(() => {})
}
- }, [enabled, sessionId])
+ }, [host, enabled, sessionId])
useBrowserStream({
+ host,
enabled: enabled && !!sessionId,
streamName: BROWSER_FRAMES_STREAM,
groupId: sessionId,
- fnId: 'console::browser-frames',
+ fnId: 'iii::browser-ui::frames',
onFrame: (payload) => {
const f = extractStreamFrame(payload)
if (!f || f.frame_seq <= lastSeqRef.current) return
diff --git a/browser/ui/styles.css b/browser/ui/styles.css
new file mode 100644
index 000000000..d5e955471
--- /dev/null
+++ b/browser/ui/styles.css
@@ -0,0 +1,863 @@
+/*
+ * Styles for the browser worker's injected console UI. Shipped as its own
+ * `console:style` asset (browser/styles.css) — the console mounts it as a
+ * and link-swaps it on change.
+ *
+ * EVERY rule is scoped under [data-iii-ui="browser"] — the wrapper the console
+ * mounts around every injected render (page, function-trigger card alike).
+ * Unscoped rules would beat the console's layered CSS document-wide (injected
+ * sheets are unlayered). Keyframe names are global: keep the br-ui- prefix.
+ * Colors come from the console's design tokens so light/dark theming is free.
+ */
+
+[data-iii-ui="browser"] .br-ui-page,
+[data-iii-ui="browser"] .br-ui-call,
+[data-iii-ui="browser"] .br-ui-err {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ color: var(--color-ink);
+ box-sizing: border-box;
+}
+[data-iii-ui="browser"] .br-ui-page *,
+[data-iii-ui="browser"] .br-ui-page *::before,
+[data-iii-ui="browser"] .br-ui-page *::after {
+ box-sizing: border-box;
+}
+
+@keyframes br-ui-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes br-ui-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.4;
+ }
+}
+[data-iii-ui="browser"] .br-ui-spin {
+ animation: br-ui-spin 1s linear infinite;
+}
+
+/* --- page shell ------------------------------------------------------ */
+[data-iii-ui="browser"] .br-ui-page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+[data-iii-ui="browser"] .br-ui-page-head {
+ flex-shrink: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 24px;
+ border-bottom: 1px solid var(--color-rule);
+}
+[data-iii-ui="browser"] .br-ui-page-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ text-transform: lowercase;
+}
+[data-iii-ui="browser"] .br-ui-page-sub {
+ margin: 2px 0 0;
+ font-size: 12px;
+ color: var(--color-ink-faint);
+ text-transform: lowercase;
+}
+[data-iii-ui="browser"] .br-ui-page-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+[data-iii-ui="browser"] .br-ui-live {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ text-transform: lowercase;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-page-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 16px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+/* --- session split --------------------------------------------------- */
+[data-iii-ui="browser"] .br-ui-split {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+[data-iii-ui="browser"] .br-ui-aside {
+ width: 288px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--color-rule);
+ overflow-y: auto;
+}
+[data-iii-ui="browser"] .br-ui-aside-err {
+ margin: 0;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--color-rule-2);
+ font-size: 11px;
+ color: var(--color-alert);
+}
+[data-iii-ui="browser"] .br-ui-page-placeholder {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ text-transform: lowercase;
+ color: var(--color-ink-ghost);
+}
+
+/* --- session rail ---------------------------------------------------- */
+[data-iii-ui="browser"] .br-ui-rail {
+ display: flex;
+ flex-direction: column;
+}
+[data-iii-ui="browser"] .br-ui-rail-row {
+ width: 100%;
+ text-align: left;
+ border: none;
+ border-bottom: 1px solid var(--color-rule-2);
+ background: transparent;
+ padding: 10px 12px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.12s;
+}
+[data-iii-ui="browser"] .br-ui-rail-row:hover {
+ background: var(--color-paper-2);
+}
+[data-iii-ui="browser"] .br-ui-rail-row.is-selected {
+ background: var(--color-panel);
+}
+[data-iii-ui="browser"] .br-ui-rail-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+[data-iii-ui="browser"] .br-ui-rail-icon {
+ flex-shrink: 0;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-rail-icon.is-selected {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-rail-title {
+ font-size: 12px;
+ color: var(--color-ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-iii-ui="browser"] .br-ui-rail-url {
+ display: flex;
+ margin-top: 2px;
+ min-width: 0;
+ font-size: 11px;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-rail-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 2px;
+ font-size: 10px;
+ text-transform: lowercase;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+[data-iii-ui="browser"] .br-ui-num {
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- session view ---------------------------------------------------- */
+[data-iii-ui="browser"] .br-ui-session {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+}
+[data-iii-ui="browser"] .br-ui-toolbar {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--color-rule);
+}
+[data-iii-ui="browser"] .br-ui-url-input {
+ flex: 1;
+ min-width: 0;
+}
+[data-iii-ui="browser"] .br-ui-pick-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 10px;
+ font-family: inherit;
+ font-size: 12px;
+ text-transform: lowercase;
+ border: 1px solid var(--color-rule);
+ background: transparent;
+ color: var(--color-ink-faint);
+ cursor: pointer;
+ transition:
+ color 0.12s,
+ border-color 0.12s,
+ background 0.12s;
+}
+[data-iii-ui="browser"] .br-ui-pick-btn:hover {
+ color: var(--color-ink);
+ border-color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-pick-btn.is-on {
+ background: var(--color-accent);
+ color: var(--color-accent-fg);
+ border-color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-picked {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--color-rule-2);
+ background: var(--color-paper-2);
+}
+[data-iii-ui="browser"] .br-ui-picked-label {
+ flex-shrink: 0;
+ font-size: 11px;
+ text-transform: lowercase;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-picked-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ border: 1px solid var(--color-rule);
+ background: var(--color-bg);
+ padding: 2px 8px;
+ font-size: 12px;
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-picked-ref {
+ flex-shrink: 0;
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-picked-x {
+ flex-shrink: 0;
+ border: none;
+ background: transparent;
+ color: var(--color-ink-faint);
+ cursor: pointer;
+ display: inline-flex;
+ transition: color 0.12s;
+}
+[data-iii-ui="browser"] .br-ui-picked-x:hover {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-picked-note {
+ font-size: 11px;
+ text-transform: lowercase;
+ color: var(--color-ink-ghost);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-iii-ui="browser"] .br-ui-session-err {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--color-rule-2);
+ font-size: 12px;
+ color: var(--color-alert);
+}
+
+/* --- viewport -------------------------------------------------------- */
+[data-iii-ui="browser"] .br-ui-vp {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+ background: var(--color-panel);
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: default;
+ outline: none;
+}
+[data-iii-ui="browser"] .br-ui-vp.is-picking {
+ cursor: crosshair;
+}
+[data-iii-ui="browser"] .br-ui-vp:focus-visible {
+ outline: 1px solid var(--color-ring);
+ outline-offset: -1px;
+}
+[data-iii-ui="browser"] .br-ui-vp-img {
+ display: block;
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ user-select: none;
+ border: 1px solid var(--color-rule);
+ background: var(--color-bg);
+}
+[data-iii-ui="browser"] .br-ui-vp-img.is-picking {
+ border-color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-vp-empty {
+ margin: 0;
+ font-size: 12px;
+ text-transform: lowercase;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-vp-hint {
+ position: absolute;
+ pointer-events: none;
+ border: 1px solid var(--color-accent);
+ background: color-mix(in srgb, var(--color-accent) 15%, transparent);
+}
+[data-iii-ui="browser"] .br-ui-vp-hint-label {
+ position: absolute;
+ left: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+ background: var(--color-ink);
+ color: var(--color-bg);
+ padding: 2px 6px;
+ font-size: 10px;
+ line-height: 1;
+}
+[data-iii-ui="browser"] .br-ui-vp-hint-label.above {
+ bottom: 100%;
+ margin-bottom: 2px;
+}
+[data-iii-ui="browser"] .br-ui-vp-hint-label.below {
+ top: 100%;
+ margin-top: 2px;
+}
+[data-iii-ui="browser"] .br-ui-vp-hint-tag {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-vp-hint-dims {
+ opacity: 0.7;
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- tabs + panels --------------------------------------------------- */
+[data-iii-ui="browser"] .br-ui-tabs {
+ flex-shrink: 0;
+ height: 38%;
+ min-height: 176px;
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--color-rule);
+}
+[data-iii-ui="browser"] .br-ui-tabs-list {
+ flex-shrink: 0;
+ padding-left: 12px;
+ padding-right: 12px;
+}
+[data-iii-ui="browser"] .br-ui-tabs-content {
+ flex: 1;
+ min-height: 0;
+}
+[data-iii-ui="browser"] .br-ui-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+[data-iii-ui="browser"] .br-ui-panel-head {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--color-rule-2);
+}
+[data-iii-ui="browser"] .br-ui-filter-input {
+ max-width: 280px;
+}
+[data-iii-ui="browser"] .br-ui-panel-note {
+ font-size: 11px;
+ text-transform: lowercase;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-panel-err {
+ margin: 0;
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--color-alert);
+}
+[data-iii-ui="browser"] .br-ui-panel-empty {
+ margin: 0;
+ padding: 8px 12px;
+ font-size: 12px;
+ text-transform: lowercase;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-feed {
+ flex: 1;
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column-reverse;
+}
+[data-iii-ui="browser"] .br-ui-toggle {
+ height: 28px;
+ padding: 0 10px;
+ font-family: inherit;
+ font-size: 11px;
+ text-transform: lowercase;
+ border: 1px solid var(--color-rule);
+ background: transparent;
+ color: var(--color-ink-faint);
+ cursor: pointer;
+ transition:
+ color 0.12s,
+ border-color 0.12s,
+ background 0.12s;
+}
+[data-iii-ui="browser"] .br-ui-toggle:hover {
+ color: var(--color-ink);
+ border-color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-toggle.is-on {
+ background: var(--color-ink);
+ color: var(--color-bg);
+ border-color: var(--color-ink);
+}
+
+/* network rows (live panel) */
+[data-iii-ui="browser"] .br-ui-nrow {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 4px 12px;
+ border-top: 1px solid var(--color-rule-2);
+ font-size: 12px;
+ line-height: 1.55;
+}
+[data-iii-ui="browser"] .br-ui-nrow-time {
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-nrow-status {
+ flex-shrink: 0;
+ width: 42px;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-nrow-status.is-failed {
+ color: var(--color-alert);
+}
+[data-iii-ui="browser"] .br-ui-nrow-method {
+ flex-shrink: 0;
+ width: 56px;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-nrow-url {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-nrow-mime {
+ flex-shrink: 0;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-alert {
+ color: var(--color-alert);
+}
+
+/* --- function-trigger card ------------------------------------------- */
+[data-iii-ui="browser"] .br-ui-call {
+ display: flex;
+ flex-direction: column;
+ background: var(--color-bg);
+}
+[data-iii-ui="browser"] .br-ui-call-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background: var(--color-paper-2);
+ border-bottom: 1px solid var(--color-rule-2);
+ font-size: 11px;
+ text-transform: lowercase;
+}
+[data-iii-ui="browser"] .br-ui-call-session {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-call-sid {
+ color: var(--color-ink);
+ font-variant-numeric: tabular-nums;
+}
+[data-iii-ui="browser"] .br-ui-call-link {
+ margin-left: auto;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-ink-faint);
+ text-decoration: none;
+ transition: color 0.12s;
+}
+[data-iii-ui="browser"] .br-ui-call-link:hover {
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-call-running {
+ margin: 0;
+ padding: 8px 12px;
+ font-size: 12px;
+ text-transform: lowercase;
+ color: var(--color-ink-faint);
+ animation: br-ui-pulse 1.6s ease-in-out infinite;
+}
+
+/* --- shared card primitives ------------------------------------------ */
+[data-iii-ui="browser"] .br-ui-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--color-rule-2);
+ background: var(--color-paper-2);
+}
+[data-iii-ui="browser"] .br-ui-pill {
+ text-transform: none;
+ letter-spacing: normal;
+}
+[data-iii-ui="browser"] .br-ui-chip {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--color-rule-2);
+ background: var(--color-paper-2);
+ border-radius: 2px;
+ padding: 2px 6px;
+ font-size: 10px;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-chip-accent {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-chip-num {
+ font-variant-numeric: tabular-nums;
+}
+[data-iii-ui="browser"] .br-ui-chip-warn {
+ color: var(--color-warn);
+ border-color: color-mix(in srgb, var(--color-warn) 40%, transparent);
+}
+[data-iii-ui="browser"] .br-ui-action-line {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--color-rule-2);
+ background: var(--color-bg);
+}
+[data-iii-ui="browser"] .br-ui-action-sym {
+ flex-shrink: 0;
+ font-weight: 600;
+}
+[data-iii-ui="browser"] .br-ui-action-sym.tone-accent {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-action-sym.tone-warn {
+ color: var(--color-warn);
+}
+[data-iii-ui="browser"] .br-ui-action-sym.tone-ink {
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-action-body {
+ min-width: 0;
+ color: var(--color-ink);
+ word-break: break-all;
+}
+[data-iii-ui="browser"] .br-ui-break {
+ word-break: break-all;
+}
+[data-iii-ui="browser"] .br-ui-hl {
+ background: color-mix(in srgb, var(--color-accent) 15%, transparent);
+ color: var(--color-accent);
+}
+
+/* snapshot / dom trees */
+[data-iii-ui="browser"] .br-ui-tree {
+ margin: 0;
+ max-height: 384px;
+ overflow: auto;
+ padding: 8px 12px;
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--color-ink);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+[data-iii-ui="browser"] .br-ui-dom {
+ max-height: 384px;
+ overflow: auto;
+ padding: 8px 12px;
+ font-size: 12px;
+ line-height: 1.55;
+}
+[data-iii-ui="browser"] .br-ui-dom-row {
+ white-space: nowrap;
+}
+[data-iii-ui="browser"] .br-ui-dom-text {
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-dom-el {
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-dom-ref {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-dom-more {
+ color: var(--color-ink-ghost);
+}
+
+/* tables + lists in cards */
+[data-iii-ui="browser"] .br-ui-empty-line {
+ padding: 12px;
+ font-size: 12.5px;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-vtable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11.5px;
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-vtable tr {
+ border-bottom: 1px solid var(--color-rule-2);
+}
+[data-iii-ui="browser"] .br-ui-vtable tr:last-child {
+ border-bottom: none;
+}
+[data-iii-ui="browser"] .br-ui-td {
+ padding: 4px 12px;
+}
+[data-iii-ui="browser"] .br-ui-td-accent {
+ color: var(--color-accent);
+}
+[data-iii-ui="browser"] .br-ui-td-dim {
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-td-name {
+ width: 45%;
+}
+[data-iii-ui="browser"] .br-ui-nowrap {
+ white-space: nowrap;
+}
+[data-iii-ui="browser"] .br-ui-right {
+ text-align: right;
+}
+[data-iii-ui="browser"] .br-ui-scroll {
+ max-height: 320px;
+ overflow: auto;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+[data-iii-ui="browser"] .br-ui-json {
+ max-height: 256px;
+ overflow: auto;
+}
+[data-iii-ui="browser"] .br-ui-json-sm {
+ max-height: 320px;
+ overflow: auto;
+}
+[data-iii-ui="browser"] .br-ui-inline-style {
+ padding: 6px 12px;
+ border-top: 1px solid var(--color-rule-2);
+ font-size: 11.5px;
+ color: var(--color-ink-faint);
+ word-break: break-all;
+}
+[data-iii-ui="browser"] .br-ui-eval-err {
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--color-alert);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+/* console log rows (shared by card + live panel) */
+[data-iii-ui="browser"] .br-ui-log-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ border-bottom: 1px solid var(--color-rule-2);
+ padding: 4px 12px;
+ font-size: 12px;
+ line-height: 1.55;
+}
+[data-iii-ui="browser"] .br-ui-log-row:last-child {
+ border-bottom: none;
+}
+[data-iii-ui="browser"] .br-ui-log-time {
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-ink-ghost);
+}
+[data-iii-ui="browser"] .br-ui-log-level {
+ width: 72px;
+ flex-shrink: 0;
+}
+[data-iii-ui="browser"] .br-ui-log-text {
+ min-width: 0;
+ flex: 1;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-dim {
+ color: var(--color-ink-ghost);
+}
+
+/* network rows (card) */
+[data-iii-ui="browser"] .br-ui-net-status {
+ width: 42px;
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-net-status.is-failed {
+ color: var(--color-alert);
+}
+[data-iii-ui="browser"] .br-ui-net-method {
+ width: 56px;
+ flex-shrink: 0;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-net-url {
+ min-width: 0;
+ flex: 1;
+ word-break: break-all;
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-net-url.is-failed {
+ color: var(--color-alert);
+}
+
+/* screenshot body */
+[data-iii-ui="browser"] .br-ui-shot {
+ padding: 12px;
+}
+[data-iii-ui="browser"] .br-ui-shot-img {
+ display: block;
+ max-width: 100%;
+ max-height: 320px;
+ border: 1px solid var(--color-rule);
+}
+
+/* --- infra error view ------------------------------------------------ */
+[data-iii-ui="browser"] .br-ui-err {
+ border-top: 1px solid var(--color-rule-2);
+ border-left: 2px solid var(--color-warn);
+ background: var(--color-bg);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+[data-iii-ui="browser"] .br-ui-err-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+[data-iii-ui="browser"] .br-ui-err-code {
+ border: 1px solid var(--color-warn);
+ padding: 2px 6px;
+}
+[data-iii-ui="browser"] .br-ui-err-type {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-err-msg {
+ margin: 0;
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: var(--color-ink);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+[data-iii-ui="browser"] .br-ui-err-detail {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--color-ink-faint);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+[data-iii-ui="browser"] .br-ui-err-note {
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--color-ink-faint);
+}
+[data-iii-ui="browser"] .br-ui-err-body {
+ font-size: 12.5px;
+ line-height: 1.6;
+ color: var(--color-ink);
+}
+[data-iii-ui="browser"] .br-ui-err-docs {
+ width: fit-content;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-accent);
+ text-decoration: none;
+}
+[data-iii-ui="browser"] .br-ui-err-docs:hover {
+ text-decoration: underline;
+}
+[data-iii-ui="browser"] .br-ui-code {
+ color: var(--color-ink);
+}
diff --git a/browser/ui/tsconfig.json b/browser/ui/tsconfig.json
new file mode 100644
index 000000000..e5ac60540
--- /dev/null
+++ b/browser/ui/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": []
+ },
+ "include": ["page.tsx", "src"]
+}
diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx
index bfd1b9c36..7e64b1d13 100644
--- a/console/web/src/App.tsx
+++ b/console/web/src/App.tsx
@@ -26,7 +26,6 @@ import {
import { buildViewOptions } from '@/lib/nav-options'
import { type RegisteredPage, useExtPages } from '@/lib/ui-slots'
import { cn } from '@/lib/utils'
-import { Browser } from '@/pages/Browser'
import { Configuration } from '@/pages/Configuration'
import { ExtPage } from '@/pages/Ext'
import { Github } from '@/pages/Github'
@@ -108,8 +107,6 @@ export function App() {
) : view === 'workers' ? (
- ) : view === 'browser' ? (
-
) : view === 'github' ? (
) : view === 'ext' ? (
@@ -150,18 +147,15 @@ function Header({
onOpenShortcuts,
}: HeaderProps) {
// Optional-worker entries appear only while their worker is present; a
- // direct #/browser hit still lands on that page's install notice.
- // `memoryAvailable` stays on the context for the chat composer's bank
- // selector; the memory page is now injected UI, so it is no longer a
- // first-party nav option here.
- const { browserAvailable, githubAvailable } =
- useConversationsCtx()
+ // Only github remains a first-party nav entry; worktrees, memory and browser
+ // moved to injected UI (gated by script presence, not a nav flag).
+ const { githubAvailable } = useConversationsCtx()
// Injected pages: the runtime analogue of worker-presence gating —
// presence is the script being loaded, which already tracks worker
// connectedness via trigger GC.
const extPages = useExtPages()
const viewOptions: { value: string; label: string }[] = [
- ...buildViewOptions(browserAvailable, githubAvailable),
+ ...buildViewOptions(githubAvailable),
...extPages.map((page) => ({
value: extNavValue(page),
label: page.title,
diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx
index e68b7a4ec..5c51d1892 100644
--- a/console/web/src/components/chat/ChatView.tsx
+++ b/console/web/src/components/chat/ChatView.tsx
@@ -14,7 +14,6 @@ import {
isChatBlockedByHarness,
isHarnessAvailable,
} from '@/hooks/use-harness-status'
-import { hashForBrowserSession } from '@/hooks/use-hash-route'
import { useLiveAnnouncer } from '@/hooks/use-live-announcer'
import { useWorktreeBinding } from '@/hooks/use-worktree-binding'
import { useWorktreeEvents } from '@/hooks/use-worktree-events'
@@ -30,10 +29,6 @@ import type {
CompactResult,
QueuedMessagePreview,
} from '@/lib/backend/types'
-import {
- BROWSER_SESSIONS_START_FUNCTION_ID,
- browserSessionIdFromCall,
-} from '@/lib/browser'
import { useConversationsCtxOptional } from '@/lib/conversations-context'
import { expandFileMentions, parseFileMentions } from '@/lib/file-mentions'
import { formatStopReason } from '@/lib/format-stop-reason'
@@ -1466,55 +1461,6 @@ export function ChatView({
onLandBlocked: handleLandBlocked,
})
- // Lightweight notice when a browser session starts in this conversation,
- // pointing at the Browser tab. Sourced from the transcript itself (the
- // worker's session-started trigger carries no conversation identity): the
- // first pass over a hydrated transcript only seeds the seen-set, so
- // reloads never re-announce old sessions.
- const browserEnabled =
- backend.id === 'real' &&
- (conversationsCtx ? conversationsCtx.browserAvailable : false)
- const browserNoticesRef = useRef<{ seeded: boolean; seen: Set }>({
- seeded: false,
- seen: new Set(),
- })
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset the tracker when the conversation changes
- useEffect(() => {
- browserNoticesRef.current = { seeded: false, seen: new Set() }
- }, [conversation.id])
- useEffect(() => {
- if (!browserEnabled) return
- if (!conversation.draft && !conversation.hydrated) return
- const tracker = browserNoticesRef.current
- const fresh: string[] = []
- for (const m of conversation.messages) {
- if (m.role !== 'function-trigger') continue
- if (m.functionId !== BROWSER_SESSIONS_START_FUNCTION_ID) continue
- if (m.running || m.pendingApproval) continue
- if (tracker.seen.has(m.id)) continue
- const browserSessionId = browserSessionIdFromCall(m.input, m.output)
- if (!browserSessionId) continue
- tracker.seen.add(m.id)
- if (tracker.seeded) fresh.push(browserSessionId)
- }
- tracker.seeded = true
- for (const browserSessionId of fresh) {
- onAppendMessage(
- conversation.id,
- makeSystemNotice(
- `browser session ${browserSessionId} started, watch it live in the browser tab (${hashForBrowserSession(browserSessionId)})`,
- ),
- )
- }
- }, [
- browserEnabled,
- conversation.messages,
- conversation.id,
- conversation.draft,
- conversation.hydrated,
- onAppendMessage,
- ])
-
// Re-scope the working directory. Allowed mid-conversation (no irreversible
// lock); a change after the chat has started drops a visible marker so the
// directory the agent operates in is never silently swapped.
diff --git a/console/web/src/components/function-trigger/renderer-registry.tsx b/console/web/src/components/function-trigger/renderer-registry.tsx
index 4afcfee27..104ed5718 100644
--- a/console/web/src/components/function-trigger/renderer-registry.tsx
+++ b/console/web/src/components/function-trigger/renderer-registry.tsx
@@ -10,10 +10,6 @@
*/
import { useMemo } from 'react'
-import {
- BrowserFunctionIdLabel,
- BrowserToolView,
-} from '@/components/chat/browser'
import { CoderFunctionIdLabel, CoderToolView } from '@/components/chat/coder'
import { EngineFunctionIdLabel, EngineToolView } from '@/components/chat/engine'
import { FpFunctionIdLabel, FpToolView } from '@/components/chat/fp'
@@ -44,9 +40,10 @@ import type { FunctionTriggerMessage } from '@/types/chat'
import type { FunctionTriggerRenderer } from '@/types/injectable-ui'
/**
- * The first-party families (12 since directory moved into its worker's injected UI), in the exact order of the old `??` chains.
- * Each family's `tryRender*` already gates on its own function ids, so an
- * entry returning `null` falls through to the next.
+ * The first-party families (11 since directory and browser moved into their
+ * workers' injected UI), in the exact order of the old `??` chains. Each
+ * family's `tryRender*` already gates on its own function ids, so an entry
+ * returning `null` falls through to the next.
*/
export const FIRST_PARTY_RENDERERS: readonly FunctionTriggerRenderer[] = [
{
@@ -147,14 +144,6 @@ export const FIRST_PARTY_RENDERERS: readonly FunctionTriggerRenderer[] = [
tryRenderPreview: StateToolView.tryRenderPreview,
FunctionIdLabel: StateFunctionIdLabel,
},
- {
- id: 'first-party/browser',
- isMatch: BrowserToolView.isBrowserFunction,
- tryRender: BrowserToolView.tryRender,
- tryRenderRunning: BrowserToolView.tryRenderRunning,
- tryRenderPreview: BrowserToolView.tryRenderPreview,
- FunctionIdLabel: BrowserFunctionIdLabel,
- },
]
/**
diff --git a/console/web/src/hooks/use-browser-events.ts b/console/web/src/hooks/use-browser-events.ts
deleted file mode 100644
index 35d2ed284..000000000
--- a/console/web/src/hooks/use-browser-events.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import { useEffect, useId, useRef, useState } from 'react'
-import { BROWSER_LIFECYCLE_TRIGGERS } from '@/lib/browser'
-import { getIiiClient } from '@/lib/iii-client'
-
-/**
- * Browser-local bindings to the browser worker's custom trigger types,
- * following the worktree-events pattern: `client.on(fnId)` plus
- * `client.registerTrigger` targeting `::`. The per-mount
- * `instanceId` keeps two hook instances from colliding on the registered
- * function name.
- *
- * Registration is wrapped in try/catch: with the worker absent (its trigger
- * types unregistered) the binding drops silently; callers already gate
- * `enabled` on browser presence, this is defense-in-depth for races.
- */
-
-const LIFECYCLE_FN = 'console::browser-lifecycle'
-
-export interface UseBrowserLifecycleEventsOptions {
- /** Only subscribe on the real backend with the browser worker present. */
- enabled: boolean
- /** Fired for every session lifecycle event, with its trigger type. */
- onEvent: (triggerType: string, payload: unknown) => void
-}
-
-export interface BrowserLifecycleSubscription {
- /**
- * True once all three trigger bindings registered. While false (probe in
- * flight, worker absent, SDK failure) callers fall back to polling.
- */
- bound: boolean
-}
-
-/**
- * Page-scoped feed of the session lifecycle trigger types
- * (session-started / session-stopped / navigated), for surfaces that
- * re-read the session list on any change.
- */
-export function useBrowserLifecycleEvents(
- opts: UseBrowserLifecycleEventsOptions,
-): BrowserLifecycleSubscription {
- const { enabled } = opts
- const onEventRef = useRef(opts.onEvent)
- onEventRef.current = opts.onEvent
-
- const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '')
- const [bound, setBound] = useState(false)
-
- useEffect(() => {
- if (!enabled) {
- setBound(false)
- return
- }
- let cancelled = false
- const offs: Array<() => void> = []
-
- void (async () => {
- let client: Awaited>
- try {
- client = await getIiiClient()
- } catch {
- // Client startup failed; stay unbound so callers fall back to polling.
- return
- }
- if (cancelled) return
- let registered = 0
- for (const triggerType of BROWSER_LIFECYCLE_TRIGGERS) {
- const suffix = triggerType.replace(/[^a-zA-Z0-9]/g, '-')
- const localFnId = `${LIFECYCLE_FN}::${suffix}::${instanceId}`
- try {
- offs.push(
- client.on(localFnId, (payload: unknown) => {
- onEventRef.current(triggerType, payload)
- }),
- )
- offs.push(
- client.registerTrigger({
- type: triggerType,
- function_id: `${localFnId}::${client.browserId}`,
- config: {},
- }),
- )
- registered += 1
- } catch {
- // Worker absent or trigger type unregistered; drop the binding.
- }
- }
- if (!cancelled) {
- setBound(registered === BROWSER_LIFECYCLE_TRIGGERS.length)
- }
- })()
-
- return () => {
- cancelled = true
- setBound(false)
- for (const off of offs) off()
- }
- }, [enabled, instanceId])
-
- return { bound }
-}
-
-export interface UseBrowserSessionEventOptions {
- /** Only subscribe on the real backend with the browser worker present. */
- enabled: boolean
- /** Trigger type to bind (e.g. `browser::console-event`). */
- triggerType: string
- /** Session the binding filters to (worker-side `session_id` filter). */
- sessionId: string | null
- /** Base id for this binding's browser-local handler. */
- fnId: string
- onEvent: (payload: unknown) => void
-}
-
-/**
- * One session-filtered binding to a browser trigger type (console-event or
- * picked). Rebinds when the session changes and unregisters on unmount.
- */
-export function useBrowserSessionEvent(
- opts: UseBrowserSessionEventOptions,
-): void {
- const { enabled, triggerType, sessionId, fnId } = opts
- const onEventRef = useRef(opts.onEvent)
- onEventRef.current = opts.onEvent
-
- const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '')
-
- useEffect(() => {
- if (!enabled || !sessionId) return
- let cancelled = false
- const offs: Array<() => void> = []
-
- void (async () => {
- let client: Awaited>
- try {
- client = await getIiiClient()
- } catch {
- return
- }
- if (cancelled) return
- const localFnId = `${fnId}::${instanceId}`
- try {
- offs.push(
- client.on(localFnId, (payload: unknown) => {
- onEventRef.current(payload)
- }),
- )
- offs.push(
- client.registerTrigger({
- type: triggerType,
- function_id: `${localFnId}::${client.browserId}`,
- config: { session_id: sessionId },
- }),
- )
- } catch {
- // Worker absent or trigger type unregistered; drop the binding.
- }
- })()
-
- return () => {
- cancelled = true
- for (const off of offs) off()
- }
- }, [enabled, triggerType, sessionId, fnId, instanceId])
-}
-
-export interface UseBrowserStreamOptions {
- enabled: boolean
- /** iii stream name to subscribe to. */
- streamName: string
- /** Stream group (the session id for per-session streams). */
- groupId: string | null
- /** Base id for this binding's browser-local handler. */
- fnId: string
- onFrame: (payload: unknown) => void
-}
-
-/**
- * Subscribe to an iii stream (`type:'stream'`) for a session, the same
- * engine-pushes / client-appends pattern the Traces view uses. Rebinds when
- * the stream group (session) changes and unregisters on unmount.
- */
-export function useBrowserStream(opts: UseBrowserStreamOptions): void {
- const { enabled, streamName, groupId, fnId } = opts
- const onFrameRef = useRef(opts.onFrame)
- onFrameRef.current = opts.onFrame
-
- const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '')
-
- useEffect(() => {
- if (!enabled || !groupId) return
- let cancelled = false
- const offs: Array<() => void> = []
-
- void (async () => {
- let client: Awaited>
- try {
- client = await getIiiClient()
- } catch {
- return
- }
- if (cancelled) return
- const localFnId = `${fnId}::${instanceId}`
- try {
- offs.push(
- client.on(localFnId, (payload: unknown) => {
- onFrameRef.current(payload)
- }),
- )
- offs.push(
- client.registerTrigger({
- type: 'stream',
- function_id: `${localFnId}::${client.browserId}`,
- config: { stream_name: streamName, group_id: groupId },
- }),
- )
- } catch {
- // Stream not available; the seed read is the fallback.
- }
- })()
-
- return () => {
- cancelled = true
- for (const off of offs) off()
- }
- }, [enabled, streamName, groupId, fnId, instanceId])
-}
diff --git a/console/web/src/hooks/use-browser-status.ts b/console/web/src/hooks/use-browser-status.ts
deleted file mode 100644
index 96055a282..000000000
--- a/console/web/src/hooks/use-browser-status.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- isWorkerPresent,
- useWorkerPresence,
- type WorkerPresence,
-} from './use-worker-presence'
-
-/**
- * Presence probe for the `browser` worker. Browser owns interactive
- * Chromium sessions: the Browser page (viewport, console feed, pick-to-chat)
- * and the chat's browser function-trigger views. It is OPTIONAL, so the console
- * gates that whole surface on its presence rather than rendering controls
- * that would call functions that don't exist. Thin wrapper over the generic
- * worker-presence probe.
- */
-
-/** Engine worker name for the browser worker. */
-const BROWSER_WORKER_NAME = 'browser'
-/** Base id for the browser-local handler bound to the `worker` trigger. */
-const BROWSER_WATCH_FN = 'console::browser-watch'
-
-export type BrowserStatus = WorkerPresence
-
-/**
- * @param enabled - only run against the real backend; pass `false` for the
- * mock/Storybook backend (treats browser as present so its UI shows in
- * isolation).
- */
-export function useBrowserStatus(enabled: boolean): BrowserStatus {
- return useWorkerPresence({
- workerName: BROWSER_WORKER_NAME,
- watchFnId: BROWSER_WATCH_FN,
- enabled,
- })
-}
-
-/**
- * Whether the browser worker's session functions are registered and safe
- * to trigger. False during the initial presence probe and while the worker
- * is absent.
- */
-export function isBrowserAvailable(status: BrowserStatus): boolean {
- return isWorkerPresent(status)
-}
diff --git a/console/web/src/hooks/use-hash-route.test.ts b/console/web/src/hooks/use-hash-route.test.ts
index 133fd15cd..a06a4ae03 100644
--- a/console/web/src/hooks/use-hash-route.test.ts
+++ b/console/web/src/hooks/use-hash-route.test.ts
@@ -65,11 +65,13 @@ describe('migrated-page hash redirects', () => {
it('rewrites legacy first-party hashes to their injected #/ext/ route', () => {
expect(normalizeExtHash('#/worktrees')).toBe('#/ext/worktree')
expect(normalizeExtHash('#/memory')).toBe('#/ext/memory')
+ expect(normalizeExtHash('#/browser')).toBe('#/ext/browser')
})
it('resolves the injected page id from a legacy hash', () => {
expect(extPageFromHash(normalizeExtHash('#/worktrees'))).toBe('worktree')
expect(extPageFromHash(normalizeExtHash('#/memory'))).toBe('memory')
+ expect(extPageFromHash(normalizeExtHash('#/browser'))).toBe('browser')
})
it('passes non-migrated hashes through unchanged', () => {
diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts
index d11c25d18..34799b034 100644
--- a/console/web/src/hooks/use-hash-route.ts
+++ b/console/web/src/hooks/use-hash-route.ts
@@ -3,19 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'
// `chat` is no longer a routed view; it's always-rendered as the side dock
// in App.tsx. Hash routes only pick which view fills the right pane. The
// component spec sheet + streaming playground moved to Storybook, so the
-// first-party routed views are `traces`, `workers`, `browser`, `github`, and
+// first-party routed views are `traces`, `workers`, `github`, and
// `configuration`. `ext` is the injectable-UI prefix: worker-contributed pages
// route at `#/ext/` — deliberately outside the first-party names so an
// injected page can never collide with or shadow `#/traces`, `#/workers`, ….
-// Pages that migrated to injected UI (worktrees, memory) keep their old
+// Pages that migrated to injected UI (worktrees, memory, browser) keep their old
// first-party hash working via a redirect to `#/ext/` — see MIGRATED_ROUTES.
-export type View =
- | 'configuration'
- | 'traces'
- | 'workers'
- | 'browser'
- | 'github'
- | 'ext'
+export type View = 'configuration' | 'traces' | 'workers' | 'github' | 'ext'
export interface WorkersConfigurationRoute {
configurationId: string | null
@@ -83,7 +77,7 @@ export function normalizeWorkersConfigurationHash(hash: string): string | null {
}
function routeFromHash(rawHash: string): View | null {
- // Migrated pages (worktrees, memory) resolve through their `#/ext/` form.
+ // Migrated pages (worktrees, memory, browser) resolve via `#/ext/`.
const hash = normalizeExtHash(rawHash)
if (hash === '' || hash === '#' || hash === '#/' || hash === '#/traces') {
return 'traces'
@@ -100,9 +94,6 @@ function routeFromHash(rawHash: string): View | null {
if (hash === '#/github') {
return 'github'
}
- if (hash === '#/browser' || hash.startsWith('#/browser/')) {
- return 'browser'
- }
if (hash.startsWith('#/ext/')) {
return 'ext'
}
@@ -130,8 +121,6 @@ function hashFor(view: View): string {
return '#/traces'
case 'workers':
return '#/workers'
- case 'browser':
- return '#/browser'
case 'github':
return '#/github'
case 'configuration':
@@ -153,8 +142,8 @@ export function useHashRoute(): [View, (next: View) => void] {
viewRef.current = view
useEffect(() => {
- // Rewrite a legacy migrated hash (`#/worktrees`, `#/memory`) to its
- // `#/ext/` form so the URL bar matches the resolved page. The view
+ // Rewrite a legacy migrated hash (`#/worktrees`, `#/memory`, `#/browser`)
+ // to its `#/ext/` form so the URL bar matches the resolved page. The view
// state already used the normalized hash, so this is cosmetic.
const normalized = normalizeExtHash(window.location.hash)
if (normalized !== window.location.hash) replaceHash(normalized)
@@ -211,6 +200,7 @@ export function hashForExtPage(pageId: string): string {
const MIGRATED_ROUTES: Record = {
'#/worktrees': 'worktree',
'#/memory': 'memory',
+ '#/browser': 'browser',
}
/**
@@ -247,61 +237,6 @@ export function useExtPageRoute(): string | null {
return selected
}
-const BROWSER_HASH = '#/browser'
-
-/** `#/browser/` -> the session id, or null for `#/browser`. */
-export function browserSessionFromHash(hash: string): string | null {
- if (!hash.startsWith(`${BROWSER_HASH}/`)) return null
- const segment = hash
- .slice(BROWSER_HASH.length + 1)
- .split('/')
- .filter(Boolean)[0]
- if (!segment) return null
- return decodeSegment(segment)
-}
-
-export function hashForBrowserSession(sessionId: string | null): string {
- if (!sessionId) return BROWSER_HASH
- return `${BROWSER_HASH}/${encodeURIComponent(sessionId)}`
-}
-
-/**
- * Selected browser session as a hash sub-route, so sessions deep-link
- * (`#/browser/`) from chat cards and survive reloads.
- */
-export function useBrowserSessionRoute(): [
- string | null,
- (next: string | null) => void,
-] {
- const [selected, setSelected] = useState(() => {
- if (typeof window === 'undefined') return null
- return browserSessionFromHash(window.location.hash)
- })
- const selectedRef = useRef(selected)
- selectedRef.current = selected
-
- useEffect(() => {
- const sync = () => {
- const next = browserSessionFromHash(window.location.hash)
- if (next !== selectedRef.current) setSelected(next)
- }
- sync()
- window.addEventListener('hashchange', sync)
- return () => window.removeEventListener('hashchange', sync)
- }, [])
-
- const navigate = useCallback((next: string | null) => {
- const targetHash = hashForBrowserSession(next)
- if (window.location.hash !== targetHash) {
- window.location.hash = targetHash
- } else {
- setSelected(next)
- }
- }, [])
-
- return [selected, navigate]
-}
-
export function useWorkersConfigurationRoute(): [
WorkersConfigurationRoute,
(configurationId: string | null, fieldPath?: string[]) => void,
diff --git a/console/web/src/lib/conversations-context.tsx b/console/web/src/lib/conversations-context.tsx
index 1e3f722ae..19ed3d25f 100644
--- a/console/web/src/lib/conversations-context.tsx
+++ b/console/web/src/lib/conversations-context.tsx
@@ -9,10 +9,6 @@ import {
isApprovalGateAvailable,
useApprovalGateStatus,
} from '@/hooks/use-approval-gate-status'
-import {
- isBrowserAvailable,
- useBrowserStatus,
-} from '@/hooks/use-browser-status'
import {
type ConversationsApi,
useConversations,
@@ -81,12 +77,6 @@ interface ConversationsContextValue extends ConversationsApi {
* backend.
*/
worktreeAvailable: boolean
- /**
- * Whether the optional `browser` worker is connected. Gates the Browser
- * page nav entry, its `browser::*` RPC, and the chat's browser
- * session-start notices. Only meaningful on the real backend.
- */
- browserAvailable: boolean
/**
* Whether the optional `memory` worker is connected. Gates the Memory
* page nav entry and its `memory::*` RPC. Only meaningful on the real
@@ -127,9 +117,6 @@ export function ConversationsProvider({
const worktreeAvailable = isWorktreeAvailable(
useWorktreeStatus(backend.id === 'real'),
)
- const browserAvailable = isBrowserAvailable(
- useBrowserStatus(backend.id === 'real'),
- )
const memoryAvailable = isMemoryAvailable(
useMemoryStatus(backend.id === 'real'),
)
@@ -183,7 +170,6 @@ export function ConversationsProvider({
approvalGateAvailable,
shellAvailable,
worktreeAvailable,
- browserAvailable,
memoryAvailable,
githubAvailable,
}
diff --git a/console/web/src/lib/nav-options.test.ts b/console/web/src/lib/nav-options.test.ts
index 877fed6cb..563c54a3b 100644
--- a/console/web/src/lib/nav-options.test.ts
+++ b/console/web/src/lib/nav-options.test.ts
@@ -3,33 +3,16 @@ import { buildViewOptions } from './nav-options'
describe('buildViewOptions', () => {
it('hides the optional-worker entries while their workers are absent', () => {
- expect(buildViewOptions(false, false).map((o) => o.value)).toEqual([
+ expect(buildViewOptions(false).map((o) => o.value)).toEqual([
'traces',
'workers',
])
})
- it('appends the browser entry when the worker is present', () => {
- expect(buildViewOptions(true, false).map((o) => o.value)).toEqual([
- 'traces',
- 'workers',
- 'browser',
- ])
- })
-
it('appends the github entry when the worker is present', () => {
- expect(buildViewOptions(false, true).map((o) => o.value)).toEqual([
- 'traces',
- 'workers',
- 'github',
- ])
- })
-
- it('appends every entry when all optional workers are present', () => {
- expect(buildViewOptions(true, true).map((o) => o.value)).toEqual([
+ expect(buildViewOptions(true).map((o) => o.value)).toEqual([
'traces',
'workers',
- 'browser',
'github',
])
})
diff --git a/console/web/src/lib/nav-options.ts b/console/web/src/lib/nav-options.ts
index e0d0ab4e2..f638fc382 100644
--- a/console/web/src/lib/nav-options.ts
+++ b/console/web/src/lib/nav-options.ts
@@ -7,16 +7,12 @@ import type { View } from '@/hooks/use-hash-route'
* notice).
*/
export function buildViewOptions(
- browserAvailable: boolean,
githubAvailable: boolean,
): { value: View; label: string }[] {
const options: { value: View; label: string }[] = [
{ value: 'traces', label: 'traces' },
{ value: 'workers', label: 'workers' },
]
- if (browserAvailable) {
- options.push({ value: 'browser', label: 'browser' })
- }
if (githubAvailable) {
options.push({ value: 'github', label: 'github' })
}
diff --git a/console/web/src/pages/Browser/components/SessionRail.tsx b/console/web/src/pages/Browser/components/SessionRail.tsx
deleted file mode 100644
index b46dc0154..000000000
--- a/console/web/src/pages/Browser/components/SessionRail.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Globe } from 'lucide-react'
-import { formatMtime } from '@/components/chat/sandbox/format'
-import type { BrowserSessionInfo } from '@/lib/browser'
-import { cn } from '@/lib/utils'
-
-/**
- * Left rail: one row per live Chromium session. Selection routes to
- * `#/browser/` so the viewport deep-links and survives reloads.
- */
-
-interface SessionRailProps {
- sessions: BrowserSessionInfo[]
- selectedId: string | null
- onSelect: (sessionId: string) => void
-}
-
-function hostOf(url: string): string {
- try {
- const parsed = new URL(url)
- return parsed.host || url
- } catch {
- return url
- }
-}
-
-export function SessionRail({
- sessions,
- selectedId,
- onSelect,
-}: SessionRailProps) {
- return (
-
- )
-}
diff --git a/console/web/src/pages/Browser/index.tsx b/console/web/src/pages/Browser/index.tsx
deleted file mode 100644
index 5686d8fa8..000000000
--- a/console/web/src/pages/Browser/index.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import { AlertCircle, Globe, Plus, RefreshCw } from 'lucide-react'
-import { useEffect, useMemo, useState } from 'react'
-import { Button } from '@/components/ui/Button'
-import { EmptyState } from '@/components/ui/EmptyState'
-import { StatusDot } from '@/components/ui/StatusDot'
-import { StatusPanel } from '@/components/ui/StatusPanel'
-import {
- isBrowserAvailable,
- useBrowserStatus,
-} from '@/hooks/use-browser-status'
-import { useBrowserSessionRoute } from '@/hooks/use-hash-route'
-import { errorMessage, startBrowserSession } from '@/lib/browser'
-import { useConversationsCtx } from '@/lib/conversations-context'
-import { cn } from '@/lib/utils'
-import { SessionRail } from './components/SessionRail'
-import { SessionView } from './components/SessionView'
-import { useBrowserSessionsLive } from './hooks/useBrowserSessionsLive'
-
-/**
- * Live browser page: session rail -> polled viewport -> console/network
- * feeds, so a user can watch what an agent is doing in a Chromium session
- * and pick elements straight into chat. Data is `browser::sessions::list`,
- * refreshed on the session lifecycle trigger types (poll fallback while
- * bindings are unavailable). The nav entry is gated on worker presence; a
- * direct hash hit with the worker absent lands on the install notice below.
- */
-
-export function Browser() {
- const { backend } = useConversationsCtx()
- const status = useBrowserStatus(backend.id === 'real')
- const available = isBrowserAvailable(status)
-
- const { sessions, loading, error, live, refresh } =
- useBrowserSessionsLive(available)
-
- const [selectedId, setSelectedId] = useBrowserSessionRoute()
- const selected = useMemo(
- () => sessions.find((s) => s.session_id === selectedId) ?? null,
- [sessions, selectedId],
- )
-
- // Auto-select the first session when nothing (or a stopped session) is
- // selected, and clear a deep link whose session is gone.
- useEffect(() => {
- if (!available || loading) return
- if (selectedId && sessions.some((s) => s.session_id === selectedId)) return
- const next = sessions[0]?.session_id ?? null
- if (next !== selectedId) setSelectedId(next)
- }, [available, loading, sessions, selectedId, setSelectedId])
-
- const [starting, setStarting] = useState(false)
- const [startError, setStartError] = useState(null)
- const handleNewSession = async () => {
- if (starting) return
- setStarting(true)
- try {
- const started = await startBrowserSession()
- setStartError(null)
- refresh()
- if (started) setSelectedId(started.session_id)
- } catch (err) {
- setStartError(errorMessage(err))
- } finally {
- setStarting(false)
- }
- }
-
- const countLabel = loading ? '...' : String(sessions.length)
-
- return (
-
-
-
-
- browser
-
-
- {available ? `${countLabel} sessions` : 'worker not connected'}
-
-
- {available ? (
-
-
-
- {live ? 'live' : 'polling'}
-
-
-
- refresh
-
- void handleNewSession()}
- disabled={starting}
- className="gap-1.5"
- >
-
- {starting ? 'starting...' : 'new session'}
-
-
- ) : null}
-
-
- {!available ? (
-
- {status.loading ? (
-
- checking for the browser worker...
-
- ) : (
- }
- headline="browser worker not installed"
- detail="this page needs the optional browser worker. run: iii worker add browser"
- />
- )}
-
- ) : error ? (
-
- }
- headline="failed to load browser sessions"
- detail={error}
- />
-
- ) : !loading && sessions.length === 0 ? (
-
- {startError ? (
- }
- headline="could not start a session"
- detail={startError}
- className="mb-4"
- />
- ) : null}
-
-
- ) : (
-
-
- {selected ? (
- setSelectedId(null)}
- />
- ) : (
-
-
- select a session
-
-
- )}
-
- )}
-
- )
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6914f4cef..685fad19a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,25 @@ importers:
.: {}
+ browser/ui:
+ dependencies:
+ '@iii-dev/console-ui':
+ specifier: workspace:*
+ version: link:../../packages/console-ui
+ zod:
+ specifier: ^4.0.0
+ version: 4.4.3
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.17
+ esbuild:
+ specifier: ^0.25.0
+ version: 0.25.12
+ typescript:
+ specifier: ^5.9.2
+ version: 5.9.3
+
console/ui:
dependencies:
'@iii-dev/console-ui':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c52bbd11e..02b60e042 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -9,6 +9,7 @@ packages:
- packages/*
- console/web
- console/ui
+ - browser/ui
- database/ui
- eval/ui
- memory/ui