Skip to content
Closed
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
25 changes: 22 additions & 3 deletions console/src/functions/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::configuration::{existing_value, set_value};

pub const CHAT_SCREEN: &str = "chat";
pub const EXT_SCREEN_PREFIX: &str = "ext:";
pub const PREVIEW_SCREEN_PREFIX: &str = "preview:";
pub const ROUTED_SCREENS: [&str; 2] = ["traces", "workers"];
pub const MAX_COLUMNS: usize = 64;

Expand Down Expand Up @@ -98,6 +99,9 @@ pub fn is_valid_screen(screen: &str) -> bool {
|| screen
.strip_prefix(EXT_SCREEN_PREFIX)
.is_some_and(|id| !id.is_empty())
|| screen
.strip_prefix(PREVIEW_SCREEN_PREFIX)
.is_some_and(|path| !path.is_empty())
}

fn migrate_screen(screen: Option<String>) -> Option<String> {
Expand Down Expand Up @@ -558,7 +562,8 @@ pub fn register(iii: &Arc<IIIClient>) {
Reuses the tab that already shows it, else places it beside chat in the active \
tab, else opens a new chat + screen tab. Every browser on this engine updates. \
Use `ext:shell` for the file explorer, `ext:browser` for browser sessions, \
`ext:editor` for the editor, `workers` for the worker catalog.",
`ext:editor` for the editor, `workers` for the worker catalog, and \
`preview:<path>` to render an HTML file the agent wrote beside the chat.",
),
);

