diff --git a/src/servers/api/dashboards/dashboards.ts b/src/servers/api/dashboards/dashboards.ts index 3ccf6e15..673b6558 100644 --- a/src/servers/api/dashboards/dashboards.ts +++ b/src/servers/api/dashboards/dashboards.ts @@ -20,7 +20,7 @@ export const dashboardsRouter = createRouter(); // `editor` joined 2026-08-15: which files a code-editor panel has open, and which is in front. Note an // unmatched key is a 400 rather than a silent 200 — so a new panel prefix MUST be added here or its state // vanishes loudly instead of quietly, which is the whole reason that branch exists. -const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code', 'editor', 'files', 'chat']; +const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code', 'editor', 'files', 'chat', 'preview']; // A `LayoutNode` is an object. Everything from the PATCH body down to jsonb is `unknown`, so without this // the only thing standing between a wrong-shaped body and the layout columns is the CHECK constraint — diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 91de70a7..a384ddb7 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -5,6 +5,7 @@ import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; import { appRegistryMetas as chatMetas } from '../apps/Chat'; +import { appRegistryMetas as previewMetas } from '../apps/Preview'; import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; @@ -28,6 +29,7 @@ export const apps = [ ...terminalMetas, ...codeEditorMetas, ...chatMetas, + ...previewMetas, ...dashboardMetas, ...chatHistoryMetas, ...widgetMetas, diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx new file mode 100644 index 00000000..6c717a6e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { Globe } from 'lucide-react'; +import { useWorkspace } from '../../components/Workspace'; +import { normalizeUrl, usePreview } from './use-preview'; + +/** + * A live app, in a panel. + * + * The iframe is remounted by `key` on reload rather than reaching for `contentWindow.location.reload()`: + * the frame is a different origin, so touching its location object throws a cross-origin error. Replacing + * the element re-requests the URL, which is the same thing from the user's side and the only thing the + * parent is allowed to do. + * + * `allow-same-origin` is present, and has to be: without it the framed app gets an opaque origin, which + * means no `localStorage` — and it authenticates with a JWT it keeps there, so it could never stay logged + * in. It is combined with `allow-scripts`, which the two together do weaken — but the alternative is a + * panel that cannot run the app it exists to show, and the URL is one the owner typed. + */ +export const PreviewApp = ({ panelId }: { panelId: string }) => { + const { workspace } = useWorkspace(); + const { url, setUrl, reloadToken } = usePreview(panelId, workspace); + const [draft, setDraft] = useState(''); + + if (!url) { + return ( +
{ + ev.preventDefault(); + const next = normalizeUrl(draft); + if (next) setUrl(next); + }} + > + + Point this panel at a running app + setDraft(ev.target.value)} + placeholder="jesper.edge.pertento.ai" + className="w-full max-w-sm rounded-md border border-duck-dark/20 bg-transparent px-3 py-1.5 text-sm" + /> + +

+ Anything the browser can reach. It stays on this panel and survives a reload. +

+ + ); + } + + return