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 (
+
+ );
+ }
+
+ return ;
+};
diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx
new file mode 100644
index 00000000..6c083f89
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from 'react';
+import { Globe, RefreshCw, ExternalLink, Pencil, Check, X } from 'lucide-react';
+import { useWorkspace } from '../../components/Workspace';
+import { normalizeUrl, usePreview } from './use-preview';
+
+/**
+ * The preview panel's header: where it points, reload, open in a real tab, and change it.
+ *
+ * "Open in a new tab" is not decoration. A framed app gets PARTITIONED storage — the browser keys it by
+ * top-level site — so the login inside this panel is separate from the login in a tab, and a page that
+ * refuses to be framed at all (`X-Frame-Options`, CSP `frame-ancestors`) renders blank here with only a
+ * console error. When either happens, the tab is the answer, and it should be one click away rather than
+ * something you rebuild by copying the URL out.
+ */
+export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
+ const { workspace } = useWorkspace();
+ const { url, setUrl, reload } = usePreview(panelId, workspace);
+
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(url);
+
+ // Follow the stored value while not editing, so a URL set from elsewhere shows up here.
+ useEffect(() => {
+ if (!editing) setDraft(url);
+ }, [url, editing]);
+
+ const commit = () => {
+ const next = normalizeUrl(draft);
+ if (next) setUrl(next);
+ setEditing(false);
+ };
+
+ if (editing) {
+ return (
+ <>
+
+ setDraft(ev.target.value)}
+ onKeyDown={(ev) => {
+ if (ev.key === 'Enter') commit();
+ if (ev.key === 'Escape') setEditing(false);
+ }}
+ className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
+ />
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ Preview
+ {url ? (
+ <>
+
+
+ {url.replace(/^https?:\/\//, '')}
+
+
+
+
+
+ >
+ ) : (
+ no URL yet
+ )}
+ >
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Preview/index.ts b/src/workspaces/officerdev/src/apps/Preview/index.ts
new file mode 100644
index 00000000..6e20bea9
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Preview/index.ts
@@ -0,0 +1,18 @@
+import type { AppRegistryMeta } from '../../AppRegistry';
+import { Globe } from 'lucide-react';
+import { PreviewApp } from './PreviewApp';
+import { PreviewHeader } from './PreviewHeader';
+
+export { PreviewApp } from './PreviewApp';
+export { PreviewHeader } from './PreviewHeader';
+export { usePreview, normalizeUrl, type PreviewPanelState } from './use-preview';
+
+export const appRegistryMetas: AppRegistryMeta[] = [
+ {
+ key: 'officerdev/preview',
+ name: 'Preview',
+ icon: Globe,
+ component: PreviewApp,
+ header: PreviewHeader,
+ },
+];
diff --git a/src/workspaces/officerdev/src/apps/Preview/use-preview.test.ts b/src/workspaces/officerdev/src/apps/Preview/use-preview.test.ts
new file mode 100644
index 00000000..25ffb8a0
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Preview/use-preview.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from 'bun:test';
+import { normalizeUrl } from './use-preview';
+
+// The panel puts this string straight into an iframe `src`, so what it accepts matters more than it looks.
+
+describe('normalizeUrl', () => {
+ test('a bare host is assumed https, because that is what people type', () => {
+ expect(normalizeUrl('jesper.edge.pertento.ai')).toBe('https://jesper.edge.pertento.ai/');
+ });
+
+ test('an explicit scheme is kept, including http for a local dev server', () => {
+ expect(normalizeUrl('https://jesper.edge.pertento.ai')).toBe('https://jesper.edge.pertento.ai/');
+ expect(normalizeUrl('http://localhost:5173')).toBe('http://localhost:5173/');
+ });
+
+ test('a path survives', () => {
+ expect(normalizeUrl('backend-jesper.edge.pertento.ai/api')).toBe('https://backend-jesper.edge.pertento.ai/api');
+ });
+
+ test('refuses the three schemes that turn a text box into script execution', () => {
+ // `javascript:` in an iframe src executes in the PARENT's context. data: and blob: are the other two
+ // shapes with the same ending. The owner typing into their own panel is not an attacker, but this
+ // costs one line and removes the category.
+ expect(normalizeUrl('javascript:alert(1)')).toBeNull();
+ expect(normalizeUrl('data:text/html,')).toBeNull();
+ expect(normalizeUrl('blob:https://x/y')).toBeNull();
+ });
+
+ test('empty and nonsense are null, so the button can stay disabled', () => {
+ expect(normalizeUrl('')).toBeNull();
+ expect(normalizeUrl(' ')).toBeNull();
+ expect(normalizeUrl('not a url at all')).toBeNull();
+ });
+});
diff --git a/src/workspaces/officerdev/src/apps/Preview/use-preview.ts b/src/workspaces/officerdev/src/apps/Preview/use-preview.ts
new file mode 100644
index 00000000..b61082e0
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Preview/use-preview.ts
@@ -0,0 +1,84 @@
+import { useCallback } from 'react';
+import { useGlobal } from 'hooks/useGlobal';
+import { useDashboardState } from 'state/useDashboardState';
+import { panelStateKey, type WorkspaceIdentity } from '../../components/Workspace';
+
+/**
+ * What a preview panel is pointing at, and a way to reload it.
+ *
+ * ── Why there is no server behind this ──
+ *
+ * A previous version of this feature spawned `bun dev` in a project directory, found a free port, and
+ * proxied it under `/api/dev-server-proxy//` — which then required rewriting HTML, JS and CSS, a
+ * script injected into the page to monkey-patch `fetch`/`XHR`/`WebSocket`, a Referer fallback for chunks
+ * that resolved at the wrong depth, and a websocket bridge for HMR. Roughly 360 lines, all of it the cost
+ * of serving an app under a path prefix the app does not know about. It is at `188b45c1^` if that case
+ * comes back — an install with no proxy in front still needs it.
+ *
+ * This version does none of that, because it does not have to: the app is already served at the root of
+ * its own origin (`https://jesper.edge.pertento.ai`), reachable by the browser over the tailnet. So the
+ * panel is an iframe and a URL, and the page inside behaves exactly as it does in a tab. Nothing of ours
+ * sits in the middle to interfere with its calls to its own backend.
+ *
+ * ── Live, not claim-once ──
+ *
+ * The header writes the URL and the body reads it — two components with no props between them — so this
+ * is the same shape as the chat session picker rather than `usePanelMemory`. Claim-once would mean typing
+ * a URL changed the header and nothing else.
+ *
+ * The URL persists (dashboard state, keyed per panel). The reload counter does NOT: it is a signal, and a
+ * reload that survived a refresh would be meaningless.
+ */
+export type PreviewPanelState = { url: string };
+
+const EMPTY: Record = {};
+
+export function usePreview(panelId: string | undefined, workspace: WorkspaceIdentity | null) {
+ const { value: bag, setValue: setBag } = useDashboardState>(
+ panelStateKey('preview', workspace),
+ EMPTY,
+ );
+
+ const url = panelId ? (bag[panelId]?.url ?? '') : '';
+
+ const setUrl = useCallback(
+ (next: string) => {
+ if (!panelId) return;
+ setBag((prev) => {
+ if (prev[panelId]?.url === next) return prev;
+ return { ...prev, [panelId]: { url: next } };
+ });
+ },
+ [panelId, setBag],
+ );
+
+ // Keyed per panel so two previews reload independently. Same shape the terminal uses for its connection
+ // state, and deliberately not persisted.
+ const [reloadToken, setReloadToken] = useGlobal(`preview-reload-${panelId ?? 'none'}`, 0);
+ const reload = useCallback(() => setReloadToken((n) => n + 1), [setReloadToken]);
+
+ return { url, setUrl, reloadToken, reload };
+}
+
+/**
+ * A URL we are willing to put in an iframe.
+ *
+ * `http` and `https` only. Without this the field would accept `javascript:` — which an iframe `src` will
+ * execute in the parent's context — and `data:`/`blob:`, which are the other two shapes that turn a text
+ * box into script execution. This is a text field on the owner's own screen rather than untrusted input,
+ * so the check is a guard against a mistake rather than an attacker, but it costs one line.
+ *
+ * A bare host (`jesper.edge.pertento.ai`) is accepted and assumed https, because that is what people type.
+ */
+export function normalizeUrl(raw: string): string | null {
+ const text = raw.trim();
+ if (!text) return null;
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`;
+ try {
+ const parsed = new URL(withScheme);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
+ return parsed.toString();
+ } catch {
+ return null;
+ }
+}