Expand Down Expand Up @@ -604,10 +609,24 @@ mod tests {

#[test]
fn screen_validation() {
for ok in ["chat", "traces", "workers", "ext:shell", "ext:browser"] {
for ok in [
"chat",
"traces",
"workers",
"ext:shell",
"ext:browser",
"preview:/tmp/p.html",
] {
assert!(is_valid_screen(ok), "{ok}");
}
for bad in ["", "ext:", "configuration", "settings", "http://x"] {
for bad in [
"",
"ext:",
"preview:",
"configuration",
"settings",
"http://x",
] {
assert!(!is_valid_screen(bad), "{bad}");
}
}
Expand Down
12 changes: 12 additions & 0 deletions console/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ import {
extPageIdForScreen,
MAX_COLUMNS,
MIN_COLUMN_FRACTION,
previewPathForScreen,
screenForView,
type TabScreen,
tabColumns,
tabSizes,
} from '@/lib/workspace-tabs'
import { Configuration } from '@/pages/Configuration'
import { ExtPage } from '@/pages/Ext'
import { PreviewPane } from '@/pages/Preview'
import { TracesV2 } from '@/pages/TracesV2'
import { Workers } from '@/pages/Workers'
import type { PanelSide } from '@/types/injectable-ui'
Expand Down Expand Up @@ -575,6 +577,16 @@ function ScreenBody({
// The active conversation's working dir, forwarded live so ext pages
// (e.g. the shell explorer) can follow the chat's folder in a split.
const { active } = useConversationsCtx()
const previewPath = previewPathForScreen(screen)
if (previewPath !== null) {
return (
<PreviewPane
path={previewPath}
panelSide={panelSide}
onRequestClose={onClose}
/>
)
}
const extId = extPageIdForScreen(screen)
if (extId !== null) {
return (
Expand Down
28 changes: 28 additions & 0 deletions console/web/src/lib/workspace-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
MAX_COLUMNS,
parseActiveTabId,
parseWorkspaceTabs,
previewPathForScreen,
resolveActiveTab,
screenForPreview,
screenForView,
screenLabel,
shouldFlushPendingWrite,
Expand Down Expand Up @@ -461,3 +463,29 @@ describe('shouldFlushPendingWrite', () => {
expect(shouldFlushPendingWrite('local', true)).toBe(true)
})
})

describe('preview screens', () => {
it('round-trips a preview path and rejects an empty one', () => {
expect(screenForPreview('/tmp/plan.html')).toBe('preview:/tmp/plan.html')
expect(previewPathForScreen('preview:/tmp/plan.html')).toBe(
'/tmp/plan.html',
)
expect(previewPathForScreen('ext:shell')).toBeNull()
expect(previewPathForScreen('chat')).toBeNull()
expect(
parseWorkspaceTabs({
workspace: { tabs: [{ id: 't', screens: ['preview:/a.html', null] }] },
})[0].screens,
).toEqual(['preview:/a.html', null])
// A malformed `preview:` (no path) is invalid, so its tab is dropped.
expect(
parseWorkspaceTabs({
workspace: { tabs: [{ id: 't', screens: ['preview:'] }] },
}),
).toEqual([])
})

it('labels a preview by its basename', () => {
expect(screenLabel('preview:/x/y/report.html', NO_EXT)).toBe('report.html')
})
})
18 changes: 17 additions & 1 deletion console/web/src/lib/workspace-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export function withColumnRemoved(
}

export const EXT_SCREEN_PREFIX = 'ext:'
export const PREVIEW_SCREEN_PREFIX = 'preview:'
export const CHAT_SCREEN: TabScreen = 'chat'

/**
Expand Down Expand Up @@ -214,6 +215,17 @@ export function screenForExtPage(pageId: string): TabScreen {
return `${EXT_SCREEN_PREFIX}${pageId}`
}

/** The file path of a `preview:<path>` screen, or null for any other. */
export function previewPathForScreen(screen: TabScreen): string | null {
return screen.startsWith(PREVIEW_SCREEN_PREFIX)
? screen.slice(PREVIEW_SCREEN_PREFIX.length)
: null
}

export function screenForPreview(path: string): TabScreen {
return `${PREVIEW_SCREEN_PREFIX}${path}`
}

/**
* The screen a routed view (+ ext page id) resolves to; `null` when the
* view has no tab representation — configuration (an overlay page, not a
Expand All @@ -237,7 +249,9 @@ const isValidScreen = (s: unknown): s is TabScreen =>
typeof s === 'string' &&
(s === CHAT_SCREEN ||
isRoutedScreen(s) ||
(s.startsWith(EXT_SCREEN_PREFIX) && s.length > EXT_SCREEN_PREFIX.length))
(s.startsWith(EXT_SCREEN_PREFIX) && s.length > EXT_SCREEN_PREFIX.length) ||
(s.startsWith(PREVIEW_SCREEN_PREFIX) &&
s.length > PREVIEW_SCREEN_PREFIX.length))

function isValidTab(v: unknown): v is WorkspaceTab {
if (!v || typeof v !== 'object') return false
Expand Down Expand Up @@ -440,6 +454,8 @@ export function screenLabel(
): string {
const extId = extPageIdForScreen(screen)
if (extId !== null) return extPageTitles.get(extId) ?? extId
const previewPath = previewPathForScreen(screen)
if (previewPath !== null) return previewPath.split('/').pop() || 'preview'
return screen
}

Expand Down
126 changes: 126 additions & 0 deletions console/web/src/pages/Preview/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from 'react'
import { EmptyState } from '@/components/ui/EmptyState'
import { PageBody, PageHeader, PageShell } from '@/components/ui/PageChrome'
import { getIiiClient } from '@/lib/iii-client'
import type { PanelSide } from '@/types/injectable-ui'

interface PreviewPaneProps {
/** Host path of the HTML file to render. */
path: string
panelSide?: PanelSide
onRequestClose?: () => void
}

interface ReadResponse {
content?: string
is_utf8?: boolean
more_lines?: boolean
}

type State =
| { phase: 'loading' }
| { phase: 'ready'; html: string; truncated: boolean }
| { phase: 'error'; message: string }

const MAX_PREVIEW_BYTES = 2_000_000

function isHtmlPath(path: string): boolean {
const lower = path.toLowerCase()
return (
lower.endsWith('.html') || lower.endsWith('.htm') || lower.endsWith('.svg')
)
}

/**
* Renders an HTML (or SVG) file the agent wrote, in a sandboxed iframe beside
* the chat. The file is read through `coder::read-file`; nothing is stored in
* the polled console config. The iframe carries an empty `sandbox`, so page
* scripts never run and it cannot reach the console around it.
*/
export function PreviewPane({
path,
panelSide = 'left',
onRequestClose,
}: PreviewPaneProps) {
const [state, setState] = useState<State>({ phase: 'loading' })
const seqRef = useRef(0)

useEffect(() => {
const seq = ++seqRef.current
setState({ phase: 'loading' })
getIiiClient()
.then((client) =>
client.trigger<ReadResponse>('coder::read-file', {
path,
max_output_bytes: MAX_PREVIEW_BYTES,
}),
)
.then((out) => {
if (seqRef.current !== seq) return
if (out.is_utf8 === false) {
setState({ phase: 'error', message: 'not a text file' })
return
}
setState({
phase: 'ready',
html: out.content ?? '',
truncated: out.more_lines === true,
})
})
.catch((err: unknown) => {
if (seqRef.current !== seq) return
setState({
phase: 'error',
message: err instanceof Error ? err.message : String(err),
})
})
}, [path])

const name = path.split('/').pop() || 'preview'
const html = isHtmlPath(path)
? state.phase === 'ready'
? state.html
: ''
: `<pre style="white-space:pre-wrap;font:13px/1.5 ui-monospace,monospace;padding:16px;margin:0">${
state.phase === 'ready' ? escapeHtml(state.html) : ''
}</pre>`

return (
<PageShell aria-label={`preview ${name}`}>
<PageHeader
title={name}
description={
state.phase === 'ready' && state.truncated
? `${path} (truncated)`
: path
}
onClose={onRequestClose}
/>
<PageBody side={panelSide} className="min-h-0 p-0">
{state.phase === 'loading' ? (
<div className="flex flex-1 items-center justify-center text-sm text-ink-faint">
loading {name}…
</div>
) : state.phase === 'error' ? (
<div className="flex flex-1 items-center justify-center p-6">
<EmptyState
title="Preview unavailable"
description={state.message}
/>
</div>
) : (
<iframe
title={`preview ${name}`}
sandbox=""
srcDoc={html}
className="size-full min-h-0 flex-1 border-0 bg-white"
/>
)}
</PageBody>
</PageShell>
)
}

function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
Loading