Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions browser/ui/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*
* `setup(host)` registers two contributions:
* - src/page/ — the `#/ext/browser` page: the session rail, a screencast-fed
* live viewport, and the console/network feeds for the selected session.
* live viewport, and the console/network feeds for the selected session;
* drill-in flow (list ⇄ session workspace) when the pane is narrow.
* - src/function-trigger-message/ — how every `browser::*` call renders in
* chat and the traces span tab (per-function terminal cards).
*
Expand All @@ -26,7 +27,7 @@ export default function setup(host: Host) {
host.pages.register({
id: 'browser',
title: 'browser',
render: () => <BrowserPage host={host} />,
render: (props) => <BrowserPage host={host} {...props} />,
})

host.functionTriggers.register(createBrowserRenderer(host))
Expand Down
116 changes: 116 additions & 0 deletions browser/ui/src/lib/widgets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/** Small shared UI pieces + inline icons for the browser page. */

import { StatusDot } from '@iii-dev/console-ui'

/** Header live / polling indicator for the session feed. */
export function LivePill({ live }: { live: boolean }) {
return (
<span
className="br-ui-live"
title={
live
? 'live — updates arrive on the browser session triggers'
: 'polling — live bindings unavailable; refreshing on a timer'
}
>
<StatusDot tone={live ? 'accent' : 'ink'} pulse={live} />
{live ? 'live' : 'polling'}
</span>
)
}

/** Narrow-mode drill-out affordance (session list ← workspace). */
export function BackButton({
onClick,
label,
}: {
onClick: () => void
label: string
}) {
return (
<button
type="button"
className="br-ui-back"
onClick={onClick}
aria-label={label}
title={label}
>
<ChevronLeftIcon className="br-ui-back-icon" />
</button>
)
}

/** Column-head refresh affordance. */
export function RefreshButton({
onClick,
label,
disabled,
spinning,
}: {
onClick: () => void
label: string
disabled?: boolean
spinning?: boolean
}) {
return (
<button
type="button"
className="br-ui-iconbtn"
onClick={onClick}
aria-label={label}
title={label}
disabled={disabled}
>
<RotateIcon
className={`br-ui-iconbtn-icon${spinning ? ' br-ui-spin' : ''}`}
/>
</button>
)
}

/* ── inline icons ─────────────────────────────────────────────────────
* Injected UI has no icon library to import — these are hand-inlined
* 24×24 stroke glyphs (lucide geometry: 1.5px stroke, round caps) sized
* by the caller's className. All are decorative (aria-hidden); the
* enclosing control carries the accessible name. (src/lib/icons.tsx keeps
* the older size-prop set the chat cards and rail rows use.) */

function iconProps(className?: string) {
return {
className,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const
}

/** Globe glyph: the browser worker's identity. */
export function GlobeIcon({ className }: { className?: string }) {
return (
<svg {...iconProps(className)} aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10" />
<path d="M2 12h20" />
</svg>
)
}

export function ChevronLeftIcon({ className }: { className?: string }) {
return (
<svg {...iconProps(className)} aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
)
}

export function RotateIcon({ className }: { className?: string }) {
return (
<svg {...iconProps(className)} aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<path d="M21 3v5h-5" />
</svg>
)
}
97 changes: 61 additions & 36 deletions browser/ui/src/page/SessionRail.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
/**
* The navigation rail's session list: one row per live Chromium session, in
* the order the worker lists them. Each row carries what identifies the
* session — page title (or host), url, session id, headless/headful, last
* use. Selection is page-local state (the injected page owns its own
* routing), so a click just flips the selected id; the look follows the
* redesign's nav-row pattern: wash + accent bar + stronger title, never
* color alone.
*/

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
loading: boolean
onSelect: (sessionId: string) => void
}

Expand All @@ -27,44 +32,64 @@ function hostOf(url: string): string {
export function SessionRail({
sessions,
selectedId,
loading,
onSelect,
}: SessionRailProps) {
if (sessions.length === 0) {
if (loading) {
return (
<div className="br-ui-rail-skel" aria-hidden>
{[0, 1, 2].map((i) => (
<div key={i} className="br-ui-skel-row">
<span className={`bar w${[60, 75, 40][i]}`} />
<span className={`bar w${[90, 85, 70][i]}`} />
</div>
))}
</div>
)
}
return (
<div className="br-ui-rail-empty">
<p>No sessions yet.</p>
<p className="dim">
Sessions started by agents appear in this list live; new session
starts one now.
</p>
</div>
)
}

return (
<nav aria-label="browser sessions" className="br-ui-rail">
<ul className="br-ui-nav-list">
{sessions.map((session) => {
const selected = session.session_id === selectedId
return (
<button
key={session.session_id}
type="button"
onClick={() => onSelect(session.session_id)}
aria-current={selected ? 'true' : undefined}
title={`${session.session_id} · ${session.url}`}
className={cn('br-ui-rail-row', selected && 'is-selected')}
>
<span className="br-ui-rail-head">
<Globe
size={12}
aria-hidden
className={cn('br-ui-rail-icon', selected && 'is-selected')}
/>
<span className="br-ui-rail-title">
{session.title?.trim() || hostOf(session.url) || 'about:blank'}
<li key={session.session_id}>
<button
type="button"
onClick={() => onSelect(session.session_id)}
aria-current={selected ? 'true' : undefined}
title={`${session.session_id} · ${session.url}`}
className={cn('br-ui-rail-row', selected && 'active')}
>
<span className="br-ui-rail-head">
<Globe size={12} aria-hidden className="br-ui-rail-icon" />
<span className="br-ui-rail-title">
{session.title?.trim() || hostOf(session.url) || 'about:blank'}
</span>
</span>
<span className="br-ui-rail-url">{session.url}</span>
<span className="br-ui-rail-meta">
<span className="br-ui-num">{session.session_id}</span>
<span>·</span>
<span>{session.headless ? 'headless' : 'headful'}</span>
<span>·</span>
<span>{formatMtime(Math.floor(session.last_used_ms / 1000))}</span>
</span>
</span>
<span className="br-ui-rail-url">
<span className="br-ui-truncate">{session.url}</span>
</span>
<span className="br-ui-rail-meta">
<span className="br-ui-num">{session.session_id}</span>
<span>·</span>
<span>{session.headless ? 'headless' : 'headful'}</span>
<span>·</span>
<span>{formatMtime(Math.floor(session.last_used_ms / 1000))}</span>
</span>
</button>
</button>
</li>
)
})}
</nav>
</ul>
)
}
Loading
Loading