feat(browser): replace the preview proxy with a real browser panel and an agent web tool (#2883)

The preview panel worked by proxying a dev server through OpenChamber's own
origin and rewriting the HTML that came back. Anything the rewriter did not
anticipate broke, and pages that refuse to be embedded never loaded at all.
This deletes the proxy (-1604 lines and its tests) and merges the preview and
browser panels into one surface backed by a real Chromium view.

What the panel is now

- A `<webview>` in its own session partition: logins and cookies persist, hot
  reload works because nothing is rewritten, DevTools are one click away.
- Annotation: pick one element, drag a region, or draw freehand, write a note,
  and it reaches chat with a screenshot of the visible page with the marks on it.
- Toolbar: hard reload, page zoom, device sizes, a light/dark switch that
  applies to the page rather than the app, and cookie/cache clearing scoped to
  the panel alone.
- Several pages at once, each tab showing the page's own favicon, and an address
  bar that suggests pages already visited in this project.
- Dev servers are listed from what is actually listening on the machine, checked
  against what a project announced, so a server is offered no matter how it was
  started. One that is still starting is waited for instead of failing.

Remote dev servers

The desktop app binds a local port and pipes raw bytes to the OpenChamber host
over the existing authenticated connection, so the page keeps its own origin at
the root of its own host. The reachable set is exactly what discovery reports
and is re-checked per connection, so an authenticated client cannot dial
arbitrary local services on the host. Links and redirects to another loopback
port stay on the machine that served the page. A tunnel that cannot be opened is
reported; it is never replaced by the plain loopback URL, which would answer
from the user's own machine under a remote address.

Agent control

Browser actions are a separate `openchamber_web` tool: open, snapshot, click,
type, scroll, inspect computed styles, resize between mobile/tablet/desktop, and
capture a screenshot into `.openchamber/screenshots/` in the project. The
existing `openchamber` tool keeps sessions, worktrees and scheduled tasks. Each
has its own setting in the new Settings -> General -> OpenChamber Tools section,
and the plugin is not injected at all when both are off.

Capability belongs to the connected client, not to configuration: a client
declares on its event stream that it can drive a page, which only a Chromium
host does. Exactly one client performs each request — it claims the request
before acting, and the first claim wins — because deciding by whose result
arrives first would be too late for a click that already happened. No client
listening is answered immediately with an explanation rather than a timeout.

Runtime boundaries

Web tabs get a plain iframe that can display a page but not inspect one. The
VS Code extension no longer offers the surface at all, since nothing that makes
the panel worth having works there. Mobile is unaffected.

Native boundary

Camera, microphone, location and device-picker requests from panel pages are
denied — Electron grants them by default when no handler is set, and the panel
loads whatever address the user types. Page capture, appearance emulation and
storage clearing verify that their target belongs to the panel's own session
instead of trusting a web-contents id from the renderer.

Persisted state

Stored `preview` tabs migrate to `browser` (v13 -> v14). Context panel tab
limits are now per surface, so filling one surface no longer evicts another's
tabs. Address history is stored per project and per runtime.

Documentation

`preview.mdx` and `desktop-browser.mdx` rewritten across all locales, the agent
tool settings path corrected, new `DOCUMENTATION.md` for the browser-control
broker and the dev tunnel, and the `ui-api-decoupling` skill updated where it
still described the deleted proxy.
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 22:44:13 +03:00
committed by GitHub
parent 50613bb170
commit a5aa32446d
151 changed files with 10431 additions and 5587 deletions
@@ -0,0 +1,77 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { browserUrlLabel } from '@/lib/browser/url';
import type { BrowserHistoryEntry } from '@/lib/browser/history';
/**
* Addresses already visited in this project, offered under the address bar.
*
* Kept deliberately plain: it is a short list of places, so it borrows the
* app's dropdown surface rather than introducing a second look for the same
* idea. Selection is driven from the address bar's own keyboard handling, which
* is why the highlighted row arrives as a prop instead of being tracked here.
*/
export const BrowserAddressSuggestions: React.FC<{
entries: readonly BrowserHistoryEntry[];
activeIndex: number;
onSelect: (url: string) => void;
onForget: (url: string) => void;
onHighlight: (index: number) => void;
}> = ({ entries, activeIndex, onSelect, onForget, onHighlight }) => {
const { t } = useI18n();
if (entries.length === 0) return null;
return (
<div
className="oc-glass-popover oc-glass-floating absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-xl p-1"
role="listbox"
aria-label={t('contextPanel.browser.history.label')}
>
{entries.map((entry, index) => (
<div
key={entry.url}
role="option"
aria-selected={index === activeIndex}
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1',
index === activeIndex ? 'bg-interactive-hover' : 'hover:bg-interactive-hover',
)}
// Pointer down rather than click: the address bar loses focus first,
// and a blur that closes the list would cancel the click.
onPointerDown={(event) => {
event.preventDefault();
onSelect(entry.url);
}}
onPointerEnter={() => onHighlight(index)}
>
<Icon name="global" className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="truncate typography-micro text-foreground">
{entry.title || browserUrlLabel(entry.url)}
</div>
<div className="truncate typography-micro text-muted-foreground">{entry.url}</div>
</div>
<button
type="button"
className={cn(
'shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity',
'hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100',
)}
aria-label={t('contextPanel.browser.history.forget')}
title={t('contextPanel.browser.history.forget')}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
onForget(entry.url);
}}
>
<Icon name="close" className="size-3" aria-hidden="true" />
</button>
</div>
))}
</div>
);
};
@@ -0,0 +1,140 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import {
FILL_VIEWPORT,
VIEWPORT_PRESETS,
clampViewportSize,
presetViewport,
rotateViewport,
viewportSize,
type BrowserViewport,
} from '@/lib/browser/viewport';
export type BrowserColorScheme = 'system' | 'light' | 'dark';
/**
* Size and appearance controls for the previewed page.
*
* Shown only when asked for. The width and height boxes are the source of
* truth; the preset list is a shortcut into them, which is why choosing a
* preset and then typing a size are the same action from here on.
*/
export const BrowserDeviceBar: React.FC<{
viewport: BrowserViewport;
onViewportChange: (viewport: BrowserViewport) => void;
colorScheme: BrowserColorScheme;
onColorSchemeChange: (scheme: BrowserColorScheme) => void;
scale: number;
}> = ({ viewport, onViewportChange, colorScheme, onColorSchemeChange, scale }) => {
const { t } = useI18n();
const size = viewportSize(viewport);
const presetId = viewport.kind === 'preset' ? viewport.id : '';
const commitSize = (side: 'width' | 'height', raw: string) => {
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return;
const current = size ?? { width: 1280, height: 800 };
onViewportChange({
kind: 'custom',
width: clampViewportSize(side === 'width' ? parsed : current.width),
height: clampViewportSize(side === 'height' ? parsed : current.height),
});
};
const inputClass = cn(
'h-6 w-14 rounded-full border border-border/50 bg-[var(--surface-elevated)] px-2 text-center',
'typography-micro tabular-nums text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
);
return (
<div className="flex items-center gap-1.5 border-b border-border bg-[var(--surface-background)] px-2 py-1">
<select
value={presetId}
onChange={(event) => {
const next = presetViewport(event.target.value);
onViewportChange(next ?? FILL_VIEWPORT);
}}
aria-label={t('contextPanel.browser.device.preset')}
className={cn(
'h-6 shrink-0 rounded-full border border-border/50 bg-[var(--surface-elevated)] px-2',
'typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
)}
>
<option value="">{t('contextPanel.browser.device.responsive')}</option>
{VIEWPORT_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.label}</option>
))}
</select>
<input
value={size ? String(size.width) : ''}
onChange={(event) => commitSize('width', event.target.value)}
placeholder="—"
inputMode="numeric"
aria-label={t('contextPanel.browser.device.width')}
className={inputClass}
/>
<span className="typography-micro text-muted-foreground">×</span>
<input
value={size ? String(size.height) : ''}
onChange={(event) => commitSize('height', event.target.value)}
placeholder="—"
inputMode="numeric"
aria-label={t('contextPanel.browser.device.height')}
className={inputClass}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="w-6 shrink-0 rounded-full px-0 text-muted-foreground hover:text-foreground"
onClick={() => onViewportChange(rotateViewport(viewport))}
disabled={!size}
aria-label={t('contextPanel.browser.device.rotate')}
>
<Icon name="refresh" className="size-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('contextPanel.browser.device.rotate')}</TooltipContent>
</Tooltip>
{/* Only worth saying when the page is not shown at its real size. */}
{size && scale < 1 ? (
<span className="shrink-0 typography-micro tabular-nums text-muted-foreground">
{Math.round(scale * 100)}%
</span>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
{(['system', 'light', 'dark'] as const).map((scheme) => (
<Button
key={scheme}
type="button"
variant={colorScheme === scheme ? 'secondary' : 'ghost'}
size="xs"
className={cn(
'shrink-0 rounded-full px-2.5 typography-micro',
colorScheme === scheme ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
onClick={() => onColorSchemeChange(scheme)}
aria-pressed={colorScheme === scheme}
>
{t(scheme === 'system'
? 'contextPanel.browser.device.schemeSystem'
: scheme === 'light'
? 'contextPanel.browser.device.schemeLight'
: 'contextPanel.browser.device.schemeDark')}
</Button>
))}
</div>
</div>
);
};
@@ -0,0 +1,143 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import { fetchDevServers, mergeDevServerCandidates, type DevServerDiscovery } from '@/lib/browser/devServers';
import { clearAnnouncedDevServers, useAnnouncedDevServers } from '@/lib/browser/announcedServers';
import { browserUrlLabel, isLoopbackUrl } from '@/lib/browser/url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
/**
* What the panel shows before anything is loaded.
*
* Rather than an inert placeholder, this lists the servers actually running,
* which is almost always what the user came here to open. Discovery failure is
* stated plainly instead of being rendered as "nothing is running" — the two
* mean very different things to someone whose dev server is definitely up.
*/
/** The base path a server is served under, or '' when it sits at the root. */
const pathLabel = (url: string): string => {
try {
const path = new URL(url).pathname;
return path === '/' ? '' : path;
} catch {
return '';
}
};
/** Re-checked while the panel is open: a project's servers appear seconds apart. */
const REFRESH_INTERVAL_MS = 2_000;
/**
* True when the listed servers are on another machine and this client has no
* way to reach them. The desktop shell tunnels a local port for exactly this
* case; a browser tab has no equivalent, and its `localhost` is its own.
*/
const isUnreachableFromHere = (): boolean => {
if (typeof window === 'undefined') return false;
if (window.__OPENCHAMBER_ELECTRON__) return false;
const baseUrl = getRuntimeApiBaseUrl();
if (!baseUrl) return false;
try {
return !isLoopbackUrl(new URL(baseUrl, window.location.href).toString());
} catch {
return false;
}
};
export const BrowserEmptyState: React.FC<{
onOpen: (url: string) => void;
directory?: string;
}> = ({ onOpen, directory = '' }) => {
const { t } = useI18n();
const [discovery, setDiscovery] = React.useState<DevServerDiscovery>({ kind: 'loading' });
const announced = useAnnouncedDevServers(directory);
const [remoteOnly] = React.useState(isUnreachableFromHere);
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout> | null = null;
const controller = new AbortController();
const poll = () => {
void fetchDevServers(controller.signal).then((result) => {
if (!active) return;
setDiscovery(result);
// One look is a snapshot of whichever servers happened to be up first.
timer = setTimeout(poll, REFRESH_INTERVAL_MS);
});
};
poll();
return () => {
active = false;
if (timer) clearTimeout(timer);
controller.abort();
};
}, []);
const candidates = React.useMemo(() => mergeDevServerCandidates({
announced,
discovered: discovery.kind === 'ready' ? discovery.servers : null,
}), [announced, discovery]);
return (
// The whole panel must not scroll: a centred column that overflows clips its
// own top, and no amount of scrolling reaches it. Only the list of servers
// scrolls, and it shrinks to whatever room is left before it does.
<div className="absolute inset-0 flex flex-col items-center justify-center gap-5 overflow-hidden bg-background p-6 text-center">
<OpenChamberLogo width={110} height={110} className="shrink-0 opacity-20" />
<div className="flex shrink-0 flex-col gap-1">
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.empty')}</span>
<span className="typography-micro text-muted-foreground">{t('contextPanel.browser.emptyHint')}</span>
</div>
{candidates.length > 0 ? (
<div className="flex min-h-0 w-full max-w-sm flex-col gap-1">
<span className="shrink-0 typography-micro text-left text-muted-foreground">
{announced.length > 0
? t('contextPanel.browser.devServers.justStarted')
: t('contextPanel.browser.devServers.title')}
</span>
{remoteOnly ? (
<span className="shrink-0 pb-1 text-left typography-micro text-muted-foreground">
{t('contextPanel.browser.devServers.remoteOnly')}
</span>
) : null}
<div className="flex min-h-0 flex-col gap-1 overflow-y-auto pr-0.5">
{candidates.map((candidate) => (
<Button
key={candidate.port}
type="button"
variant="outline"
size="sm"
className="w-full shrink-0 justify-start gap-2"
onClick={() => {
// The offer is answered; leaving it up would keep suggesting
// servers behind a page the user is already looking at.
clearAnnouncedDevServers(directory);
onOpen(candidate.url);
}}
>
<Icon name="global" className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{browserUrlLabel(candidate.url) || candidate.url}</span>
<span className="ml-auto truncate typography-micro text-muted-foreground">
{pathLabel(candidate.url)}
</span>
</Button>
))}
</div>
</div>
) : null}
{candidates.length === 0 && discovery.kind === 'unavailable' ? (
<span className="typography-micro text-muted-foreground">
{t('contextPanel.browser.devServers.unavailable')}
</span>
) : null}
</div>
);
};
@@ -0,0 +1,907 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { useUIStore } from '@/stores/useUIStore';
import { BLANK_URL, isLoopbackUrl, isStartingServerFailure, normalizeBrowserUrl } from '@/lib/browser/url';
import { probeLoopbackStatus } from '@/lib/browser/devServers';
import {
cancelAnnotationSession,
runAnnotationSession,
type AnnotationHost,
type PageCapture,
} from '@/lib/browser/annotationSession';
import { resolveAnnotationOverlayTheme } from '@/lib/browser/overlayTheme';
import { registerBrowserController } from '@/lib/browser/controlClient';
import { suggestFromHistory } from '@/lib/browser/history';
import { selectBrowserHistory, useBrowserHistoryStore } from '@/stores/useBrowserHistoryStore';
import {
DevTunnelUnavailableError,
resolveBrowsableUrl,
shouldTunnelLoopbackUrl,
toDisplayUrl,
} from '@/lib/browser/devTunnel';
import {
buildClickScript,
buildInspectScript,
buildScrollScript,
buildSnapshotScript,
buildTypeScript,
} from '@/lib/browser/pageActions';
import { BrowserToolbar } from './BrowserToolbar';
import { BrowserDeviceBar, type BrowserColorScheme } from './BrowserDeviceBar';
import {
FILL_VIEWPORT,
fitViewport,
isViewportMode,
viewportForMode,
viewportSummary,
type BrowserViewport,
} from '@/lib/browser/viewport';
import { BrowserEmptyState } from './BrowserEmptyState';
import { useAnnotationAttach, useAnnotationOverlayLabels } from './useAnnotationAttach';
import { readEventPayload, useWebviewNavigation } from './useWebviewNavigation';
export type BrowserPaneProps = {
initialUrl: string;
directory: string;
tabID: string;
};
/**
* Chromium is the only host that can give us a real page: cookies, service
* workers, HMR sockets, DevTools, and same-document access for annotation. When
* it is unavailable the surface degrades to a plain iframe that can display a
* page but cannot inspect one, rather than pretending otherwise.
*/
const isChromiumHost = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
);
/** How long to keep waiting for a dev server that is still coming up. */
const DEV_SERVER_WAIT_MS = 40_000;
/** Chromium's zoom is exponential: factor = 1.2 ^ level. */
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4;
const BROWSER_PARTITION = 'persist:openchamber-browser';
/** Kept small: this rides along with every snapshot. */
const CONSOLE_PROBLEM_LIMIT = 20;
const DEV_SERVER_RETRY_DELAY_MS = 600;
/**
* A shorter budget for a server that *answers* but with a 5xx, which is what a
* dev gateway does while the app behind it is still starting. Kept short and
* applied only before the first good load, so a genuine server error — a build
* failure page, say — is shown promptly instead of being hidden behind a
* spinner.
*/
const GATEWAY_WAIT_MS = 20_000;
const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const webviewRef = React.useRef<WebviewElement | null>(null);
// Tracked in state as well as a ref: effects that attach listeners must re-run
// when the view appears, which a stable ref cannot tell them.
const [webviewElement, setWebviewElement] = React.useState<WebviewElement | null>(null);
const attachWebview = React.useCallback((node: WebviewElement | null) => {
webviewRef.current = node;
setWebviewElement(node);
}, []);
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
// Captured once: the webview owns its history from here on, and re-deriving
// this from props would drag the view back to where the tab started.
const initialUrlRef = React.useRef(normalizeBrowserUrl(initialUrl));
const startUrl = initialUrlRef.current !== BLANK_URL ? initialUrlRef.current : '';
// The view is created with its final URL already in `src`, never navigated
// into place afterwards. A tab opened in the background renders hidden, where
// an imperative navigation is lost, and mutating `src` after the element
// exists is not reliably honoured either — both leave a panel that never
// loads. `null` means "still resolving", and the view is not rendered yet.
const [initialSrc, setInitialSrc] = React.useState<string | null>(startUrl ? null : BLANK_URL);
const [address, setAddress] = React.useState(startUrl);
const [isAnnotating, setIsAnnotating] = React.useState(false);
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
const [zoomLevel, setZoomLevel] = React.useState(0);
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
// Read inside agent actions, which are not re-created when the viewport
// changes and would otherwise report whatever it was when they were built.
const viewportRef = React.useRef(viewport);
viewportRef.current = viewport;
/**
* Errors and warnings the page logged, reported with the next snapshot.
*
* A page that looks right and is throwing looks identical to one that is
* fine, and finding out otherwise used to mean opening DevTools by hand.
*/
const consoleProblemsRef = React.useRef<Array<{ level: string; message: string; source: string }>>([]);
const [colorScheme, setColorScheme] = React.useState<BrowserColorScheme>('system');
const [stageSize, setStageSize] = React.useState({ width: 0, height: 0 });
const stageRef = React.useRef<HTMLDivElement | null>(null);
/** When the current run of retries began, per URL. */
const retryRef = React.useRef<{ url: string; startedAt: number } | null>(null);
/** Set once this tab has seen a page that was not a startup error. */
const servedOkRef = React.useRef(false);
const openedAtRef = React.useRef(Date.now());
const persistUrl = React.useCallback((url: string) => {
if (!url || url === BLANK_URL || !directory || !tabID) return;
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const navigation = useWebviewNavigation(webviewElement, {
initialUrl: startUrl,
onUrlChange: React.useCallback((url: string) => {
const display = toDisplayUrl(url);
setAddress(display);
persistUrl(display);
}, [persistUrl]),
});
/** Set when a remote dev server could not be reached from this machine. */
const [tunnelFailedUrl, setTunnelFailedUrl] = React.useState<string | null>(null);
const attachAnnotation = useAnnotationAttach(directory);
const overlayLabels = useAnnotationOverlayLabels();
const isLoading = navigation.status.kind === 'loading';
const history = useBrowserHistoryStore(selectBrowserHistory(directory));
const recordHistoryVisit = useBrowserHistoryStore((state) => state.recordVisit);
const forgetHistoryVisit = useBrowserHistoryStore((state) => state.forget);
// Recorded once a page has actually loaded, and with the title it reported:
// an address that failed to open is not somewhere to offer going back to.
React.useEffect(() => {
if (navigation.status.kind !== 'ready') return;
recordHistoryVisit(directory, {
url: toDisplayUrl(navigation.status.url),
title: navigation.status.title,
});
}, [directory, navigation.status, recordHistoryVisit]);
const suggestions = React.useMemo(
() => suggestFromHistory(history, address),
[history, address],
);
const loadUrl = React.useCallback((value: string) => {
const next = normalizeBrowserUrl(value);
if (next === BLANK_URL) return;
// The address bar shows what the user asked for; a tunnel only changes
// where the bytes come from, and surfacing 127.0.0.1:<random> would be
// confusing and useless to copy.
setAddress(next);
setTunnelFailedUrl(null);
void resolveBrowsableUrl(next).then((target) => {
const webview = webviewRef.current;
if (!webview) {
setInitialSrc(target);
return;
}
try {
webview.loadURL(target);
} catch {
// Not attached yet: hand the navigation to the attribute, which
// Chromium applies once the view attaches.
setInitialSrc(target);
}
}).catch((error: unknown) => {
// Loading the address here anyway would answer from this machine while
// showing the remote one's address. Say what happened instead.
if (error instanceof DevTunnelUnavailableError) setTunnelFailedUrl(next);
});
}, []);
// Resolving through the tunnel is what lets a persisted loopback URL reach a
// dev server on a remote host; locally it returns the URL unchanged.
React.useEffect(() => {
if (!startUrl) return;
let active = true;
void resolveBrowsableUrl(startUrl)
.then((target) => { if (active) setInitialSrc(target); })
.catch((error: unknown) => {
if (!active) return;
if (error instanceof DevTunnelUnavailableError) {
// The view still needs a src or the panel stays blank forever; it
// gets a blank one, with the failure stated over it.
setTunnelFailedUrl(startUrl);
setInitialSrc(BLANK_URL);
return;
}
setInitialSrc(startUrl);
});
return () => { active = false; };
// Only ever the initial navigation; later changes come from the user.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const annotationHost = React.useMemo<AnnotationHost>(() => ({
executeJavaScript: async (code: string, userGesture?: boolean) => {
const webview = webviewRef.current;
if (!webview) throw new Error('Browser view is not available');
return webview.executeJavaScript(code, userGesture);
},
capturePage: async (): Promise<PageCapture | null> => {
const webview = webviewRef.current;
if (!webview) return null;
const webContentsId = webview.getWebContentsId();
if (!Number.isFinite(webContentsId)) return null;
return await invokeDesktopCommand<PageCapture>('desktop_browser_capture_page', { webContentsId });
},
}), []);
const handleAnnotate = React.useCallback(() => {
if (isAnnotating) {
setIsAnnotating(false);
void cancelAnnotationSession(annotationHost);
return;
}
if (!navigation.url) {
toast.error(t('contextPanel.browser.annotate.noPage'));
return;
}
const theme = resolveAnnotationOverlayTheme(
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
);
setIsAnnotating(true);
void runAnnotationSession({
host: annotationHost,
theme,
labels: overlayLabels,
})
.then(async (result) => {
setIsAnnotating(false);
if (!result) return;
await attachAnnotation(result);
})
.catch(() => {
setIsAnnotating(false);
toast.error(t('contextPanel.browser.annotate.failed'));
});
}, [annotationHost, attachAnnotation, currentTheme, isAnnotating, navigation.url, overlayLabels, t]);
// Escape leaves annotation mode from the app side too: the overlay owns the
// in-page Escape, but the panel can be focused instead.
React.useEffect(() => {
if (!isAnnotating) return;
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
setIsAnnotating(false);
void cancelAnnotationSession(annotationHost);
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [annotationHost, isAnnotating]);
// Agent-driven actions. Waiting for the page to settle after a navigation is
// deliberate: a snapshot taken mid-load describes a page that no longer
// exists by the time the agent reads it.
const waitForIdle = React.useCallback(async (timeoutMs = 8_000): Promise<boolean> => {
const startedAt = Date.now();
for (;;) {
const webview = webviewRef.current;
if (!webview) return false;
let busy = false;
try {
busy = webview.isLoading();
} catch {
return false;
}
if (!busy) return true;
// A page with a looping video or a long-lived stream can report loading
// indefinitely. Give up waiting and act on it anyway rather than letting
// the whole action expire.
if (Date.now() - startedAt > timeoutMs) return false;
await new Promise((resolve) => setTimeout(resolve, 120));
}
}, []);
const runControlAction = React.useCallback(async (
action: string,
parameters: Record<string, unknown>,
): Promise<unknown> => {
const webview = webviewRef.current;
if (!webview) throw new Error('The browser panel is not ready');
// Showing the bar when the agent sizes the page keeps the change visible:
// the user should see which layout is being looked at, not just that it
// suddenly narrowed.
const applyViewportParameter = (): void => {
if (!isViewportMode(parameters.viewport)) return;
setViewport(viewportForMode(parameters.viewport));
setShowDeviceBar(true);
};
if (action === 'browser.back' || action === 'browser.forward') {
const goingBack = action === 'browser.back';
const canMove = goingBack ? webview.canGoBack() : webview.canGoForward();
if (!canMove) {
throw new Error(goingBack
? 'There is nothing to go back to in this tab'
: 'There is nothing to go forward to in this tab');
}
if (goingBack) webview.goBack();
else webview.goForward();
await new Promise((resolve) => setTimeout(resolve, 150));
await waitForIdle();
let title = '';
try { title = webview.getTitle() || ''; } catch { title = ''; }
return { url: toDisplayUrl(webview.getURL()), title };
}
if (action === 'browser.capture') {
// Wait for a settled page first: a screenshot of a half-painted layout is
// worse than none, because it looks like a finished one.
await waitForIdle();
const capture = await annotationHost.capturePage();
if (!capture) throw new Error('The page could not be captured');
let title = '';
try { title = webview.getTitle() || ''; } catch { title = ''; }
return {
...capture,
url: toDisplayUrl(webview.getURL()),
title,
viewport: viewportSummary(viewportRef.current),
};
}
if (action === 'browser.resize') {
if (!isViewportMode(parameters.viewport)) throw new Error('viewport is required');
applyViewportParameter();
// Let the resize land before reporting it, so a snapshot that follows
// describes the new layout rather than the old one.
await new Promise((resolve) => setTimeout(resolve, 200));
await waitForIdle();
return { viewport: viewportSummary(viewportForMode(parameters.viewport)) };
}
if (action === 'browser.open') {
const url = typeof parameters.url === 'string' ? parameters.url : '';
if (!url) throw new Error('url is required');
applyViewportParameter();
loadUrl(url);
await new Promise((resolve) => setTimeout(resolve, 150));
const settled = await waitForIdle(25_000);
let title = '';
try {
title = webview.getTitle() || '';
} catch {
title = '';
}
// `settled: false` means the page is still fetching, not that opening
// failed — the agent can snapshot it and decide for itself.
return {
url: normalizeBrowserUrl(url),
title,
opened: true,
settled,
viewport: viewportSummary(viewportRef.current),
};
}
await waitForIdle();
const asOptionalString = (value: unknown): string | undefined => (
typeof value === 'string' && value ? value : undefined
);
const buildScript = (): string | null => {
switch (action) {
case 'browser.snapshot':
return buildSnapshotScript({ selector: asOptionalString(parameters.selector) });
case 'browser.click':
return buildClickScript({
selector: asOptionalString(parameters.selector),
text: asOptionalString(parameters.text),
});
case 'browser.type':
return buildTypeScript({
selector: String(parameters.selector ?? ''),
value: String(parameters.value ?? ''),
submit: parameters.submit === true,
});
case 'browser.inspect':
return buildInspectScript({ selector: String(parameters.selector ?? '') });
case 'browser.scroll':
return buildScrollScript({
selector: asOptionalString(parameters.selector),
direction: asOptionalString(parameters.direction),
});
default:
return null;
}
};
const script = buildScript();
if (!script) throw new Error(`Unsupported browser action: ${action}`);
const result = await webview.executeJavaScript(script, true);
if (!result || typeof result !== 'object') {
throw new Error('The page returned no result');
}
const record = result as Record<string, unknown>;
if (record.ok !== true) {
throw new Error(typeof record.error === 'string' && record.error ? record.error : 'Browser action failed');
}
// A snapshot has to say which layout it describes, or the agent cannot tell
// a mobile rendering from a desktop one.
if (action === 'browser.snapshot') {
const problems = consoleProblemsRef.current;
return {
...record,
viewport: viewportSummary(viewportRef.current),
...(problems.length > 0 ? { consoleProblems: [...problems] } : {}),
};
}
// A click or a submit commonly starts a navigation; let it land so the
// agent's next snapshot sees the page the action produced.
if (action === 'browser.click' || (action === 'browser.type' && parameters.submit === true)) {
await new Promise((resolve) => setTimeout(resolve, 150));
await waitForIdle();
}
return result;
}, [annotationHost, loadUrl, waitForIdle]);
React.useEffect(
() => registerBrowserController({ run: runControlAction }),
[runControlAction],
);
// Leaving the tab must not strand an overlay or live style overrides on the page.
React.useEffect(() => {
const host = annotationHost;
return () => { void cancelAnnotationSession(host); };
}, [annotationHost]);
React.useEffect(() => {
if (!webviewElement) return;
const onConsoleMessage = (event: Event) => {
const detail = event as unknown as { level?: number; message?: string; sourceId?: string; line?: number };
// 2 is warning, 3 is error; anything quieter is the page talking to itself.
if (typeof detail.level !== 'number' || detail.level < 2) return;
const source = detail.sourceId ? `${detail.sourceId}${detail.line ? `:${detail.line}` : ''}` : '';
consoleProblemsRef.current.push({
level: detail.level >= 3 ? 'error' : 'warning',
message: String(detail.message ?? '').slice(0, 400),
source,
});
if (consoleProblemsRef.current.length > CONSOLE_PROBLEM_LIMIT) {
consoleProblemsRef.current.splice(0, consoleProblemsRef.current.length - CONSOLE_PROBLEM_LIMIT);
}
};
// Each page gets its own record; carrying the last one over would blame a
// new page for the previous page's failures.
const onStartLoading = () => { consoleProblemsRef.current = []; };
webviewElement.addEventListener('console-message', onConsoleMessage);
webviewElement.addEventListener('did-start-loading', onStartLoading);
return () => {
webviewElement.removeEventListener('console-message', onConsoleMessage);
webviewElement.removeEventListener('did-start-loading', onStartLoading);
};
}, [webviewElement]);
/**
* Keeps loopback navigations on the machine the page came from.
*
* A tunnelled page can send the view to another local port — a docs server
* behind a dev gateway, an API on its own port. That navigation happens
* inside the view, so nothing resolved it, and it would be looked for on this
* machine instead of the host.
*
* A link or a script navigation is caught before it happens. A server
* redirect cannot be: by the time the view reports it, it is already loading.
* That one is recovered from its failure instead, once per address, so a port
* that genuinely is not there still fails honestly.
*/
const retunneledUrlsRef = React.useRef(new Set<string>());
// Asking for an address again is a fresh request, so the recovery budget
// comes back with it. The automatic retry deliberately does not reset it.
const loadUrlFromUser = React.useCallback((value: string) => {
retunneledUrlsRef.current.clear();
loadUrl(value);
}, [loadUrl]);
React.useEffect(() => {
if (!webviewElement) return;
const onWillNavigate = (event: Event) => {
const detail = readEventPayload<{ url?: string }>(event);
const target = typeof detail.url === 'string' ? detail.url : '';
if (!target || !shouldTunnelLoopbackUrl(target)) return;
event.preventDefault();
loadUrl(target);
};
const onFailLoad = (event: Event) => {
const detail = readEventPayload<{
errorCode?: number;
validatedURL?: string;
isMainFrame?: boolean;
}>(event);
if (detail.isMainFrame === false) return;
// Superseded navigations are not failures.
if (detail.errorCode === -3) return;
const target = typeof detail.validatedURL === 'string' ? detail.validatedURL : '';
if (!target || !shouldTunnelLoopbackUrl(target)) return;
if (retunneledUrlsRef.current.has(target)) return;
retunneledUrlsRef.current.add(target);
loadUrl(target);
};
webviewElement.addEventListener('will-navigate', onWillNavigate);
webviewElement.addEventListener('did-fail-load', onFailLoad);
return () => {
webviewElement.removeEventListener('will-navigate', onWillNavigate);
webviewElement.removeEventListener('did-fail-load', onFailLoad);
};
}, [loadUrl, webviewElement]);
// Popups open in place; a detached window would escape the panel entirely.
React.useEffect(() => {
if (!webviewElement) return;
const onNewWindow = (event: Event) => {
const detail = (event as CustomEvent<{ url?: string }>).detail;
event.preventDefault();
if (detail?.url) loadUrl(detail.url);
};
webviewElement.addEventListener('new-window', onNewWindow);
return () => webviewElement.removeEventListener('new-window', onNewWindow);
}, [loadUrl, webviewElement]);
const applyZoom = React.useCallback((level: number) => {
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
setZoomLevel(next);
try {
webviewRef.current?.setZoomLevel(next);
} catch {
// Not attached yet; the next change applies it.
}
}, []);
const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
void invokeDesktopCommand('desktop_browser_clear_data', {
partition: BROWSER_PARTITION,
cookies: what === 'cookies',
cache: what === 'cache',
})
.then(() => {
toast.success(t(what === 'cookies'
? 'contextPanel.browser.clearedCookies'
: 'contextPanel.browser.clearedCache'));
// Cleared storage only shows in a page that reloads without it.
try { webviewRef.current?.reloadIgnoringCache(); } catch { /* not attached */ }
})
.catch(() => toast.error(t('contextPanel.browser.clearFailed')));
}, [t]);
// The stage is measured rather than assumed: the panel is resizable, and a
// viewport that fitted a moment ago may not fit now.
React.useEffect(() => {
const stage = stageRef.current;
if (!stage || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver((entries) => {
const rect = entries[0]?.contentRect;
if (rect) setStageSize({ width: rect.width, height: rect.height });
});
observer.observe(stage);
return () => observer.disconnect();
}, []);
const applyColorScheme = React.useCallback((scheme: BrowserColorScheme) => {
setColorScheme(scheme);
const webview = webviewRef.current;
if (!webview) return;
let webContentsId = -1;
try {
webContentsId = webview.getWebContentsId();
} catch {
return;
}
void invokeDesktopCommand('desktop_browser_set_color_scheme', { webContentsId, scheme })
.catch((error: unknown) => {
setColorScheme('system');
toast.error(error instanceof Error ? error.message : t('contextPanel.browser.device.schemeFailed'));
});
}, [t]);
const handleReload = React.useCallback(() => {
try {
if (isLoading) webviewRef.current?.stop();
else webviewRef.current?.reload();
} catch {
// Not attached yet.
}
}, [isLoading]);
// A page opened the moment its dev server launched is not ready twice over:
// first nothing is listening at all, then a gateway answers while the app
// behind it is still starting. Neither is an error the user can act on, and
// both used to leave them pressing reload. Both are waited out here.
const status = navigation.status;
React.useEffect(() => {
const reloadSoon = (): (() => void) => {
setIsWaitingForServer(true);
const timer = setTimeout(() => {
try {
webviewRef.current?.reload();
} catch {
// View went away; the next mount starts over.
}
}, DEV_SERVER_RETRY_DELAY_MS);
return () => clearTimeout(timer);
};
// Nothing is listening yet.
if (status.kind === 'failed') {
if (!isStartingServerFailure(status.code, status.url)) {
setIsWaitingForServer(false);
return;
}
const now = Date.now();
const run = retryRef.current?.url === status.url
? retryRef.current
: { url: status.url, startedAt: now };
retryRef.current = run;
if (now - run.startedAt > DEV_SERVER_WAIT_MS) {
setIsWaitingForServer(false);
return;
}
return reloadSoon();
}
// Mid-navigation: leave whatever state the previous decision set, so a
// retry does not flash the page behind the waiting screen and back.
if (status.kind === 'loading') return;
retryRef.current = null;
if (status.kind !== 'ready' || !status.url || !isLoopbackUrl(status.url)) {
servedOkRef.current = true;
setIsWaitingForServer(false);
return;
}
if (servedOkRef.current || Date.now() - openedAtRef.current > GATEWAY_WAIT_MS) {
servedOkRef.current = true;
setIsWaitingForServer(false);
return;
}
// The page loaded, but a 5xx here means the server answered on behalf of an
// app that is not up yet. Checked by status rather than by reading the page:
// guessing from its contents would mean encoding what each dev server's
// error page looks like, which is exactly the trap this panel came out of.
let cancelled = false;
let cancelReload: (() => void) | null = null;
// Probe the address on the host, not the local tunnel port: the check runs
// on the server, where our ephemeral port means nothing. Asking about it
// failed every time, which read as "settled" and left the page on the error
// until a manual reload.
void probeLoopbackStatus(toDisplayUrl(status.url)).then((httpStatus) => {
if (cancelled) return;
if (httpStatus === null || httpStatus < 500) {
servedOkRef.current = true;
setIsWaitingForServer(false);
return;
}
cancelReload = reloadSoon();
});
return () => {
cancelled = true;
cancelReload?.();
};
}, [status]);
const failed = navigation.status.kind === 'failed' && !isWaitingForServer ? navigation.status : null;
const layout = fitViewport(viewport, stageSize);
return (
<div className="absolute inset-0 flex flex-col bg-background">
<BrowserToolbar
address={address}
onAddressChange={setAddress}
onSubmit={loadUrlFromUser}
suggestions={suggestions}
onForgetSuggestion={(url) => forgetHistoryVisit(directory, url)}
onBack={() => { try { webviewRef.current?.goBack(); } catch { /* not attached */ } }}
onForward={() => { try { webviewRef.current?.goForward(); } catch { /* not attached */ } }}
onReload={handleReload}
onOpenExternal={() => void openExternalUrl(navigation.url || address)}
canGoBack={navigation.canGoBack}
canGoForward={navigation.canGoForward}
isLoading={isLoading}
onAnnotate={handleAnnotate}
isAnnotating={isAnnotating}
onOpenDevTools={() => { try { webviewRef.current?.openDevTools(); } catch { /* not attached */ } }}
onHardReload={() => { try { webviewRef.current?.reloadIgnoringCache(); } catch { /* not attached */ } }}
onZoomIn={() => applyZoom(zoomLevel + ZOOM_STEP)}
onZoomOut={() => applyZoom(zoomLevel - ZOOM_STEP)}
onZoomReset={() => applyZoom(0)}
zoomPercent={Math.round(Math.pow(1.2, zoomLevel) * 100)}
onClearCookies={() => clearBrowsingData('cookies')}
onClearCache={() => clearBrowsingData('cache')}
onToggleDeviceBar={() => setShowDeviceBar((current) => !current)}
isDeviceBarOpen={showDeviceBar}
/>
{showDeviceBar ? (
<BrowserDeviceBar
viewport={viewport}
onViewportChange={setViewport}
colorScheme={colorScheme}
onColorSchemeChange={applyColorScheme}
scale={layout?.scale ?? 1}
/>
) : null}
<div
ref={stageRef}
className={cn(
'relative min-h-0 flex-1 bg-background',
// A sized viewport sits on a backdrop so its edges are visible; at
// fill there is nothing to frame.
layout && 'flex items-center justify-center overflow-hidden bg-[var(--surface-muted)]',
)}
>
{initialSrc !== null ? (
<webview
ref={attachWebview}
src={initialSrc}
partition="persist:openchamber-browser"
allowpopups
style={layout
? {
// Laid out at the chosen size and scaled visually: the page must
// measure itself at the width being tested, not at the panel's.
width: `${layout.width}px`,
height: `${layout.height}px`,
transform: `scale(${layout.scale})`,
border: 'none',
flex: 'none',
boxShadow: '0 2px 18px rgba(0,0,0,.28)',
}
: { width: '100%', height: '100%', border: 'none' }}
/>
) : null}
{initialSrc !== null && !startUrl && !navigation.url && !isLoading ? (
<BrowserEmptyState onOpen={loadUrlFromUser} directory={directory} />
) : null}
{isWaitingForServer ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.waitingForServer')}</span>
<span className="typography-micro text-muted-foreground">{t('contextPanel.browser.waitingForServerHint')}</span>
</div>
) : null}
{tunnelFailedUrl ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.tunnelFailed')}</span>
<span className="typography-micro text-muted-foreground">
{t('contextPanel.browser.tunnelFailedHint', { url: tunnelFailedUrl })}
</span>
</div>
) : null}
{failed && !tunnelFailedUrl ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
<span className="typography-ui-header text-foreground">
{failed.crashed ? t('contextPanel.browser.crashed') : t('contextPanel.browser.loadFailed')}
</span>
<span className="typography-micro text-muted-foreground">
{failed.crashed
? t('contextPanel.browser.crashedHint')
: failed.description || t('contextPanel.browser.loadFailedUnknown')}
</span>
</div>
) : null}
{isLoading ? (
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 overflow-hidden">
<div className="h-full w-1/3 animate-[browser-progress_1.1s_ease-in-out_infinite] bg-[var(--primary)]" />
</div>
) : null}
</div>
</div>
);
};
/**
* Non-Chromium runtimes get a plain iframe. Same-origin policy makes the page
* opaque to us here: no navigation events, no annotation, no console. The
* toolbar reflects that instead of offering controls that would silently fail.
*/
const IframeBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
const normalized = normalizeBrowserUrl(initialUrl);
const startUrl = normalized !== BLANK_URL ? normalized : '';
const [address, setAddress] = React.useState(startUrl);
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
const [history, setHistory] = React.useState<string[]>(startUrl ? [startUrl] : []);
const [historyIndex, setHistoryIndex] = React.useState(startUrl ? 0 : -1);
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
const persistUrl = React.useCallback((url: string) => {
if (!url || url === BLANK_URL || !directory || !tabID) return;
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const visitedAddresses = useBrowserHistoryStore(selectBrowserHistory(directory));
const recordHistoryVisit = useBrowserHistoryStore((state) => state.recordVisit);
const forgetHistoryVisit = useBrowserHistoryStore((state) => state.forget);
const suggestions = React.useMemo(
() => suggestFromHistory(visitedAddresses, address),
[visitedAddresses, address],
);
const navigate = React.useCallback((value: string) => {
const next = normalizeBrowserUrl(value);
if (next === BLANK_URL) return;
setAddress(next);
setLoadedUrl(next);
persistUrl(next);
// The page is opaque here, so there is no load event and no title to wait
// for; what was asked for is the only thing this runtime can record.
recordHistoryVisit(directory, { url: next });
setHistory((current) => {
const kept = historyIndex >= 0 ? current.slice(0, historyIndex + 1) : [];
if (kept[kept.length - 1] === next) {
setHistoryIndex(kept.length - 1);
return kept;
}
setHistoryIndex(kept.length);
return [...kept, next];
});
}, [directory, historyIndex, persistUrl, recordHistoryVisit]);
const goTo = React.useCallback((index: number) => {
const next = history[index];
if (!next) return;
setHistoryIndex(index);
setAddress(next);
setLoadedUrl(next);
persistUrl(next);
}, [history, persistUrl]);
return (
<div className="absolute inset-0 flex flex-col bg-background">
<BrowserToolbar
address={address}
onAddressChange={setAddress}
onSubmit={navigate}
suggestions={suggestions}
onForgetSuggestion={(url) => forgetHistoryVisit(directory, url)}
onBack={() => goTo(historyIndex - 1)}
onForward={() => goTo(historyIndex + 1)}
onReload={bumpReload}
onOpenExternal={() => void openExternalUrl(loadedUrl || address)}
canGoBack={historyIndex > 0}
canGoForward={historyIndex >= 0 && historyIndex < history.length - 1}
isLoading={false}
/>
<div className="relative min-h-0 flex-1 bg-background">
{loadedUrl ? (
<iframe
key={`${loadedUrl}|${reloadNonce}`}
src={loadedUrl}
title={t('contextPanel.browser.frameTitle')}
className="h-full w-full border-none bg-white"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
/>
) : (
<BrowserEmptyState onOpen={navigate} />
)}
</div>
</div>
);
};
export const BrowserPane: React.FC<BrowserPaneProps> = (props) => {
const [chromium] = React.useState(isChromiumHost);
return chromium ? <WebviewBrowser {...props} /> : <IframeBrowser {...props} />;
};
@@ -0,0 +1,241 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { BrowserAddressSuggestions } from './BrowserAddressSuggestions';
import type { BrowserHistoryEntry } from '@/lib/browser/history';
import { cn } from '@/lib/utils';
import type { IconName } from '@/components/icon/icons';
type ToolbarButtonProps = {
icon: IconName;
label: string;
onClick: () => void;
disabled?: boolean;
pressed?: boolean;
};
const ToolbarButton: React.FC<ToolbarButtonProps> = ({ icon, label, onClick, disabled, pressed }) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={pressed ? 'secondary' : 'ghost'}
size="xs"
className={cn(
'w-6 shrink-0 rounded-full px-0 text-muted-foreground',
'hover:text-foreground',
pressed && 'text-foreground',
)}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={pressed}
>
<Icon name={icon} className="size-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{label}</TooltipContent>
</Tooltip>
);
export type BrowserToolbarProps = {
address: string;
/** Addresses already visited in this project, offered while typing. */
suggestions?: readonly BrowserHistoryEntry[];
onForgetSuggestion?: (url: string) => void;
onAddressChange: (value: string) => void;
onSubmit: (value: string) => void;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onOpenExternal: () => void;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
/** These need a real Chromium host; hidden without one. */
onAnnotate?: () => void;
onOpenDevTools?: () => void;
isAnnotating?: boolean;
onHardReload?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onZoomReset?: () => void;
/** Whole percent, e.g. 110. Controls hide at 100 to keep the bar quiet. */
zoomPercent?: number;
onClearCookies?: () => void;
onClearCache?: () => void;
onToggleDeviceBar?: () => void;
isDeviceBarOpen?: boolean;
};
export const BrowserToolbar: React.FC<BrowserToolbarProps> = ({
address,
suggestions = [],
onForgetSuggestion,
onAddressChange,
onSubmit,
onBack,
onForward,
onReload,
onOpenExternal,
canGoBack,
canGoForward,
isLoading,
onAnnotate,
onOpenDevTools,
isAnnotating,
onHardReload,
onZoomIn,
onZoomOut,
onZoomReset,
zoomPercent = 100,
onClearCookies,
onClearCache,
onToggleDeviceBar,
isDeviceBarOpen,
}) => {
const { t } = useI18n();
const [isAddressFocused, setIsAddressFocused] = React.useState(false);
const [activeSuggestion, setActiveSuggestion] = React.useState(-1);
const visibleSuggestions = isAddressFocused ? suggestions : [];
// A new list is a new choice; keeping an old index would highlight whatever
// happens to sit in that position now.
React.useEffect(() => {
setActiveSuggestion(-1);
}, [address, isAddressFocused]);
const submitAddress = (value: string) => {
setIsAddressFocused(false);
setActiveSuggestion(-1);
onSubmit(value);
};
const onAddressKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (visibleSuggestions.length === 0) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const count = visibleSuggestions.length;
// Wraps through "nothing selected", so the typed address stays reachable.
setActiveSuggestion((current) => {
const next = current + step;
if (next < -1) return count - 1;
if (next >= count) return -1;
return next;
});
return;
}
if (event.key === 'Escape' && activeSuggestion >= 0) {
event.preventDefault();
setActiveSuggestion(-1);
return;
}
if (event.key === 'Enter' && activeSuggestion >= 0) {
event.preventDefault();
const chosen = visibleSuggestions[activeSuggestion];
if (chosen) submitAddress(chosen.url);
}
};
return (
<div className="flex items-center gap-1 border-b border-border bg-[var(--surface-background)] px-2 py-1">
<ToolbarButton icon="arrow-left" label={t('contextPanel.browser.back')} onClick={onBack} disabled={!canGoBack} />
<ToolbarButton icon="arrow-right" label={t('contextPanel.browser.forward')} onClick={onForward} disabled={!canGoForward} />
<ToolbarButton
icon="refresh"
label={isLoading ? t('contextPanel.browser.stop') : t('contextPanel.browser.reload')}
onClick={onReload}
/>
{onHardReload ? (
<ToolbarButton icon="restart" label={t('contextPanel.browser.hardReload')} onClick={onHardReload} />
) : null}
<form
className="relative min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault();
submitAddress(address);
}}
>
<input
value={address}
onChange={(event) => onAddressChange(event.target.value)}
onFocus={() => setIsAddressFocused(true)}
onBlur={() => setIsAddressFocused(false)}
onKeyDown={onAddressKeyDown}
spellCheck={false}
autoComplete="off"
role="combobox"
aria-expanded={visibleSuggestions.length > 0}
aria-controls="openchamber-browser-address-suggestions"
className={cn(
'h-6 w-full rounded-full border border-border/50 bg-[var(--surface-elevated)] px-3',
'typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
)}
aria-label={t('contextPanel.browser.addressAria')}
/>
<div id="openchamber-browser-address-suggestions">
<BrowserAddressSuggestions
entries={visibleSuggestions}
activeIndex={activeSuggestion}
onSelect={submitAddress}
onForget={(url) => onForgetSuggestion?.(url)}
onHighlight={setActiveSuggestion}
/>
</div>
</form>
{onZoomOut && onZoomIn ? (
<div className="flex shrink-0 items-center">
<ToolbarButton icon="subtract" label={t('contextPanel.browser.zoomOut')} onClick={onZoomOut} />
{zoomPercent !== 100 && onZoomReset ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="shrink-0 rounded-full px-1.5 typography-micro tabular-nums text-muted-foreground"
onClick={onZoomReset}
aria-label={t('contextPanel.browser.zoomReset')}
>
{zoomPercent}%
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('contextPanel.browser.zoomReset')}</TooltipContent>
</Tooltip>
) : null}
<ToolbarButton icon="add" label={t('contextPanel.browser.zoomIn')} onClick={onZoomIn} />
</div>
) : null}
{onClearCookies ? (
<ToolbarButton icon="delete-bin" label={t('contextPanel.browser.clearCookies')} onClick={onClearCookies} />
) : null}
{onClearCache ? (
<ToolbarButton icon="database-2" label={t('contextPanel.browser.clearCache')} onClick={onClearCache} />
) : null}
{onToggleDeviceBar ? (
<ToolbarButton
icon="smartphone"
label={t('contextPanel.browser.deviceToolbar')}
onClick={onToggleDeviceBar}
pressed={isDeviceBarOpen}
/>
) : null}
{onAnnotate ? (
<ToolbarButton
icon="cursor"
label={t('contextPanel.browser.annotate.toggle')}
onClick={onAnnotate}
pressed={isAnnotating}
/>
) : null}
{onOpenDevTools ? (
<ToolbarButton icon="terminal-box" label={t('contextPanel.browser.devTools')} onClick={onOpenDevTools} />
) : null}
<ToolbarButton icon="external-link" label={t('contextPanel.browser.openExternal')} onClick={onOpenExternal} />
</div>
);
};
@@ -0,0 +1,71 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { formatBrowserAnnotationPrompt } from '@/lib/browser/annotationPrompt';
import type { AnnotationSessionResult } from '@/lib/browser/annotationSession';
import type { BrowserAnnotationOverlayLabels } from '@/lib/browser/annotationOverlay';
/**
* Attaches a finished annotation to the active composer.
*
* The screenshot is attached before the text so that a failed image upload
* cannot leave a prompt claiming an attachment that never arrived — the text
* states what actually happened.
*/
export const useAnnotationAttach = (directory: string) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
return React.useCallback(async (result: AnnotationSessionResult): Promise<void> => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) {
toast.error(t('contextPanel.browser.annotate.noSession'));
return;
}
let screenshotAttached = false;
if (result.screenshot) {
try {
await addAttachedFile(result.screenshot);
screenshotAttached = true;
} catch {
screenshotAttached = false;
}
}
addInlineCommentDraft({ directory, sessionKey }, {
source: 'preview-annotation',
fileLabel: result.payload.pageUrl || 'browser',
startLine: 1,
endLine: 1,
code: formatBrowserAnnotationPrompt({
payload: result.payload,
screenshotAttached,
intro: t('contextPanel.browser.annotate.intro'),
}),
language: 'markdown',
text: '',
});
toast.success(t('contextPanel.browser.annotate.attached'));
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, directory, newSessionDraftOpen, t]);
};
/** Overlay labels, resolved through i18n so the in-page UI follows the app locale. */
export const useAnnotationOverlayLabels = (): BrowserAnnotationOverlayLabels => {
const { t } = useI18n();
return React.useMemo(() => ({
select: t('contextPanel.browser.annotate.tool.element'),
marquee: t('contextPanel.browser.annotate.tool.region'),
draw: t('contextPanel.browser.annotate.tool.draw'),
commentPlaceholder: t('contextPanel.browser.annotate.commentPlaceholder'),
submit: t('contextPanel.browser.annotate.submit'),
}), [t]);
};
@@ -0,0 +1,237 @@
import React from 'react';
import { IDLE_NAV_STATUS, type BrowserNavStatus } from '@/lib/browser/contract';
import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore';
import {
INITIAL_CRASH_RECOVERY_STATE,
planCrashRecovery,
type CrashRecoveryState,
} from '@/lib/browser/crashRecovery';
/**
* Translates `<webview>` lifecycle events into a single navigation status.
*
* Chromium reports failures and successes through separate events that can
* arrive in either order, and it emits `did-fail-load` for sub-resources as
* well as for the main frame. Both are handled here so the panel only ever
* sees one authoritative state:
*
* - Sub-frame failures are ignored; only the main frame changes the status.
* - `ERR_ABORTED` is not a failure. It is what Chromium reports when a
* navigation is superseded by the next one, and treating it as an error puts
* an error screen over a page that is loading perfectly well.
*
* Takes the element rather than a ref so the listeners attach when the view
* actually appears. A ref is stable, so an effect keyed on it runs once — and
* silently attaches nothing at all when the view mounts a render later.
*
* `<webview>` puts its event payload directly on the event object rather than
* under `detail`, so reading `detail` yields a failure with no code and no
* description — an error screen that says nothing. Both shapes are read here.
*
* A lost renderer is handled here too. It is reported by neither of the above:
* the page simply stops existing, and the panel would otherwise stay blank with
* no indication that anything happened.
*/
/** Chromium's code for "this navigation was replaced by another one". */
const ERR_ABORTED = -3;
/**
* `about:blank` is the view's resting state, not somewhere the user went. It
* arrives through `did-navigate` like any other address, and taking it at face
* value puts it in the address bar and makes the panel look like it is showing
* a page — which hides the empty state that would otherwise offer somewhere to
* go.
*/
const isRealPageUrl = (url: unknown): url is string => (
typeof url === 'string' && url.length > 0 && url !== 'about:blank'
);
type FailLoadDetail = {
errorCode?: number;
errorDescription?: string;
validatedURL?: string;
isMainFrame?: boolean;
};
/** Reads a webview event payload, whichever shape this Electron version uses. */
export const readEventPayload = <T extends object>(event: Event): Partial<T> => {
const record = event as unknown as { detail?: unknown };
if (record.detail && typeof record.detail === 'object') return record.detail as Partial<T>;
return event as unknown as Partial<T>;
};
export type WebviewNavigation = {
readonly status: BrowserNavStatus;
readonly url: string;
readonly title: string;
readonly canGoBack: boolean;
readonly canGoForward: boolean;
};
export const useWebviewNavigation = (
webview: WebviewElement | null,
{ initialUrl, onUrlChange }: { initialUrl: string; onUrlChange: (url: string) => void },
): WebviewNavigation => {
const [status, setStatus] = React.useState<BrowserNavStatus>(
initialUrl ? { kind: 'loading', url: initialUrl } : IDLE_NAV_STATUS,
);
const [url, setUrl] = React.useState(initialUrl);
const [title, setTitle] = React.useState('');
const [canGoBack, setCanGoBack] = React.useState(false);
const [canGoForward, setCanGoForward] = React.useState(false);
const urlChangeRef = React.useRef(onUrlChange);
urlChangeRef.current = onUrlChange;
// Survives re-attaches so a view that keeps crashing cannot restart its own
// budget by being remounted.
const crashStateRef = React.useRef<CrashRecoveryState>(INITIAL_CRASH_RECOVERY_STATE);
React.useEffect(() => {
if (!webview) return;
const readCurrentUrl = (): string => {
try {
const value = webview.getURL();
return isRealPageUrl(value) ? value : '';
} catch {
return '';
}
};
const syncHistory = () => {
try {
setCanGoBack(webview.canGoBack());
setCanGoForward(webview.canGoForward());
} catch {
// Webview not attached yet; the next event resyncs.
}
};
const commitUrl = (next: string) => {
if (!isRealPageUrl(next)) return;
setUrl(next);
urlChangeRef.current(next);
};
const onStartLoading = () => {
const current = readCurrentUrl();
setStatus({ kind: 'loading', url: current });
};
const onStopLoading = () => {
const current = readCurrentUrl();
let pageTitle = '';
try {
pageTitle = webview.getTitle() || '';
} catch {
pageTitle = '';
}
setTitle(pageTitle);
commitUrl(current);
syncHistory();
// A failure already produced a terminal status; do not overwrite it with
// the `did-stop-loading` that always follows.
setStatus((previous) => (
previous.kind === 'failed' && previous.url === current
? previous
: { kind: 'ready', url: current, title: pageTitle }
));
};
const onNavigate = (event: Event) => {
const detail = readEventPayload<{ url?: string }>(event);
if (isRealPageUrl(detail.url)) {
commitUrl(detail.url);
syncHistory();
}
};
const onFaviconUpdated = (event: Event) => {
const detail = readEventPayload<{ favicons?: string[] }>(event);
const icon = Array.isArray(detail.favicons) ? detail.favicons.find(Boolean) : '';
const page = readCurrentUrl();
if (icon && page) useBrowserFaviconStore.getState().resolve(page, icon);
};
const onTitleUpdated = (event: Event) => {
const detail = readEventPayload<{ title?: string }>(event);
if (typeof detail.title === 'string') setTitle(detail.title);
};
const onFailLoad = (event: Event) => {
const detail = readEventPayload<FailLoadDetail>(event);
if (detail.isMainFrame === false) return;
const code = typeof detail.errorCode === 'number' ? detail.errorCode : 0;
if (code === ERR_ABORTED) return;
setStatus({
kind: 'failed',
url: isRealPageUrl(detail.validatedURL) ? detail.validatedURL : readCurrentUrl(),
code,
description: typeof detail.errorDescription === 'string' ? detail.errorDescription : '',
});
};
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
const onCrashed = () => {
const target = readCurrentUrl();
const plan = planCrashRecovery(crashStateRef.current, Date.now());
if (!plan) {
// Out of attempts: say what happened rather than reload again. The
// toolbar's own reload stays available, which is the user's call.
setStatus({ kind: 'failed', url: target, code: 0, description: '', crashed: true });
return;
}
crashStateRef.current = plan.state;
setStatus({ kind: 'loading', url: target });
recoveryTimer = setTimeout(() => {
recoveryTimer = null;
try {
webview.reload();
} catch {
setStatus({ kind: 'failed', url: target, code: 0, description: '', crashed: true });
}
}, plan.delayMs);
};
webview.addEventListener('did-start-loading', onStartLoading);
webview.addEventListener('did-stop-loading', onStopLoading);
webview.addEventListener('did-navigate', onNavigate);
webview.addEventListener('did-navigate-in-page', onNavigate);
webview.addEventListener('page-title-updated', onTitleUpdated);
webview.addEventListener('page-favicon-updated', onFaviconUpdated);
webview.addEventListener('did-fail-load', onFailLoad);
// Electron renamed this event; older builds still emit only the old name.
webview.addEventListener('render-process-gone', onCrashed);
webview.addEventListener('crashed', onCrashed);
// The webview may already be settled by the time this effect runs. Only
// treat it as settled when a page is actually loaded: a freshly created
// view reports "not loading" before its guest attaches, and settling on
// that would declare an empty page ready and hide the real one behind an
// empty state.
try {
if (!webview.isLoading() && readCurrentUrl()) onStopLoading();
} catch {
// Not attached yet.
}
return () => {
if (recoveryTimer !== null) clearTimeout(recoveryTimer);
webview.removeEventListener('render-process-gone', onCrashed);
webview.removeEventListener('crashed', onCrashed);
webview.removeEventListener('did-start-loading', onStartLoading);
webview.removeEventListener('did-stop-loading', onStopLoading);
webview.removeEventListener('did-navigate', onNavigate);
webview.removeEventListener('did-navigate-in-page', onNavigate);
webview.removeEventListener('page-title-updated', onTitleUpdated);
webview.removeEventListener('page-favicon-updated', onFaviconUpdated);
webview.removeEventListener('did-fail-load', onFailLoad);
};
}, [webview]);
return { status, url, title, canGoBack, canGoForward };
};
@@ -1258,6 +1258,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff');
const hideToolInputPreview = part.tool === 'openchamber'
|| part.tool === 'openchamber_web'
|| part.tool === 'apply_patch'
|| part.tool === 'edit'
|| part.tool === 'multiedit';
@@ -56,6 +56,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'openchamber') {
return <Icon name="openchamber" className={iconClass} />;
}
if (tool === 'openchamber_web') {
return <Icon name="global" className={iconClass} />;
}
if (tool === 'question') {
return <Icon name="survey" className={iconClass} />;
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview';
import { setAnnouncedDevServers } from '@/lib/browser/announcedServers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
@@ -39,6 +41,10 @@ type UrlWatchEntry = {
openedUrl: boolean;
tail: string;
openInPreview: boolean;
/** Addresses announced so far by an auto-discovery run, in announcement order. */
announced: string[];
/** Set once the panel is showing these candidates and wants later ones too. */
offering: boolean;
};
interface ProjectActionsButtonProps {
@@ -49,11 +55,14 @@ interface ProjectActionsButtonProps {
allowMobile?: boolean;
}
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
const AUTO_DISCOVER_ACTION_ID = '__openchamber_auto_discover_preview__';
const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000;
/**
* How long to keep listening after the first server announces itself. A project
* that starts several at once staggers them by a second or two, and opening the
* first to speak would just be a race.
*/
const AUTO_DISCOVER_SETTLE_MS = 3_000;
const stripControlChars = (value: string): string => {
let next = '';
@@ -89,57 +98,6 @@ const normalizeManualOpenUrl = (value: string | undefined): string | null => {
}
};
const extractBestUrl = (value: string): string | null => {
const cleaned = value.replace(ANSI_ESCAPE_PATTERN, '');
const matches = cleaned.match(URL_GLOBAL_PATTERN);
if (!matches || matches.length === 0) {
return null;
}
const normalized = matches
.map((entry) => entry.replace(/[),.;]+$/, ''))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
const portCandidates: Array<{ raw: string; parsed: URL }> = [];
for (const candidate of normalized) {
try {
const parsed = new URL(candidate);
if (parsed.port && parsed.port.length > 0) {
portCandidates.push({ raw: candidate, parsed });
}
} catch {
// noop
}
}
if (portCandidates.length > 0) {
const scoreCandidate = (entry: { raw: string; parsed: URL }): number => {
const { parsed } = entry;
const host = parsed.hostname.toLowerCase();
const isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
const normalizedPath = parsed.pathname || '/';
const pathSegments = normalizedPath.split('/').filter(Boolean).length;
const hasRootPath = normalizedPath === '/' || normalizedPath === '';
const hasQueryOrHash = Boolean(parsed.search || parsed.hash);
let score = 0;
if (isLocalHost) score += 50;
if (hasRootPath) score += 30;
score -= Math.min(pathSegments * 5, 20);
if (hasQueryOrHash) score -= 10;
return score;
};
portCandidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
return portCandidates[0]?.parsed.origin ?? portCandidates[0]?.raw ?? null;
}
return normalized[0] ?? null;
};
export const ProjectActionsButton = ({
projectRef,
@@ -307,6 +265,39 @@ export const ProjectActionsButton = ({
}, [actions, canUseAutoDiscover, selectedActionId]);
React.useEffect(() => {
/**
* Decides what an auto-discovery run found, once its servers have had a
* moment to announce themselves. One address is opened; several are offered
* in the browser panel, because choosing between them would be a guess
* dressed up as a feature.
*/
const settleAutoDiscovery = (runKey: string) => {
delete previewWaitTimeoutByRunKeyRef.current[runKey];
const watch = urlWatchByRunKeyRef.current[runKey];
if (!watch || watch.openedUrl) return;
const store = useTerminalStore.getState();
const run = store.projectActionRuns[runKey];
if (!run) return;
const candidates = watch.announced;
if (candidates.length === 0) return;
watch.openedUrl = true;
store.updateProjectActionRunStatus(runKey, 'running');
if (candidates.length === 1) {
setAnnouncedDevServers(run.directory, []);
setTabPreviewUrl(run.directory, run.tabId, candidates[0], { locked: false, autoOpened: true });
openContextPreview(run.directory, candidates[0]);
return;
}
watch.offering = true;
setAnnouncedDevServers(run.directory, candidates);
useUIStore.getState().openContextSurface(run.directory, 'browser');
toast.info(t('projectActions.toast.multipleServers'));
};
const monitorRuns = () => {
const terminalStore = useTerminalStore.getState();
const terminalSessions = terminalStore.sessions;
@@ -319,7 +310,7 @@ export const ProjectActionsButton = ({
continue;
}
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false, announced: [], offering: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks;
@@ -330,7 +321,34 @@ export const ProjectActionsButton = ({
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
// Auto-discovery inferred the command; it must not also infer the
// address. It collects what the servers announce and decides once they
// have had a moment to all speak up.
// Keep listening after the panel starts offering candidates: servers in
// one project can be seconds apart, and a list that froze at whoever was
// ready first would quietly omit the rest.
if (watch.openInPreview && (!watch.openedUrl || watch.offering)) {
const announced = extractAnnouncedUrls(textForScan);
const before = watch.announced.length;
for (const url of announced) {
if (!watch.announced.includes(url)) watch.announced.push(url);
}
const added = watch.announced.length - before;
if (watch.offering && added > 0) {
setAnnouncedDevServers(entry.directory, watch.announced);
} else if (!watch.openedUrl && before === 0 && watch.announced.length > 0) {
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
previewWaitTimeoutByRunKeyRef.current[runKey] = window.setTimeout(
() => settleAutoDiscovery(runKey),
AUTO_DISCOVER_SETTLE_MS,
);
}
}
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true && !watch.openInPreview
? extractProjectActionUrl(textForScan)
: null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
watch.lastSeenChunkId = lastChunkId;
@@ -551,6 +569,8 @@ export const ProjectActionsButton = ({
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
@@ -10,6 +10,7 @@ import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings';
import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
import { OpenChamberToolsSettings } from './OpenChamberToolsSettings';
import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -52,6 +53,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
<DefaultsSettings />
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<SessionRetentionSettings />
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
{showAbout && <AboutSettings />}
@@ -144,6 +146,7 @@ const GeneralSectionContent: React.FC = () => {
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{showPasskeySettings && <PasskeySettings />}
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[
'fileEditorKeymap',
'autoSaveEnabled',
@@ -0,0 +1,66 @@
import * as React from 'react';
import {
SettingsSection,
SettingsCheckboxRow,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { updateDesktopSettings } from '@/lib/persistence';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
/**
* Which OpenChamber capabilities agents are given.
*
* Each entry is one tool the managed OpenCode child is handed, so the choices
* belong together and not under the CLI's own configuration the binary path
* is about which OpenCode runs, these are about what it can do.
*
* A toggle is written immediately but only reaches agents once OpenCode
* restarts, so each one records a pending restart rather than implying the
* change is already live.
*/
export const OpenChamberToolsSettings: React.FC = () => {
const { t } = useI18n();
const agentControlToolEnabled = useUIStore((state) => state.agentControlToolEnabled);
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled);
const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
void updateDesktopSettings({ agentControlToolEnabled: enabled });
recordDeferredOpenCodeRestart('cli', { id: 'agent-control-tool' });
}, [setAgentControlToolEnabled]);
const handleAgentWebToolChange = React.useCallback((enabled: boolean) => {
setAgentWebToolEnabled(enabled);
void updateDesktopSettings({ agentWebToolEnabled: enabled });
recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' });
}, [setAgentWebToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.tools.title')}>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
settingsItem="sessions.agent-control-tool"
checked={agentControlToolEnabled}
onChange={handleAgentControlToolChange}
label={t('settings.openchamber.tools.field.agentControlTool')}
ariaLabel={t('settings.openchamber.tools.field.agentControlToolAria')}
info={t('settings.openchamber.tools.field.agentControlToolInfo')}
/>
<SettingsCheckboxRow
settingsItem="sessions.agent-web-tool"
checked={agentWebToolEnabled}
onChange={handleAgentWebToolChange}
label={t('settings.openchamber.tools.field.agentWebTool')}
ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')}
info={t('settings.openchamber.tools.field.agentWebToolInfo')}
/>
</div>
</SettingsSection>
);
};
@@ -26,8 +26,6 @@ export const OpenCodeCliSettings: React.FC = () => {
const [isSaving, setIsSaving] = React.useState(false);
const showOpenCodeUpdateNotifications = useUIStore((state) => state.showOpenCodeUpdateNotifications);
const setShowOpenCodeUpdateNotifications = useUIStore((state) => state.setShowOpenCodeUpdateNotifications);
const agentControlToolEnabled = useUIStore((state) => state.agentControlToolEnabled);
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
React.useEffect(() => {
let cancelled = false;
@@ -102,11 +100,6 @@ export const OpenCodeCliSettings: React.FC = () => {
void updateDesktopSettings({ showOpenCodeUpdateNotifications: enabled });
}, [setShowOpenCodeUpdateNotifications]);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
void updateDesktopSettings({ agentControlToolEnabled: enabled });
}, [setAgentControlToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.opencodeCli.title')}>
<div className="space-y-0.5">
@@ -160,15 +153,6 @@ export const OpenCodeCliSettings: React.FC = () => {
/>
)}
<SettingsCheckboxRow
settingsItem="sessions.agent-control-tool"
checked={agentControlToolEnabled}
onChange={handleAgentControlToolChange}
label={t('settings.openchamber.opencodeCli.field.agentControlTool')}
ariaLabel={t('settings.openchamber.opencodeCli.field.agentControlToolAria')}
info={t('settings.openchamber.opencodeCli.field.agentControlToolInfo')}
/>
<div className="flex justify-start py-1.5">
<Button
type="button"
@@ -654,9 +654,12 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const unsubscribe = subscribeOpenchamberEvents((event) => {
if (event.type === 'scheduled-task-ran') {
needsGlobalRefresh = true;
} else {
} else if (event.type === 'session-created') {
sessionDirectories.add(event.directory);
requestWorktreeDiscovery();
} else {
// Browser control events carry no session state; nothing to refresh.
return;
}
if (refreshTimeout) {
clearTimeout(refreshTimeout);
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'bun:test';
import {
ANNOTATION_TEARDOWN_SCRIPT,
buildAnnotationOverlayScript,
type BrowserAnnotationOverlayLabels,
type BrowserAnnotationOverlayTheme,
} from './annotationOverlay';
const theme: BrowserAnnotationOverlayTheme = {
colorScheme: 'dark',
primary: 'rgb(214, 93, 42)',
primarySoft: 'rgba(214, 93, 42, 0.16)',
primaryFaint: 'rgba(214, 93, 42, 0.1)',
primaryContrast: 'rgb(255, 255, 255)',
surface: 'rgb(10, 10, 10)',
surfaceElevated: 'rgb(20, 20, 20)',
glassSurface: 'rgba(20, 20, 20, 0.64)',
glassFilter: 'blur(26px) saturate(1.16)',
border: 'rgb(40, 40, 40)',
text: 'rgb(240, 240, 240)',
mutedText: 'rgb(160, 160, 160)',
};
const labels: BrowserAnnotationOverlayLabels = {
select: 'Element',
marquee: 'Region',
draw: 'Draw',
commentPlaceholder: 'Describe the change...',
submit: 'Attach',
};
/**
* The overlay ships as source text evaluated inside another page, so ordinary
* type-checking never sees it. These are the failures that produced: a stray
* backtick silently truncated the whole script, and a value interpolated
* without escaping would end it early or run as code.
*/
const parses = (source: string): boolean => {
try {
new Function(source);
return true;
} catch {
return false;
}
};
describe('annotation overlay script', () => {
const script = buildAnnotationOverlayScript(theme, labels);
test('parses as JavaScript', () => {
expect(parses(`return ${script}`)).toBe(true);
});
test('contains no backtick, which would terminate the template it lives in', () => {
expect(script).not.toContain('`');
});
test('carries the theme and labels through as data, not as concatenated code', () => {
expect(script).toContain(JSON.stringify(theme.primarySoft));
expect(script).toContain(JSON.stringify(labels.commentPlaceholder));
});
test('escapes a label that would otherwise close the script', () => {
const hostile = buildAnnotationOverlayScript(theme, {
...labels,
submit: '"); alert(1); ("',
});
expect(parses(`return ${hostile}`)).toBe(true);
expect(hostile).not.toContain('alert(1); ("');
});
test('the teardown script parses on its own', () => {
expect(parses(ANNOTATION_TEARDOWN_SCRIPT)).toBe(true);
});
});
@@ -0,0 +1,610 @@
/**
* The annotation overlay that runs *inside* the previewed page.
*
* This module produces a self-contained script string. It is injected through
* `webview.executeJavaScript`, so it cannot import anything, cannot reference
* our theme variables (the page has its own `:root`), and must not disturb the
* document it lands in. Consequences that drive the implementation:
*
* - All chrome lives in a **closed** shadow root on a single host element, so
* page CSS cannot restyle it and page scripts cannot walk into it.
* - Every color and every label is passed in from the host, already resolved
* and already translated. Nothing user-facing is hardcoded here.
*
* The chrome is two small pieces rather than one bar: the tools sit at the top
* of the page, and the comment box follows whatever was just marked. A single
* bar pinned to one edge covers the part of the page people most often want to
* point at, and accumulates into something that feels like an application.
*
* Lifecycle: the script resolves with a payload (or null when cancelled) and
* tears down its own chrome *before* resolving, waiting for a repaint, so a
* screenshot taken by the host contains the page and none of our UI.
*/
export type BrowserAnnotationOverlayTheme = {
readonly colorScheme: 'light' | 'dark';
readonly primary: string;
/** Translucent primary, already resolved to a concrete color by the host. */
readonly primarySoft: string;
/** Faint primary used for hover backgrounds. */
readonly primaryFaint: string;
readonly primaryContrast: string;
readonly surface: string;
readonly surfaceElevated: string;
/** Translucent elevated surface, matching the app's floating panels. */
readonly glassSurface: string;
/** A ready `backdrop-filter` value; '' when the theme has no glass. */
readonly glassFilter: string;
readonly border: string;
readonly text: string;
readonly mutedText: string;
};
export type BrowserAnnotationOverlayLabels = {
readonly select: string;
readonly marquee: string;
readonly draw: string;
readonly commentPlaceholder: string;
readonly submit: string;
};
const ANNOTATION_ACTIVE_FLAG = '__openchamberBrowserAnnotationActive';
/** Cancels an overlay left behind by an earlier session. Safe when none is active. */
export const ANNOTATION_TEARDOWN_SCRIPT = `(() => {
try {
var host = document.querySelector('[data-openchamber-annotation]');
if (host && host.parentNode) host.parentNode.removeChild(host);
} catch (error) { /* page navigated away */ }
try {
var cursor = document.getElementById('openchamber-annotation-cursor');
if (cursor && cursor.parentNode) cursor.parentNode.removeChild(cursor);
} catch (error) { /* page navigated away */ }
try { delete window['${ANNOTATION_ACTIVE_FLAG}']; } catch (error) { /* non-configurable */ }
})();`;
export const buildAnnotationOverlayScript = (
theme: BrowserAnnotationOverlayTheme,
labels: BrowserAnnotationOverlayLabels,
): string => {
const config = JSON.stringify({ theme, labels });
return String.raw`new Promise((resolve) => {
var CONFIG = ${config};
var THEME = CONFIG.theme;
var LABELS = CONFIG.labels;
var OVERLAY_ATTR = 'data-openchamber-annotation';
var Z_OVERLAY = 2147483646;
var MAX_TEXT = 400;
var MIN_RECT = 3;
var EDITOR_GAP = 10;
try {
var stale = document.querySelector('[' + OVERLAY_ATTR + ']');
if (stale && stale.parentNode) stale.parentNode.removeChild(stale);
} catch (error) { /* nothing to clean */ }
window['${ANNOTATION_ACTIVE_FLAG}'] = true;
var counter = 0;
var nextId = function (prefix) { counter += 1; return prefix + '-' + counter; };
var selected = null;
var regions = [];
var strokes = [];
var tool = 'select';
var settled = false;
// ---------------------------------------------------------------- geometry
var rectFrom = function (domRect) {
return { x: domRect.left, y: domRect.top, width: domRect.width, height: domRect.height };
};
var normalizeRect = function (ax, ay, bx, by) {
return { x: Math.min(ax, bx), y: Math.min(ay, by), width: Math.abs(bx - ax), height: Math.abs(by - ay) };
};
var usableRect = function (rect) { return rect.width >= MIN_RECT && rect.height >= MIN_RECT; };
// ------------------------------------------------------------- description
var isOverlayNode = function (element) {
var node = element;
while (node) {
if (node.nodeType === 1 && node.hasAttribute && node.hasAttribute(OVERLAY_ATTR)) return true;
node = node.parentNode || (node.host || null);
}
return false;
};
var elementFromPoint = function (x, y) {
var found = document.elementFromPoint(x, y);
if (!found || isOverlayNode(found)) return null;
return found;
};
var selectorPart = function (element) {
var part = element.tagName.toLowerCase();
if (element.id) return part + '#' + element.id;
var className = typeof element.className === 'string' ? element.className.trim() : '';
if (className) {
var first = className.split(/\s+/).filter(Boolean).slice(0, 2).join('.');
if (first) part += '.' + first;
}
return part;
};
var buildSelector = function (element) {
if (element.id) return '#' + element.id;
var parts = [];
var node = element;
var depth = 0;
while (node && node.nodeType === 1 && depth < 5) {
var part = selectorPart(node);
var parent = node.parentElement;
if (parent) {
var siblings = Array.prototype.filter.call(parent.children, function (child) {
return child.tagName === node.tagName;
});
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
}
parts.unshift(part);
if (node.id) break;
node = parent;
depth += 1;
}
return parts.join(' > ');
};
var INTERESTING_ATTRS = ['id', 'class', 'name', 'type', 'href', 'src', 'alt', 'title', 'role', 'placeholder', 'aria-label', 'data-testid'];
var STYLE_PROPS = [
'display', 'position', 'color', 'backgroundColor', 'fontSize', 'fontWeight', 'fontFamily',
'lineHeight', 'padding', 'margin', 'border', 'borderRadius', 'width', 'height', 'opacity',
'zIndex', 'flexDirection', 'justifyContent', 'alignItems', 'gap', 'textAlign'
];
var describe = function (element) {
var computed = window.getComputedStyle(element);
var attributes = {};
for (var i = 0; i < INTERESTING_ATTRS.length; i += 1) {
var name = INTERESTING_ATTRS[i];
var value = element.getAttribute(name);
if (value) attributes[name] = String(value).slice(0, 200);
}
var computedStyle = {};
for (var j = 0; j < STYLE_PROPS.length; j += 1) {
var prop = STYLE_PROPS[j];
computedStyle[prop] = String(computed[prop] == null ? '' : computed[prop]);
}
var ancestry = [];
var node = element.parentElement;
var depth = 0;
while (node && node.nodeType === 1 && depth < 4) {
var entry = { tag: node.tagName.toLowerCase(), selectorPart: selectorPart(node) };
if (node.id) entry.id = node.id;
var cls = typeof node.className === 'string' ? node.className.trim() : '';
if (cls) entry.className = cls.slice(0, 200);
ancestry.unshift(entry);
node = node.parentElement;
depth += 1;
}
var box = element.getBoundingClientRect();
var text = (element.textContent == null ? '' : String(element.textContent)).replace(/\s+/g, ' ').trim();
return {
tag: element.tagName.toLowerCase(),
text: text.slice(0, MAX_TEXT),
selector: buildSelector(element),
path: ancestry.map(function (e) { return e.selectorPart; }).concat([selectorPart(element)]).join(' > '),
bounds: rectFrom(box),
center: { x: box.left + box.width / 2, y: box.top + box.height / 2 },
attributes: attributes,
computedStyle: computedStyle,
ancestry: ancestry
};
};
// ------------------------------------------------------------------ chrome
var host = document.createElement('div');
host.setAttribute(OVERLAY_ATTR, '');
host.style.cssText = 'position:fixed;inset:0;z-index:' + Z_OVERLAY + ';pointer-events:none';
var shadow = host.attachShadow({ mode: 'closed' });
var style = document.createElement('style');
style.textContent = [
':host{all:initial}',
'*{box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}',
'.layer{position:fixed;inset:0;pointer-events:none}',
'.box{position:fixed;left:0;top:0;display:none;pointer-events:none;border:1.5px solid ' + THEME.primary + ';background:' + THEME.primarySoft + ';border-radius:2px}',
'.label{position:fixed;left:0;top:0;display:none;pointer-events:none;padding:1px 6px;border-radius:4px;background:' + THEME.primary + ';color:' + THEME.primaryContrast + ';font-size:11px;line-height:17px;white-space:nowrap;font-weight:600}',
'.tools{position:fixed;top:14px;left:50%;transform:translateX(-50%);display:flex;gap:2px;padding:4px;border-radius:999px;border:1px solid ' + THEME.border + ';background:' + THEME.glassSurface + ';-webkit-backdrop-filter:' + THEME.glassFilter + ';backdrop-filter:' + THEME.glassFilter + ';box-shadow:0 6px 20px rgba(0,0,0,.24);pointer-events:auto}',
'.tools button{appearance:none;border:none;background:transparent;color:' + THEME.mutedText + ';border-radius:999px;padding:5px 14px;font-size:12px;line-height:18px;font-weight:500;cursor:pointer;white-space:nowrap}',
'.tools button:hover{background:' + THEME.primaryFaint + '}',
'.tools button[aria-pressed="true"]{background:' + THEME.primarySoft + ';color:' + THEME.primary + ';font-weight:600}',
'.editor{position:fixed;left:0;top:0;display:none;align-items:center;gap:8px;width:min(420px,calc(100vw - 24px));padding:6px;padding-left:16px;border-radius:22px;border:1px solid ' + THEME.border + ';background:' + THEME.glassSurface + ';-webkit-backdrop-filter:' + THEME.glassFilter + ';backdrop-filter:' + THEME.glassFilter + ';box-shadow:0 8px 28px rgba(0,0,0,.3);pointer-events:auto}',
'.editor textarea{flex:1;min-width:0;resize:none;border:none;background:transparent;color:' + THEME.text + ';font-size:13px;line-height:20px;outline:none;padding:6px 0;min-height:32px;max-height:104px;display:block}',
'.editor textarea::placeholder{color:' + THEME.mutedText + '}',
'.editor button{appearance:none;border:none;background:' + THEME.primary + ';color:' + THEME.primaryContrast + ';border-radius:999px;padding:8px 18px;font-size:12px;line-height:18px;font-weight:600;cursor:pointer;white-space:nowrap}',
'.editor button[disabled]{opacity:.5;cursor:default}'
].join('');
shadow.appendChild(style);
var cursorStyle = document.createElement('style');
cursorStyle.id = 'openchamber-annotation-cursor';
document.head.appendChild(cursorStyle);
var setCursor = function (value) {
cursorStyle.textContent = value ? '*{cursor:' + value + ' !important}' : '';
};
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', 'layer');
svg.style.overflow = 'visible';
shadow.appendChild(svg);
var hoverBox = document.createElement('div');
hoverBox.className = 'box';
var hoverLabel = document.createElement('div');
hoverLabel.className = 'label';
var marqueeBox = document.createElement('div');
marqueeBox.className = 'box';
shadow.append(hoverBox, hoverLabel, marqueeBox);
var positionBox = function (node, rect) {
node.style.display = 'block';
node.style.transform = 'translate(' + rect.x + 'px,' + rect.y + 'px)';
node.style.width = rect.width + 'px';
node.style.height = rect.height + 'px';
};
var tools = document.createElement('div');
tools.className = 'tools';
shadow.appendChild(tools);
var toolButtons = {};
[['select', LABELS.select], ['marquee', LABELS.marquee], ['draw', LABELS.draw]].forEach(function (entry) {
var button = document.createElement('button');
button.type = 'button';
button.textContent = entry[1];
button.addEventListener('click', function () { setTool(entry[0]); });
toolButtons[entry[0]] = button;
tools.appendChild(button);
});
var editor = document.createElement('div');
editor.className = 'editor';
shadow.appendChild(editor);
var comment = document.createElement('textarea');
comment.rows = 1;
comment.placeholder = LABELS.commentPlaceholder;
var submit = document.createElement('button');
submit.type = 'button';
submit.textContent = LABELS.submit;
editor.append(comment, submit);
/**
* Grows the box with its content.
*
* A hidden element reports scrollHeight 0, so measuring one collapses the
* field to nothing which is what left a sliver of a click target and an
* invisible caret. Measuring only while visible, and never below the CSS
* min-height, keeps it usable.
*/
var resizeComment = function () {
if (editor.style.display === 'none') return;
comment.style.height = 'auto';
comment.style.height = Math.max(32, Math.min(comment.scrollHeight, 104)) + 'px';
};
comment.addEventListener('input', resizeComment);
// -------------------------------------------------------------- selection
var lastTargetRect = null;
/** Places the comment box under whatever was just marked, kept on screen. */
var positionEditor = function () {
if (!lastTargetRect) {
editor.style.display = 'none';
return;
}
var wasHidden = editor.style.display === 'none';
editor.style.display = 'flex';
// Measured now that it is laid out, not while it was still hidden.
if (wasHidden) resizeComment();
var box = editor.getBoundingClientRect();
var left = lastTargetRect.x + lastTargetRect.width / 2 - box.width / 2;
var top = lastTargetRect.y + lastTargetRect.height + EDITOR_GAP;
// Above the target when there is no room below, so the box is never left
// half off the bottom of the page.
if (top + box.height > window.innerHeight - 8) {
top = Math.max(8, lastTargetRect.y - box.height - EDITOR_GAP);
}
left = Math.max(8, Math.min(left, window.innerWidth - box.width - 8));
editor.style.transform = 'translate(' + Math.round(left) + 'px,' + Math.round(top) + 'px)';
};
/** Focus after layout: focusing a zero-height field puts the caret nowhere. */
var focusComment = function () {
requestAnimationFrame(function () {
try {
comment.focus({ preventScroll: true });
comment.setSelectionRange(comment.value.length, comment.value.length);
} catch (error) { /* field went away */ }
});
};
var hasTargets = function () {
return Boolean(selected) || regions.length > 0 || strokes.length > 0;
};
var syncChrome = function () {
submit.disabled = !hasTargets();
if (!hasTargets()) {
lastTargetRect = null;
editor.style.display = 'none';
return;
}
positionEditor();
};
var syncSelectionVisuals = function () {
if (!selected) return;
var rect = rectFrom(selected.element.getBoundingClientRect());
if (!usableRect(rect)) {
selected.outline.style.display = 'none';
selected.badge.style.display = 'none';
return;
}
positionBox(selected.outline, rect);
selected.badge.style.display = 'block';
selected.badge.style.transform = 'translate(' + Math.max(2, rect.x) + 'px,' + Math.max(2, rect.y - 19) + 'px)';
lastTargetRect = rect;
};
var dropSelection = function () {
if (!selected) return;
selected.outline.remove();
selected.badge.remove();
selected = null;
};
var selectElement = function (element) {
var wasSelected = selected && selected.element === element;
dropSelection();
if (wasSelected) {
// Clicking the chosen element again clears it, so a misclick costs one
// click rather than starting over.
lastTargetRect = null;
syncChrome();
return;
}
var outline = document.createElement('div');
outline.className = 'box';
var badge = document.createElement('div');
badge.className = 'label';
badge.textContent = selectorPart(element);
shadow.append(outline, badge);
selected = { id: nextId('element'), element: element, outline: outline, badge: badge };
syncSelectionVisuals();
syncChrome();
focusComment();
};
var setTool = function (next) {
tool = next;
hoverBox.style.display = 'none';
hoverLabel.style.display = 'none';
marqueeBox.style.display = 'none';
setCursor(next === 'select' ? '' : 'crosshair');
for (var key in toolButtons) {
if (Object.prototype.hasOwnProperty.call(toolButtons, key)) {
toolButtons[key].setAttribute('aria-pressed', key === next ? 'true' : 'false');
}
}
};
// ------------------------------------------------------------- interaction
var drag = null;
var onPointerMove = function (event) {
if (drag) {
if (drag.kind === 'marquee') {
drag.rect = normalizeRect(drag.startX, drag.startY, event.clientX, event.clientY);
positionBox(marqueeBox, drag.rect);
} else if (drag.kind === 'draw') {
drag.points.push({ x: event.clientX, y: event.clientY });
drag.path.setAttribute('d', drag.points.map(function (point, index) {
return (index === 0 ? 'M' : 'L') + point.x + ' ' + point.y;
}).join(' '));
}
return;
}
if (tool !== 'select') return;
var element = elementFromPoint(event.clientX, event.clientY);
if (!element) {
hoverBox.style.display = 'none';
hoverLabel.style.display = 'none';
return;
}
var rect = rectFrom(element.getBoundingClientRect());
positionBox(hoverBox, rect);
hoverLabel.textContent = selectorPart(element);
hoverLabel.style.display = 'block';
hoverLabel.style.transform = 'translate(' + Math.max(2, rect.x) + 'px,' + Math.max(2, rect.y - 19) + 'px)';
};
var onPointerDown = function (event) {
if (event.button !== 0) return;
if (isOverlayNode(event.target)) return;
event.preventDefault();
event.stopPropagation();
if (tool === 'select') {
var element = elementFromPoint(event.clientX, event.clientY);
if (element) selectElement(element);
return;
}
if (tool === 'marquee') {
drag = { kind: 'marquee', startX: event.clientX, startY: event.clientY, rect: null };
return;
}
var path = document.createElementNS(svgNS, 'path');
path.setAttribute('fill', 'none');
path.setAttribute('stroke', THEME.primary);
path.setAttribute('stroke-width', '2.5');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.appendChild(path);
drag = { kind: 'draw', path: path, points: [{ x: event.clientX, y: event.clientY }] };
};
var onPointerUp = function () {
if (!drag) return;
var finished = drag;
drag = null;
if (finished.kind === 'marquee') {
marqueeBox.style.display = 'none';
var rect = finished.rect;
if (!rect || !usableRect(rect)) return;
// A region marks an area, not the elements inside it: expanding it into a
// selection made the result depend on the page's markup rather than on
// what was drawn.
regions.push({ id: nextId('region'), rect: rect });
var outline = document.createElementNS(svgNS, 'rect');
outline.setAttribute('x', String(rect.x));
outline.setAttribute('y', String(rect.y));
outline.setAttribute('width', String(rect.width));
outline.setAttribute('height', String(rect.height));
outline.style.fill = THEME.primarySoft;
outline.setAttribute('stroke', THEME.primary);
outline.setAttribute('stroke-width', '1.5');
outline.setAttribute('rx', '2');
svg.appendChild(outline);
lastTargetRect = rect;
syncChrome();
focusComment();
return;
}
var points = finished.points;
if (points.length < 2) {
if (finished.path.parentNode) finished.path.parentNode.removeChild(finished.path);
return;
}
var minX = points[0].x, minY = points[0].y, maxX = points[0].x, maxY = points[0].y;
for (var p = 1; p < points.length; p += 1) {
minX = Math.min(minX, points[p].x);
minY = Math.min(minY, points[p].y);
maxX = Math.max(maxX, points[p].x);
maxY = Math.max(maxY, points[p].y);
}
var bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
strokes.push({ id: nextId('stroke'), points: points, bounds: bounds });
lastTargetRect = bounds;
syncChrome();
focusComment();
};
/**
* Swallows the page's own mouse handling while annotating.
*
* Blocking pointerdown is not enough: a click still follows it, so marking a
* button pressed that button and marking a link navigated away from the page
* being annotated. These are the events that reach page code, so these are
* the ones that have to stop.
*/
var blockPageInteraction = function (event) {
if (isOverlayNode(event.target)) return;
event.preventDefault();
event.stopPropagation();
};
var BLOCKED_EVENTS = ['click', 'auxclick', 'dblclick', 'mousedown', 'mouseup', 'contextmenu'];
var onScrollOrResize = function () {
syncSelectionVisuals();
positionEditor();
};
var onKeyDown = function (event) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
finish(null);
return;
}
if (event.key === 'Enter' && !event.shiftKey && event.target === comment) {
event.preventDefault();
attach();
}
};
document.addEventListener('pointermove', onPointerMove, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
BLOCKED_EVENTS.forEach(function (name) {
window.addEventListener(name, blockPageInteraction, { capture: true, passive: false });
});
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize, true);
window.addEventListener('keydown', onKeyDown, true);
// ------------------------------------------------------------------ finish
var teardown = function () {
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
BLOCKED_EVENTS.forEach(function (name) {
window.removeEventListener(name, blockPageInteraction, true);
});
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize, true);
window.removeEventListener('keydown', onKeyDown, true);
setCursor('');
if (cursorStyle.parentNode) cursorStyle.parentNode.removeChild(cursorStyle);
if (host.parentNode) host.parentNode.removeChild(host);
try { delete window['${ANNOTATION_ACTIVE_FLAG}']; } catch (error) { /* non-configurable */ }
};
var finish = function (payload) {
if (settled) return;
settled = true;
teardown();
// Removing the chrome is not enough: the host screenshots this page right
// after we resolve, and the compositor can still hold a frame containing
// our outlines. Yield two frames so the page has actually repainted.
requestAnimationFrame(function () {
requestAnimationFrame(function () { resolve(payload); });
});
};
var attach = function () {
if (!hasTargets()) return;
finish({
id: 'annotation-' + Date.now(),
pageUrl: String(location.href),
pageTitle: String(document.title || ''),
viewport: { width: window.innerWidth, height: window.innerHeight },
devicePixelRatio: window.devicePixelRatio || 1,
comment: comment.value,
// Zero or one element, never more. Kept as a list so elements, regions
// and strokes stay one uniform shape for everything downstream.
elements: selected ? [{ id: selected.id, element: describe(selected.element) }] : [],
regions: regions.map(function (entry) { return { id: entry.id, rect: entry.rect }; }),
strokes: strokes.map(function (entry) {
return { id: entry.id, points: entry.points, bounds: entry.bounds };
})
});
};
submit.addEventListener('click', attach);
(document.body || document.documentElement).appendChild(host);
setTool('select');
syncChrome();
});`;
};
@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import { formatBrowserAnnotationPrompt } from './annotationPrompt';
import type { BrowserAnnotationPayload, BrowserElementTarget } from './contract';
const element = (overrides: Partial<BrowserElementTarget> = {}): BrowserElementTarget => ({
tag: 'button',
text: ' Save changes ',
selector: '#save',
path: 'form > button#save',
bounds: { x: 10.4, y: 20.6, width: 100, height: 40 },
center: { x: 60, y: 40 },
attributes: { id: 'save', 'aria-label': 'Save' },
computedStyle: { display: 'flex', position: 'static', color: 'rgb(0, 0, 0)' },
ancestry: [{ tag: 'form', selectorPart: 'form' }],
...overrides,
});
const basePayload = (overrides: Partial<BrowserAnnotationPayload> = {}): BrowserAnnotationPayload => ({
id: 'annotation-1',
pageUrl: 'http://localhost:5173/settings',
pageTitle: 'Settings',
viewport: { width: 1280, height: 800 },
devicePixelRatio: 2,
comment: '',
elements: [],
regions: [],
strokes: [],
...overrides,
});
describe('annotation prompt', () => {
test('describes each selected element with selector, ancestry and attributes', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload({ elements: [{ id: 'e1', element: element() }] }),
screenshotAttached: true,
intro: 'Selected elements:',
});
expect(output).toContain('Selected elements:');
expect(output).toContain('Element 1: button');
expect(output).toContain('- Selector: #save');
expect(output).toContain('- Ancestry: form');
expect(output).toContain('aria-label="Save"');
expect(output).toContain('Screenshot: attached');
});
test('collapses element text whitespace', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload({ elements: [{ id: 'e1', element: element() }] }),
screenshotAttached: false,
intro: 'Selected',
});
expect(output).toContain('- Text: Save changes');
});
test('rounds geometry so the prompt has no float noise', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload({ elements: [{ id: 'e1', element: element() }] }),
screenshotAttached: false,
intro: 'Selected',
});
expect(output).toContain('- Bounds: x=10, y=21, width=100, height=40');
});
test('reports a missing screenshot rather than staying silent about it', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload({ elements: [{ id: 'e1', element: element() }] }),
screenshotAttached: false,
intro: 'Selected',
});
expect(output).toContain('Screenshot: not attached');
});
test('numbers multiple elements, regions and drawings independently', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload({
elements: [{ id: 'e1', element: element() }, { id: 'e2', element: element({ selector: '#cancel' }) }],
regions: [{ id: 'r1', rect: { x: 0, y: 0, width: 10, height: 10 } }],
strokes: [{ id: 's1', points: [{ x: 0, y: 0 }, { x: 5, y: 5 }], bounds: { x: 0, y: 0, width: 5, height: 5 } }],
}),
screenshotAttached: true,
intro: 'Selected',
});
expect(output).toContain('Element 1: button');
expect(output).toContain('Element 2: button');
expect(output).toContain('Region 1: x=0, y=0, width=10, height=10');
expect(output).toContain('Drawing 1: 2 points');
});
test('includes the comment only when the user wrote one', () => {
const withComment = formatBrowserAnnotationPrompt({
payload: basePayload({ comment: ' needs more contrast ', elements: [{ id: 'e1', element: element() }] }),
screenshotAttached: false,
intro: 'Selected',
});
expect(withComment).toContain('Comment: needs more contrast');
const withoutComment = formatBrowserAnnotationPrompt({
payload: basePayload({ elements: [{ id: 'e1', element: element() }] }),
screenshotAttached: false,
intro: 'Selected',
});
expect(withoutComment).not.toContain('Comment:');
});
test('falls back to the url when the page has no title', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload({ pageTitle: '' }),
screenshotAttached: false,
intro: 'Selected',
});
expect(output).toContain('Page: http://localhost:5173/settings');
expect(output).not.toContain('URL:');
});
test('keeps the url on its own line when a title is present', () => {
const output = formatBrowserAnnotationPrompt({
payload: basePayload(),
screenshotAttached: false,
intro: 'Selected',
});
expect(output).toContain('Page: Settings');
expect(output).toContain('URL: http://localhost:5173/settings');
});
});
@@ -0,0 +1,83 @@
/**
* Renders a browser annotation into the text the agent actually reads.
*
* The body is deliberately English regardless of UI locale: it is prompt
* content, not interface copy. Only the caller-supplied intro is translated,
* because that line is echoed back to the user in the composer.
*/
import type {
BrowserAnnotationPayload,
BrowserAnnotationRegion,
BrowserAnnotationStroke,
BrowserElementTarget,
} from './contract';
const round = (value: number): number => Math.round(value);
const describeElement = (target: BrowserElementTarget, index: number): string[] => {
const text = target.text.trim();
const attributes = Object.entries(target.attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
const ancestry = target.ancestry.map((entry) => entry.selectorPart).join(' > ');
const styles = target.computedStyle;
const bounds = target.bounds;
return [
`Element ${index + 1}: ${target.tag}`,
text ? `- Text: ${text}` : null,
`- Selector: ${target.selector}`,
`- Path: ${target.path}`,
ancestry ? `- Ancestry: ${ancestry}` : null,
attributes ? `- Attributes: ${attributes}` : null,
`- Bounds: x=${round(bounds.x)}, y=${round(bounds.y)}, width=${round(bounds.width)}, height=${round(bounds.height)}`,
`- Styles: display=${styles.display ?? ''}; position=${styles.position ?? ''}; font=${styles.fontWeight ?? ''} ${styles.fontSize ?? ''} / ${styles.lineHeight ?? ''} ${styles.fontFamily ?? ''}; color=${styles.color ?? ''}; background=${styles.backgroundColor ?? ''}`,
].filter((line): line is string => typeof line === 'string');
};
const describeRegion = (region: BrowserAnnotationRegion, index: number): string => {
const rect = region.rect;
return `Region ${index + 1}: x=${round(rect.x)}, y=${round(rect.y)}, width=${round(rect.width)}, height=${round(rect.height)}`;
};
const describeStroke = (stroke: BrowserAnnotationStroke, index: number): string => {
const bounds = stroke.bounds;
return `Drawing ${index + 1}: ${stroke.points.length} points over x=${round(bounds.x)}, y=${round(bounds.y)}, width=${round(bounds.width)}, height=${round(bounds.height)}`;
};
export const formatBrowserAnnotationPrompt = ({
payload,
screenshotAttached,
intro,
}: {
payload: BrowserAnnotationPayload;
screenshotAttached: boolean;
intro: string;
}): string => {
const introLabel = intro.replace(/[.:]+$/g, '');
const comment = payload.comment.trim();
const lines: Array<string | null> = [
`${introLabel}:`,
`Page: ${payload.pageTitle.trim() || payload.pageUrl || 'browser'}`,
payload.pageTitle.trim() && payload.pageUrl ? `URL: ${payload.pageUrl}` : null,
`Viewport: ${round(payload.viewport.width)}x${round(payload.viewport.height)}, DPR ${payload.devicePixelRatio}`,
`Screenshot: ${screenshotAttached ? 'attached' : 'not attached'}`,
comment ? `Comment: ${comment}` : null,
];
for (const [index, entry] of payload.elements.entries()) {
lines.push('', ...describeElement(entry.element, index));
}
if (payload.regions.length > 0) {
lines.push('');
payload.regions.forEach((region, index) => lines.push(describeRegion(region, index)));
}
if (payload.strokes.length > 0) {
lines.push('');
payload.strokes.forEach((stroke, index) => lines.push(describeStroke(stroke, index)));
}
return lines.filter((line): line is string => typeof line === 'string').join('\n');
};
@@ -0,0 +1,118 @@
/**
* Turns a native page capture plus an annotation payload into the image the
* agent receives.
*
* The image is the whole visible page, with the marked targets drawn on top
* not a crop around them. A tight crop answers "what does this element look
* like" but destroys the answer to "where is it and what is it next to", which
* is usually the more useful half of pointing at something.
*
* The capture arrives in device pixels while every rectangle in the payload is
* in CSS pixels, so everything is scaled by the ratio between the two rather
* than by `devicePixelRatio` the page may be zoomed, and the measured ratio
* stays correct when it is.
*/
import type { BrowserAnnotationPayload, BrowserRect } from './contract';
const MAX_OUTPUT_WIDTH = 1600;
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to decode browser page capture'));
image.src = src;
});
export const renderAnnotationScreenshot = async ({
base64,
mime,
captureWidth,
captureHeight,
cssWidth,
cssHeight,
payload,
accentColor,
accentFill,
}: {
base64: string;
mime: string;
captureWidth: number;
captureHeight: number;
cssWidth: number;
cssHeight: number;
payload: BrowserAnnotationPayload;
/** Solid outline color. */
accentColor: string;
/**
* Translucent fill, resolved by the caller. Never derive this by appending an
* alpha suffix to `accentColor`: theme colors are not necessarily hex, and an
* invalid value leaves canvas on its default opaque black, which paints over
* the very element the screenshot is meant to show.
*/
accentFill: string;
}): Promise<File | null> => {
if (!base64) return null;
try {
const image = await loadImage(`data:${mime};base64,${base64}`);
const pixelWidth = Math.max(1, image.naturalWidth || captureWidth);
const pixelHeight = Math.max(1, image.naturalHeight || captureHeight);
const scaleX = pixelWidth / Math.max(1, cssWidth || pixelWidth);
const scaleY = pixelHeight / Math.max(1, cssHeight || pixelHeight);
const outputScale = Math.min(1, MAX_OUTPUT_WIDTH / pixelWidth);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.floor(pixelWidth * outputScale));
canvas.height = Math.max(1, Math.floor(pixelHeight * outputScale));
const context = canvas.getContext('2d');
if (!context) return null;
context.scale(outputScale, outputScale);
context.drawImage(image, 0, 0, pixelWidth, pixelHeight);
/** CSS pixels to capture pixels; the canvas transform handles the rest. */
const toCanvas = (rect: BrowserRect): BrowserRect => ({
x: rect.x * scaleX,
y: rect.y * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY,
});
// Scale the outline with the image so it stays visible once a full page is
// shrunk to the output width; a hairline on a 1600px-wide page is lost.
const outlineWidth = Math.max(2, 2.5 * scaleX / Math.max(outputScale, 0.2));
context.strokeStyle = accentColor;
context.fillStyle = accentFill;
const markRect = (rect: BrowserRect) => {
const box = toCanvas(rect);
context.lineWidth = outlineWidth;
context.fillRect(box.x, box.y, box.width, box.height);
context.strokeRect(box.x, box.y, box.width, box.height);
};
for (const entry of payload.elements) markRect(entry.element.bounds);
for (const region of payload.regions) markRect(region.rect);
for (const stroke of payload.strokes) {
if (stroke.points.length < 2) continue;
context.beginPath();
stroke.points.forEach((point, index) => {
const x = point.x * scaleX;
const y = point.y * scaleY;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth = outlineWidth * 1.4;
context.stroke();
}
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
if (!blob) return null;
return new File([blob], `browser-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
} catch {
return null;
}
};
@@ -0,0 +1,136 @@
import { describe, expect, test } from 'bun:test';
import { cancelAnnotationSession, runAnnotationSession, type AnnotationHost, type PageCapture } from './annotationSession';
import type { BrowserAnnotationOverlayLabels, BrowserAnnotationOverlayTheme } from './annotationOverlay';
const theme: BrowserAnnotationOverlayTheme = {
colorScheme: 'dark',
primary: 'rgb(1, 2, 3)',
primarySoft: 'rgba(1, 2, 3, 0.16)',
primaryFaint: 'rgba(1, 2, 3, 0.1)',
primaryContrast: 'rgb(255, 255, 255)',
surface: 'rgb(10, 10, 10)',
surfaceElevated: 'rgb(20, 20, 20)',
glassSurface: 'rgba(20, 20, 20, 0.64)',
glassFilter: 'blur(26px) saturate(1.16)',
border: 'rgb(30, 30, 30)',
text: 'rgb(240, 240, 240)',
mutedText: 'rgb(160, 160, 160)',
};
const labels = {
select: 'Element', marquee: 'Region', draw: 'Draw',
commentPlaceholder: 'Describe', submit: 'Attach',
} satisfies BrowserAnnotationOverlayLabels;
const validPayload = {
id: 'annotation-1',
pageUrl: 'http://localhost:5173/',
pageTitle: 'App',
viewport: { width: 1000, height: 700 },
devicePixelRatio: 1,
comment: 'tighten this',
elements: [{
id: 'element-1',
element: {
tag: 'div',
text: 'Hi',
selector: '#hero',
path: 'main > div#hero',
bounds: { x: 0, y: 0, width: 100, height: 50 },
center: { x: 50, y: 25 },
attributes: {},
computedStyle: {},
ancestry: [],
},
}],
regions: [],
strokes: [],
};
const capture: PageCapture = { mime: 'image/jpeg', base64: 'AAAA', width: 1000, height: 700 };
type Call = { code: string; gesture?: boolean };
const createHost = (options: {
overlayResult: unknown;
capturePage?: () => Promise<PageCapture | null>;
}): { host: AnnotationHost; calls: Call[] } => {
const calls: Call[] = [];
const host: AnnotationHost = {
executeJavaScript: async (code: string, gesture?: boolean) => {
calls.push({ code, gesture });
if (code.includes('new Promise')) return options.overlayResult;
if (code.includes('window.innerWidth')) return { width: 1000, height: 700 };
return undefined;
},
capturePage: options.capturePage ?? (async () => capture),
};
return { host, calls };
};
describe('annotation session', () => {
test('returns null when the user cancels inside the page', async () => {
const { host } = createHost({ overlayResult: null });
expect(await runAnnotationSession({ host, theme, labels })).toBeNull();
});
test('does not capture anything when the overlay was cancelled', async () => {
let captures = 0;
const { host } = createHost({
overlayResult: null,
capturePage: async () => { captures += 1; return null; },
});
await runAnnotationSession({ host, theme, labels });
expect(captures).toBe(0);
});
test('discards a malformed payload and tears the overlay down', async () => {
const { host, calls } = createHost({ overlayResult: { id: 'x', elements: 'not-an-array' } });
const result = await runAnnotationSession({ host, theme, labels });
expect(result).toBeNull();
expect(calls.some((call) => call.code.includes('data-openchamber-annotation'))).toBe(true);
});
test('returns the annotation when the capture succeeds', async () => {
const { host } = createHost({ overlayResult: validPayload });
const result = await runAnnotationSession({ host, theme, labels });
expect(result?.payload.id).toBe('annotation-1');
});
test('keeps the annotation when the capture throws', async () => {
// A failed screenshot degrades the annotation; it must not discard it.
const { host } = createHost({
overlayResult: validPayload,
capturePage: async () => { throw new Error('capture failed'); },
});
const result = await runAnnotationSession({ host, theme, labels });
expect(result?.payload.id).toBe('annotation-1');
expect(result?.screenshot).toBeNull();
});
test('keeps the annotation when capture returns nothing', async () => {
const { host } = createHost({ overlayResult: validPayload, capturePage: async () => null });
const result = await runAnnotationSession({ host, theme, labels });
expect(result?.payload.comment).toBe('tighten this');
expect(result?.screenshot).toBeNull();
});
test('runs the overlay with a user gesture so the page treats it as interactive', async () => {
const { host, calls } = createHost({ overlayResult: null });
await runAnnotationSession({ host, theme, labels });
expect(calls[0]?.gesture).toBe(true);
});
test('cancelling a stale session tolerates a destroyed page', async () => {
const host: AnnotationHost = {
executeJavaScript: async () => { throw new Error('webview destroyed'); },
capturePage: async () => null,
};
// Resolving at all is the contract: a destroyed page must not throw.
expect(await cancelAnnotationSession(host)).toBeFalsy();
});
});
@@ -0,0 +1,106 @@
/**
* Drives one annotation session end to end.
*
* The overlay resolves only after tearing its own chrome down and waiting for a
* repaint, so the capture that follows shows the page and none of our UI. The
* page itself is never modified: annotation marks what is there, it does not
* edit it.
*/
import {
ANNOTATION_TEARDOWN_SCRIPT,
buildAnnotationOverlayScript,
type BrowserAnnotationOverlayLabels,
type BrowserAnnotationOverlayTheme,
} from './annotationOverlay';
import { isBrowserAnnotationPayload, type BrowserAnnotationPayload } from './contract';
import { renderAnnotationScreenshot } from './annotationScreenshot';
export type PageCapture = {
readonly mime: string;
readonly base64: string;
readonly width: number;
readonly height: number;
};
/**
* The capabilities an annotation session needs from its host, named so the
* session can be exercised without a live `<webview>`.
*/
export type AnnotationHost = {
readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
readonly capturePage: () => Promise<PageCapture | null>;
};
export type AnnotationSessionResult = {
readonly payload: BrowserAnnotationPayload;
readonly screenshot: File | null;
};
const VIEWPORT_SCRIPT = '({ width: window.innerWidth, height: window.innerHeight })';
const readViewport = async (host: AnnotationHost): Promise<{ width: number; height: number } | null> => {
try {
const value = await host.executeJavaScript(VIEWPORT_SCRIPT, true);
if (!value || typeof value !== 'object') return null;
const record = value as { width?: unknown; height?: unknown };
if (typeof record.width !== 'number' || typeof record.height !== 'number') return null;
if (!Number.isFinite(record.width) || !Number.isFinite(record.height)) return null;
return { width: record.width, height: record.height };
} catch {
return null;
}
};
/** Best-effort cleanup of an overlay left behind by a previous session. */
export const cancelAnnotationSession = async (host: AnnotationHost): Promise<void> => {
try {
await host.executeJavaScript(ANNOTATION_TEARDOWN_SCRIPT, false);
} catch {
// The page navigated or was destroyed; the overlay went with it.
}
};
export const runAnnotationSession = async ({
host,
theme,
labels,
}: {
host: AnnotationHost;
theme: BrowserAnnotationOverlayTheme;
labels: BrowserAnnotationOverlayLabels;
}): Promise<AnnotationSessionResult | null> => {
const script = buildAnnotationOverlayScript(theme, labels);
const raw = await host.executeJavaScript(script, true);
// Cancelled from inside the page.
if (raw === null || raw === undefined) return null;
if (!isBrowserAnnotationPayload(raw)) {
await cancelAnnotationSession(host);
return null;
}
const payload = raw;
try {
const viewport = await readViewport(host) ?? payload.viewport;
const capture = await host.capturePage();
if (!capture || !capture.base64) {
return { payload, screenshot: null };
}
const screenshot = await renderAnnotationScreenshot({
base64: capture.base64,
mime: capture.mime || 'image/jpeg',
captureWidth: capture.width,
captureHeight: capture.height,
cssWidth: viewport.width,
cssHeight: viewport.height,
payload,
accentColor: theme.primary,
accentFill: theme.primarySoft,
});
return { payload, screenshot };
} catch {
return { payload, screenshot: null };
}
};
@@ -0,0 +1,53 @@
import React from 'react';
/**
* Addresses the servers of one directory announced when they started.
*
* Auto-discovery starts a command and watches what it prints. When several
* servers announce themselves a gateway and the apps behind it, an API beside
* a site there is no honest way to pick one, so the candidates are parked
* here and the browser panel offers them.
*
* These beat port discovery when both are available: an app served under a base
* path announces that path, and a listening socket cannot reveal it.
*
* Kept in memory only. They describe one run of one command; a stored copy is
* exactly the kind of stale address that sent the panel to the wrong page.
*/
const announcedByDirectory = new Map<string, readonly string[]>();
const listeners = new Set<() => void>();
const emit = (): void => {
for (const listener of listeners) listener();
};
export const setAnnouncedDevServers = (directory: string, urls: readonly string[]): void => {
const key = directory.trim();
if (!key) return;
if (urls.length === 0) announcedByDirectory.delete(key);
else announcedByDirectory.set(key, [...urls]);
emit();
};
export const clearAnnouncedDevServers = (directory: string): void => {
if (announcedByDirectory.delete(directory.trim())) emit();
};
const EMPTY: readonly string[] = [];
const getAnnouncedDevServers = (directory: string): readonly string[] => (
announcedByDirectory.get(directory.trim()) ?? EMPTY
);
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const useAnnouncedDevServers = (directory: string): readonly string[] => (
React.useSyncExternalStore(
subscribe,
() => getAnnouncedDevServers(directory),
() => EMPTY,
)
);
@@ -0,0 +1,94 @@
import { describe, expect, test } from 'bun:test';
import {
annotationTargetCount,
isBrowserAnnotationPayload,
isBrowserElementTarget,
navStatusUrl,
type BrowserAnnotationPayload,
type BrowserElementTarget,
} from './contract';
const element: BrowserElementTarget = {
tag: 'button',
text: 'Save',
selector: '#save',
path: 'main > form > button#save',
bounds: { x: 10, y: 20, width: 100, height: 40 },
center: { x: 60, y: 40 },
attributes: { id: 'save' },
computedStyle: { display: 'flex' },
ancestry: [{ tag: 'form', selectorPart: 'form' }],
};
const payload: BrowserAnnotationPayload = {
id: 'annotation-1',
pageUrl: 'http://localhost:5173/settings',
pageTitle: 'Settings',
viewport: { width: 1280, height: 800 },
devicePixelRatio: 2,
comment: 'Make this primary',
elements: [{ id: 'element-1', element }],
regions: [{ id: 'region-1', rect: { x: 200, y: 0, width: 50, height: 50 } }],
strokes: [],
};
describe('navigation status', () => {
test('idle carries no url; the other states carry the one they describe', () => {
expect(navStatusUrl({ kind: 'idle' })).toBe('');
expect(navStatusUrl({ kind: 'loading', url: 'http://a/' })).toBe('http://a/');
expect(navStatusUrl({ kind: 'ready', url: 'http://a/', title: 'A' })).toBe('http://a/');
expect(navStatusUrl({ kind: 'failed', url: 'http://a/', code: -6, description: 'FILE_NOT_FOUND' }))
.toBe('http://a/');
});
});
describe('element target validation', () => {
test('accepts a fully-formed target', () => {
expect(isBrowserElementTarget(element)).toBe(true);
});
test('rejects payloads that would only fail later, at prompt or draw time', () => {
expect(isBrowserElementTarget({ ...element, attributes: { id: 3 } })).toBe(false);
expect(isBrowserElementTarget({ ...element, computedStyle: { display: null } })).toBe(false);
expect(isBrowserElementTarget({ ...element, ancestry: [{ tag: 'form' }] })).toBe(false);
expect(isBrowserElementTarget({ ...element, center: { x: 1 } })).toBe(false);
expect(isBrowserElementTarget({ ...element, bounds: { x: 1, y: 2, width: 3 } })).toBe(false);
});
test('rejects non-finite geometry rather than passing NaN downstream', () => {
expect(isBrowserElementTarget({ ...element, bounds: { x: Number.NaN, y: 0, width: 1, height: 1 } })).toBe(false);
});
});
describe('annotation payload validation', () => {
test('accepts a complete payload', () => {
expect(isBrowserAnnotationPayload(payload)).toBe(true);
});
test('accepts a payload with nothing marked but a comment', () => {
expect(isBrowserAnnotationPayload({ ...payload, elements: [], regions: [], strokes: [] }))
.toBe(true);
});
test('rejects a payload whose nested element is malformed', () => {
expect(isBrowserAnnotationPayload({
...payload,
elements: [{ id: 'element-1', element: { ...element, selector: 12 } }],
})).toBe(false);
});
test('rejects a malformed stroke', () => {
expect(isBrowserAnnotationPayload({
...payload,
strokes: [{ id: 's', points: [{ x: 1 }], bounds: { x: 0, y: 0, width: 1, height: 1 } }],
})).toBe(false);
});
});
describe('target geometry', () => {
test('counts every kind of target', () => {
expect(annotationTargetCount(payload)).toBe(2);
expect(annotationTargetCount({ ...payload, elements: [], regions: [], strokes: [] })).toBe(0);
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Browser surface contract.
*
* One shared vocabulary for the in-app browser across every runtime. The
* surface renders a real Chromium `<webview>` on desktop and a plain iframe
* everywhere else; both report their state through the types below, so the
* panel never has to ask which transport it is talking to.
*
* Navigation is a tagged union rather than a bag of booleans: `loading` and
* `failed` carry the URL they describe, which is what makes a late event from
* a superseded navigation discardable instead of ambiguous.
*/
export type BrowserNavStatus =
| { readonly kind: 'idle' }
| { readonly kind: 'loading'; readonly url: string }
| { readonly kind: 'ready'; readonly url: string; readonly title: string }
| {
readonly kind: 'failed';
readonly url: string;
readonly code: number;
readonly description: string;
/** The page's renderer died rather than the load failing; worth saying so. */
readonly crashed?: boolean;
};
export const IDLE_NAV_STATUS: BrowserNavStatus = { kind: 'idle' };
/** The URL a nav status refers to, or '' when idle. */
export const navStatusUrl = (status: BrowserNavStatus): string => (
status.kind === 'idle' ? '' : status.url
);
export type BrowserRect = {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
};
export type BrowserPoint = { readonly x: number; readonly y: number };
export type BrowserElementAncestor = {
readonly tag: string;
readonly id?: string;
readonly className?: string;
readonly selectorPart: string;
};
/**
* A single DOM element described well enough for an agent to find it again in
* source: a selector, a readable ancestry path, its own box, and the computed
* styles that usually matter when someone is asking for a visual change.
*/
export type BrowserElementTarget = {
readonly tag: string;
readonly text: string;
readonly selector: string;
readonly path: string;
readonly bounds: BrowserRect;
readonly center: BrowserPoint;
readonly attributes: Readonly<Record<string, string>>;
readonly computedStyle: Readonly<Record<string, string>>;
readonly ancestry: ReadonlyArray<BrowserElementAncestor>;
};
export type BrowserAnnotationElement = {
readonly id: string;
readonly element: BrowserElementTarget;
};
/** A free-form rectangle the user dragged over a part of the page. */
export type BrowserAnnotationRegion = {
readonly id: string;
readonly rect: BrowserRect;
};
/** A free-hand stroke drawn over the page. */
export type BrowserAnnotationStroke = {
readonly id: string;
readonly points: ReadonlyArray<BrowserPoint>;
readonly bounds: BrowserRect;
};
/**
* The complete result of one annotation session: everything the user marked,
* everything they restyled, and what they said about it. Emitted once, on
* submit never per-click.
*/
export type BrowserAnnotationPayload = {
readonly id: string;
readonly pageUrl: string;
readonly pageTitle: string;
readonly viewport: { readonly width: number; readonly height: number };
readonly devicePixelRatio: number;
readonly comment: string;
readonly elements: ReadonlyArray<BrowserAnnotationElement>;
readonly regions: ReadonlyArray<BrowserAnnotationRegion>;
readonly strokes: ReadonlyArray<BrowserAnnotationStroke>;
};
export const annotationTargetCount = (payload: BrowserAnnotationPayload): number => (
payload.elements.length + payload.regions.length + payload.strokes.length
);
const isRecord = (value: unknown): value is Record<string, unknown> => (
typeof value === 'object' && value !== null
);
const isFiniteNumber = (value: unknown): value is number => (
typeof value === 'number' && Number.isFinite(value)
);
const isStringRecord = (value: unknown): value is Record<string, string> => (
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
);
const isBrowserRect = (value: unknown): value is BrowserRect => (
isRecord(value)
&& isFiniteNumber(value.x)
&& isFiniteNumber(value.y)
&& isFiniteNumber(value.width)
&& isFiniteNumber(value.height)
);
const isBrowserPoint = (value: unknown): value is BrowserPoint => (
isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y)
);
const isAncestor = (value: unknown): value is BrowserElementAncestor => (
isRecord(value)
&& typeof value.tag === 'string'
&& typeof value.selectorPart === 'string'
&& (value.id === undefined || typeof value.id === 'string')
&& (value.className === undefined || typeof value.className === 'string')
);
/**
* Annotation payloads cross a trust boundary: they are produced by a script
* running inside a page we do not control. Every field a consumer dereferences
* is validated here, not just the ones that are convenient to check a
* partially-valid payload would otherwise throw far away from its origin, at
* prompt-formatting or screenshot-crop time.
*/
export const isBrowserElementTarget = (value: unknown): value is BrowserElementTarget => (
isRecord(value)
&& typeof value.tag === 'string'
&& typeof value.text === 'string'
&& typeof value.selector === 'string'
&& typeof value.path === 'string'
&& isBrowserRect(value.bounds)
&& isBrowserPoint(value.center)
&& isStringRecord(value.attributes)
&& isStringRecord(value.computedStyle)
&& Array.isArray(value.ancestry)
&& value.ancestry.every(isAncestor)
);
const isAnnotationElement = (value: unknown): value is BrowserAnnotationElement => (
isRecord(value) && typeof value.id === 'string' && isBrowserElementTarget(value.element)
);
const isAnnotationRegion = (value: unknown): value is BrowserAnnotationRegion => (
isRecord(value) && typeof value.id === 'string' && isBrowserRect(value.rect)
);
const isAnnotationStroke = (value: unknown): value is BrowserAnnotationStroke => (
isRecord(value)
&& typeof value.id === 'string'
&& isBrowserRect(value.bounds)
&& Array.isArray(value.points)
&& value.points.every(isBrowserPoint)
);
export const isBrowserAnnotationPayload = (value: unknown): value is BrowserAnnotationPayload => (
isRecord(value)
&& typeof value.id === 'string'
&& typeof value.pageUrl === 'string'
&& typeof value.pageTitle === 'string'
&& typeof value.comment === 'string'
&& isRecord(value.viewport)
&& isFiniteNumber(value.viewport.width)
&& isFiniteNumber(value.viewport.height)
&& isFiniteNumber(value.devicePixelRatio)
&& Array.isArray(value.elements)
&& value.elements.every(isAnnotationElement)
&& Array.isArray(value.regions)
&& value.regions.every(isAnnotationRegion)
&& Array.isArray(value.strokes)
&& value.strokes.every(isAnnotationStroke)
);
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
type Listener = (event: { type: string; requestId: string; action: string; parameters: Record<string, unknown> }) => void;
const posted: Array<{ requestId: string; ok: boolean; data?: unknown; error?: string }> = [];
const claims: string[] = [];
/** Flipped to false to play the client that lost the race for a request. */
let grantClaims = true;
let listener: Listener | null = null;
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async (path: string, init?: { body?: string }) => {
const body = JSON.parse(init?.body ?? '{}');
if (path.endsWith('/claim')) {
claims.push(body.requestId);
return { ok: true, status: 200, json: async () => ({ granted: grantClaims }) };
}
posted.push(body);
return { ok: true, status: 200 };
}),
}));
mock.module('@/lib/openchamberEvents', () => ({
subscribeOpenchamberEvents: (handler: Listener) => {
listener = handler;
return () => { listener = null; };
},
}));
const { registerBrowserController, registerBrowserOpener } = await import('./controlClient');
/** Registrations are module-global, so every test unwinds its own. */
const cleanups: Array<() => void> = [];
const emitOpen = (parameters: Record<string, unknown>): void => {
listener?.({ type: 'browser-control-request', requestId: 'req-1', action: 'browser.open', parameters });
};
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
describe('opening a page before any view exists', () => {
beforeEach(() => {
posted.length = 0;
claims.length = 0;
grantClaims = true;
});
afterEach(() => {
while (cleanups.length > 0) cleanups.pop()?.();
});
test('lets the view that the open created apply the layout that was asked for', async () => {
const opened: string[] = [];
const ran: Array<{ action: string; parameters: Record<string, unknown> }> = [];
cleanups.push(registerBrowserOpener((url) => {
opened.push(url);
// The pane mounts a moment after the tab is created, as it does in the app.
setTimeout(() => {
cleanups.push(registerBrowserController({
run: async (action, parameters) => {
ran.push({ action, parameters });
return { viewport: { mode: 'mobile', width: 390, height: 844 } };
},
}));
}, 120);
}));
emitOpen({ url: 'https://example.test', viewport: 'mobile' });
await wait(400);
expect(opened).toEqual(['https://example.test']);
expect(ran).toEqual([{ action: 'browser.resize', parameters: { viewport: 'mobile' } }]);
expect(posted[0]?.data).toEqual({
url: 'https://example.test',
opened: true,
viewportApplied: true,
viewport: { mode: 'mobile', width: 390, height: 844 },
});
});
test('does nothing at all when another client was granted the request', async () => {
grantClaims = false;
const opened: string[] = [];
const ran: string[] = [];
cleanups.push(registerBrowserOpener((url) => { opened.push(url); }));
cleanups.push(registerBrowserController({
run: async (action) => { ran.push(action); return {}; },
}));
emitOpen({ url: 'https://example.test' });
await wait(50);
expect(claims).toEqual(['req-1']);
// The losing client must not act: a late result cannot undo a click.
expect(ran).toEqual([]);
expect(opened).toEqual([]);
expect(posted).toEqual([]);
});
test('claims the request before touching a page', async () => {
const ran: string[] = [];
cleanups.push(registerBrowserController({
run: async (action) => { ran.push(action); return {}; },
}));
listener?.({ type: 'browser-control-request', requestId: 'req-1', action: 'browser.click', parameters: { selector: 'button' } });
await wait(50);
expect(claims).toEqual(['req-1']);
expect(ran).toEqual(['browser.click']);
});
test('does not wait for a view when no layout was requested', async () => {
cleanups.push(registerBrowserOpener(() => {}));
emitOpen({ url: 'https://example.test' });
await wait(20);
expect(posted[0]?.data).toEqual({ url: 'https://example.test', opened: true });
});
test('says the layout was not applied when no view ever appears', async () => {
cleanups.push(registerBrowserOpener(() => {}));
emitOpen({ url: 'https://example.test', viewport: 'mobile' });
// Past the client's own attach deadline.
await wait(2_400);
const data = posted[0]?.data as { viewportApplied?: boolean; note?: string };
expect(data.viewportApplied).toBe(false);
expect(typeof data.note).toBe('string');
});
});
@@ -0,0 +1,215 @@
/**
* Client half of agent browser control.
*
* The server broadcasts a browser request to every connected client, because it
* cannot know which one is showing the browser panel. More than one may be able
* to serve it, so a client asks the server for the request before doing
* anything, and acts only if it is granted. Deciding by whose result arrives
* first would be too late by then every client has already clicked.
*
* `browser.open` is the exception: it is handled even with no view attached,
* since opening a tab is precisely what creates one. The view it creates then
* takes over the rest of that same request, so asking for a layout while
* opening does not cost the agent a second call.
*/
import { runtimeFetch } from '@/lib/runtime-fetch';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
type BrowserControlRequest = {
readonly requestId: string;
readonly action: string;
readonly parameters: Record<string, unknown>;
};
/** Implemented by the mounted browser pane. */
export type BrowserController = {
/** Runs one action and resolves with its JSON-serializable result. */
readonly run: (action: string, parameters: Record<string, unknown>) => Promise<unknown>;
};
/** Opens a URL when no browser view exists yet. */
export type BrowserOpener = (url: string) => void;
/**
* How long a freshly opened tab is given to mount its view. A pane appears
* within a frame or two; this is slack for a busy renderer, not a wait anyone
* should ever notice.
*/
const VIEW_ATTACH_TIMEOUT_MS = 2_000;
const VIEW_ATTACH_POLL_MS = 50;
let activeController: BrowserController | null = null;
let opener: BrowserOpener | null = null;
let unsubscribe: (() => void) | null = null;
/**
* Delivers a result to the server.
*
* A dropped result is indistinguishable from an unreachable browser on the
* agent's side, so a failure here is reported rather than swallowed that
* silence is what once turned a missing body parser into an unexplained
* twenty-second timeout.
*/
/**
* Asks for the exclusive right to perform a request.
*
* A refusal is the normal outcome for a client that lost the race, and so is a
* failure to ask at all: acting without a grant is what this exists to prevent.
*/
const claimRequest = async (requestId: string): Promise<boolean> => {
try {
const response = await runtimeFetch('/api/browser-control/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId }),
});
if (!response.ok) return false;
const body = await response.json() as { granted?: boolean };
return body?.granted === true;
} catch {
return false;
}
};
const postResult = async (requestId: string, outcome: { ok: boolean; data?: unknown; error?: string }): Promise<void> => {
try {
const response = await runtimeFetch('/api/browser-control/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, ...outcome }),
});
if (!response.ok) {
console.warn(
`[browser-control] the server rejected the result for ${requestId} (HTTP ${response.status}); `
+ 'the agent will see this action time out',
);
}
} catch (error) {
console.warn(`[browser-control] could not deliver the result for ${requestId}:`, error);
}
};
/**
* Waits for a browser view to register itself, or gives up.
*
* Polls rather than subscribes because registration is a plain assignment made
* by whichever pane mounts; a callback would have to be maintained by every
* caller of `registerBrowserController` for one waiter.
*/
const waitForController = async (
timeoutMs = VIEW_ATTACH_TIMEOUT_MS,
): Promise<BrowserController | null> => {
const deadline = Date.now() + timeoutMs;
while (!activeController && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, VIEW_ATTACH_POLL_MS));
}
return activeController;
};
const handleRequest = async (request: BrowserControlRequest): Promise<void> => {
const isOpen = request.action === 'browser.open';
const controller = activeController;
if (!controller && !(isOpen && opener)) return;
// Nothing below this line may touch a page without the server's grant.
if (!await claimRequest(request.requestId)) return;
try {
if (isOpen && !controller) {
const url = typeof request.parameters.url === 'string' ? request.parameters.url : '';
if (!url) {
await postResult(request.requestId, { ok: false, error: 'url is required' });
return;
}
opener?.(url);
const requestedViewport = typeof request.parameters.viewport === 'string'
? request.parameters.viewport
: '';
if (!requestedViewport || requestedViewport === 'fill') {
await postResult(request.requestId, { ok: true, data: { url, opened: true } });
return;
}
// The tab was just created, so its view is a few frames away. Waiting for
// it lets the layout the agent asked for be applied to the page it is
// opening, rather than to the next call it has to make.
const attached = await waitForController();
if (!attached) {
// Still no view. Reporting a plain success here would leave the agent
// believing a size it asked for was applied to a page nobody is showing.
await postResult(request.requestId, {
ok: true,
data: {
url,
opened: true,
viewportApplied: false,
note: 'The panel had no browser view yet, so the viewport was not applied. Call browser.resize now that one exists.',
},
});
return;
}
const resized = await attached.run('browser.resize', { viewport: requestedViewport });
const viewport = resized && typeof resized === 'object'
? (resized as { viewport?: unknown }).viewport ?? null
: null;
await postResult(request.requestId, {
ok: true,
data: { url, opened: true, viewportApplied: true, viewport },
});
return;
}
const data = await controller!.run(request.action, request.parameters);
await postResult(request.requestId, { ok: true, data });
} catch (error) {
await postResult(request.requestId, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
};
const ensureSubscribed = (): void => {
if (unsubscribe) return;
unsubscribe = subscribeOpenchamberEvents((event) => {
if (event.type !== 'browser-control-request') return;
void handleRequest({
requestId: event.requestId,
action: event.action,
parameters: event.parameters,
});
});
};
const releaseIfIdle = (): void => {
if (activeController || opener || !unsubscribe) return;
unsubscribe();
unsubscribe = null;
};
/**
* Registers the mounted browser view. The most recently mounted view wins;
* unregistering only clears the registry when it still points at the caller,
* so a stale unmount cannot detach a newer view.
*/
export const registerBrowserController = (controller: BrowserController): (() => void) => {
activeController = controller;
ensureSubscribed();
return () => {
if (activeController === controller) activeController = null;
releaseIfIdle();
};
};
/** Registers the app-level fallback that can open a browser tab on demand. */
export const registerBrowserOpener = (open: BrowserOpener): (() => void) => {
opener = open;
ensureSubscribed();
return () => {
if (opener === open) opener = null;
releaseIfIdle();
};
};
@@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test';
import {
CRASH_RECOVERY_BASE_DELAY_MS,
CRASH_RECOVERY_MAX_ATTEMPTS,
CRASH_RECOVERY_WINDOW_MS,
INITIAL_CRASH_RECOVERY_STATE,
planCrashRecovery,
} from './crashRecovery';
describe('crash recovery', () => {
test('recovers from the first crash immediately enough to feel automatic', () => {
const plan = planCrashRecovery(INITIAL_CRASH_RECOVERY_STATE, 1_000);
expect(plan?.delayMs).toBe(CRASH_RECOVERY_BASE_DELAY_MS);
expect(plan?.state).toEqual({ attempts: 1, windowStartedAt: 1_000 });
});
test('waits longer after each attempt instead of reloading in a tight loop', () => {
let state = INITIAL_CRASH_RECOVERY_STATE;
const delays: number[] = [];
for (let index = 0; index < CRASH_RECOVERY_MAX_ATTEMPTS; index += 1) {
const plan = planCrashRecovery(state, 1_000 + index);
expect(plan === null).toBe(false);
delays.push(plan!.delayMs);
state = plan!.state;
}
expect(delays).toEqual([250, 500, 1000]);
});
test('gives up once the attempts in this window are spent', () => {
let state = INITIAL_CRASH_RECOVERY_STATE;
for (let index = 0; index < CRASH_RECOVERY_MAX_ATTEMPTS; index += 1) {
state = planCrashRecovery(state, 1_000)!.state;
}
expect(planCrashRecovery(state, 1_000)).toBeNull();
});
test('a crash long after the last one starts over rather than staying given up', () => {
let state = INITIAL_CRASH_RECOVERY_STATE;
for (let index = 0; index < CRASH_RECOVERY_MAX_ATTEMPTS; index += 1) {
state = planCrashRecovery(state, 1_000)!.state;
}
const later = 1_000 + CRASH_RECOVERY_WINDOW_MS;
const plan = planCrashRecovery(state, later);
expect(plan?.delayMs).toBe(CRASH_RECOVERY_BASE_DELAY_MS);
expect(plan?.state).toEqual({ attempts: 1, windowStartedAt: later });
});
test('crashes inside the window keep counting against the same window', () => {
const first = planCrashRecovery(INITIAL_CRASH_RECOVERY_STATE, 1_000)!;
const second = planCrashRecovery(first.state, 1_000 + CRASH_RECOVERY_WINDOW_MS - 1)!;
expect(second.state.windowStartedAt).toBe(1_000);
expect(second.state.attempts).toBe(2);
});
});
@@ -0,0 +1,66 @@
/**
* Recovery policy for a page whose renderer died.
*
* A `<webview>` runs the page in its own process, and that process can be lost
* out of memory, a hung tab killed by the system, a page that crashes itself.
* Nothing else in the view's lifecycle reports it: no load fails and no
* navigation happens, so without this the panel simply stays blank forever with
* no way back other than closing the tab.
*
* Reloading is usually right, because most crashes are transient. Reloading
* without limit is not: a page that crashes on load would be reloaded until the
* machine gives up. So attempts are bounded within a window, and once they run
* out the panel says so instead of trying again.
*
* The policy is a pure function of the previous state and the current time so
* it can be reasoned about and tested without crashing a real renderer.
*/
export const CRASH_RECOVERY_WINDOW_MS = 30_000;
export const CRASH_RECOVERY_MAX_ATTEMPTS = 3;
export const CRASH_RECOVERY_BASE_DELAY_MS = 250;
export type CrashRecoveryState = {
readonly attempts: number;
/** When the current window opened, or null before the first crash. */
readonly windowStartedAt: number | null;
};
export const INITIAL_CRASH_RECOVERY_STATE: CrashRecoveryState = {
attempts: 0,
windowStartedAt: null,
};
type CrashRecoveryPlan = {
/** How long to wait before reloading. */
readonly delayMs: number;
readonly state: CrashRecoveryState;
};
/**
* Decides whether to reload after a crash, and how long to wait first.
*
* Returns null when the attempts in this window are spent the caller should
* then report the crash rather than retry. Each attempt waits longer than the
* last, so a page that crashes immediately is not reloaded in a tight loop.
*/
export const planCrashRecovery = (
state: CrashRecoveryState,
now: number,
): CrashRecoveryPlan | null => {
// A crash long after the previous one is a new problem, not a continuation
// of an old one; counting it against a stale window would refuse to recover
// from the first crash of an otherwise healthy session.
const startsNewWindow = state.windowStartedAt === null
|| now - state.windowStartedAt >= CRASH_RECOVERY_WINDOW_MS;
const attempts = startsNewWindow ? 0 : state.attempts;
if (attempts >= CRASH_RECOVERY_MAX_ATTEMPTS) return null;
return {
delayMs: CRASH_RECOVERY_BASE_DELAY_MS * 2 ** attempts,
state: {
attempts: attempts + 1,
windowStartedAt: startsNewWindow ? now : state.windowStartedAt,
},
};
};
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test';
import { mergeDevServerCandidates } from './devServers';
const discovered = [
{ port: 3000, url: 'http://localhost:3000/', command: 'node' },
{ port: 4321, url: 'http://localhost:4321/', command: 'node' },
{ port: 4323, url: 'http://localhost:4323/', command: 'node' },
];
describe('dev server candidates', () => {
test('takes the announced address, which carries the base path', () => {
const merged = mergeDevServerCandidates({
announced: ['http://localhost:4323/__analytics'],
discovered,
});
expect(merged.find((entry) => entry.port === 4323)?.url).toBe('http://localhost:4323/__analytics');
});
test('keeps a listening server whose announcement was mangled', () => {
// A terminal wrapping ".../localhost:3000" mid-port yields port 300, which
// parses fine and points nowhere.
const merged = mergeDevServerCandidates({
announced: ['http://localhost:300'],
discovered,
});
expect(merged.map((entry) => entry.port)).toContain(3000);
expect(merged.map((entry) => entry.port)).not.toContain(300);
});
test('drops an announced address with nothing listening behind it', () => {
const merged = mergeDevServerCandidates({
announced: ['http://localhost:9999/app'],
discovered,
});
expect(merged.map((entry) => entry.port)).not.toContain(9999);
});
test('lists servers that never announced themselves', () => {
const merged = mergeDevServerCandidates({ announced: [], discovered });
expect(merged.map((entry) => entry.port)).toEqual([3000, 4321, 4323]);
expect(merged.every((entry) => entry.announced === false)).toBe(true);
});
test('puts the servers this run announced first', () => {
const merged = mergeDevServerCandidates({
announced: ['http://localhost:4323/__analytics'],
discovered,
});
expect(merged[0]?.port).toBe(4323);
});
test('falls back to announcements when discovery is unavailable', () => {
const merged = mergeDevServerCandidates({
announced: ['http://localhost:4323/__analytics', 'http://localhost:3000'],
discovered: null,
});
expect(merged.map((entry) => entry.port)).toEqual([3000, 4323]);
});
test('is empty when neither source has anything', () => {
expect(mergeDevServerCandidates({ announced: [], discovered: null })).toEqual([]);
expect(mergeDevServerCandidates({ announced: [], discovered: [] })).toEqual([]);
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* Client for dev-server discovery.
*
* The result is a tagged union rather than an array, because "no dev server is
* running" and "we could not look" lead to different UI and must not collapse
* into the same empty list.
*/
import { runtimeFetch } from '@/lib/runtime-fetch';
export type DiscoveredDevServer = {
readonly port: number;
readonly url: string;
readonly command: string;
};
export type DevServerDiscovery =
| { readonly kind: 'loading' }
| { readonly kind: 'ready'; readonly servers: ReadonlyArray<DiscoveredDevServer> }
| { readonly kind: 'unavailable' };
const isDiscoveredServer = (value: unknown): value is DiscoveredDevServer => {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record.port === 'number'
&& Number.isFinite(record.port)
&& typeof record.url === 'string'
&& record.url.length > 0
&& typeof record.command === 'string';
};
export const fetchDevServers = async (signal?: AbortSignal): Promise<DevServerDiscovery> => {
try {
const response = await runtimeFetch('/api/dev-servers', { signal });
if (!response.ok) return { kind: 'unavailable' };
const body: unknown = await response.json();
if (!body || typeof body !== 'object') return { kind: 'unavailable' };
const servers = (body as { servers?: unknown }).servers;
if (!Array.isArray(servers)) return { kind: 'unavailable' };
return { kind: 'ready', servers: servers.filter(isDiscoveredServer) };
} catch {
return { kind: 'unavailable' };
}
};
/**
* Asks the server for the HTTP status a loopback URL currently returns.
*
* A dev server fronted by a gateway answers requests before the app behind it
* is up, returning a 5xx page. That is a successful load as far as the browser
* is concerned, so it produces no navigation failure the status is the only
* honest signal, short of reading the page and guessing from its contents.
*
* Returns null when the status could not be established.
*/
export const probeLoopbackStatus = async (url: string): Promise<number | null> => {
try {
const response = await runtimeFetch('/api/system/probe-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!response.ok) return null;
const body: unknown = await response.json();
if (!body || typeof body !== 'object') return null;
const status = (body as { status?: unknown }).status;
return typeof status === 'number' && Number.isFinite(status) ? status : null;
} catch {
return null;
}
};
export type DevServerCandidate = {
readonly url: string;
readonly port: number;
/** Present when a server announced this address itself. */
readonly announced: boolean;
};
const portOf = (url: string): number | null => {
try {
const parsed = new URL(url);
const port = Number.parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
};
/**
* Combines what servers said with what is actually listening.
*
* Each source knows something the other cannot. An announcement carries the base
* path an app is served under, which a socket cannot reveal. A listening port is
* ground truth, which an announcement is not: terminals wrap long lines, and a
* URL split mid-port reads as a perfectly plausible address on a port where
* nothing is running.
*
* So discovery decides which servers exist and announcements supply their paths.
* When discovery is unavailable the announcements stand on their own offering
* something unverified beats offering nothing.
*/
export const mergeDevServerCandidates = ({
announced,
discovered,
}: {
announced: ReadonlyArray<string>;
discovered: ReadonlyArray<DiscoveredDevServer> | null;
}): DevServerCandidate[] => {
const announcedByPort = new Map<number, string>();
for (const url of announced) {
const port = portOf(url);
if (port !== null && !announcedByPort.has(port)) announcedByPort.set(port, url);
}
if (!discovered) {
return [...announcedByPort.entries()]
.map(([port, url]) => ({ url, port, announced: true }))
.sort((left, right) => left.port - right.port);
}
return discovered
.map((server) => {
const announcedUrl = announcedByPort.get(server.port);
return {
url: announcedUrl ?? server.url,
port: server.port,
announced: announcedUrl !== undefined,
};
})
.sort((left, right) => {
// Servers this run announced come first: they are the ones just started.
if (left.announced !== right.announced) return left.announced ? -1 : 1;
return left.port - right.port;
});
};
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
let apiBaseUrl = 'https://remote.example.test';
let tunnelResult: unknown = { localPort: 52418, reused: false };
mock.module('@/lib/desktopNative', () => ({
invokeDesktopCommand: mock(async () => {
if (tunnelResult instanceof Error) throw tunnelResult;
return tunnelResult;
}),
}));
mock.module('@/lib/runtime-auth', () => ({
getRuntimeBearerTokenSync: () => 'token',
getRuntimeExtraHeadersSync: () => ({}),
}));
mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: () => apiBaseUrl,
subscribeRuntimeEndpointChanged: () => () => {},
}));
const {
DevTunnelUnavailableError,
resolveBrowsableUrl,
shouldTunnelLoopbackUrl,
toDisplayUrl,
} = await import('./devTunnel');
const globalScope = globalThis as unknown as { window?: unknown };
const asDesktop = (value: boolean) => {
globalScope.window = value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } };
};
describe('loopback navigations against a remote instance', () => {
beforeEach(() => {
apiBaseUrl = 'https://remote.example.test';
tunnelResult = { localPort: 52418, reused: false };
asDesktop(true);
});
afterEach(() => {
delete globalScope.window;
});
test('a page reached through a tunnel keeps its other ports on the host', () => {
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(true);
});
test('a tunnel port is this machine on purpose and is left alone', async () => {
const tunneled = await resolveBrowsableUrl('http://localhost:3000/');
expect(tunneled).toBe('http://127.0.0.1:52418/');
// Following a link inside the tunnelled page must not tunnel the tunnel.
expect(shouldTunnelLoopbackUrl(tunneled)).toBe(false);
// And the address bar still shows what was asked for.
expect(toDisplayUrl(tunneled)).toBe('http://localhost:3000/');
});
test('a public address is not loopback at all', () => {
expect(shouldTunnelLoopbackUrl('https://openchamber.dev/docs/')).toBe(false);
});
test('an implicit port is the port the scheme means, not nothing', () => {
// http://localhost/ is port 80 on the host, and must be tunnelled like any
// other. Reading it as 0 would send the view to this machine instead.
expect(shouldTunnelLoopbackUrl('http://localhost/')).toBe(true);
expect(shouldTunnelLoopbackUrl('https://localhost/')).toBe(true);
});
test('a failed tunnel is reported, never answered by this machine', async () => {
tunnelResult = new Error('discovery unavailable');
let failed = false;
try {
// A port no earlier test opened: a successful tunnel is cached per target.
await resolveBrowsableUrl('http://localhost:3100/');
} catch (error) {
failed = error instanceof DevTunnelUnavailableError;
}
// Falling back to the plain loopback URL would show whatever runs on that
// port here, under the address of a server on another machine.
expect(failed).toBe(true);
});
test('a local instance resolves its own loopback correctly', () => {
apiBaseUrl = 'http://127.0.0.1:3901';
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
});
test('nothing is tunneled outside the desktop shell', () => {
asDesktop(false);
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* Makes a remote dev server browsable from the desktop app.
*
* A URL like `http://localhost:5173` means "this machine" to whoever resolves
* it. When OpenChamber is running on another host, that is the wrong machine:
* the dev server is on the host, the browser is here. The desktop shell binds
* an equivalent local port and pipes it to the host, so the same page loads
* from a real local origin with nothing rewritten.
*
* Everywhere else local runtime, web, mobile the URL is already correct and
* is returned untouched.
*/
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { isLoopbackUrl } from './url';
type TunnelResult = { localPort: number; reused: boolean; url: string };
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
const localPortByTarget = new Map<string, number>();
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
const originByLocalPort = new Map<number, string>();
const isDesktopRuntime = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
);
/**
* True when the app is talking to an OpenChamber on another machine. A local
* runtime resolves loopback URLs correctly on its own and must not be tunneled,
* which would only add a hop.
*/
const isRemoteRuntime = (baseUrl: string): boolean => {
if (!baseUrl) return false;
try {
const parsed = new URL(baseUrl, typeof window !== 'undefined' ? window.location.href : undefined);
const localOrigin = typeof window !== 'undefined' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
if (localOrigin && parsed.origin === localOrigin) return false;
return !isLoopbackUrl(parsed.toString());
} catch {
return false;
}
};
/**
* The port a loopback URL addresses, including the one it leaves implicit.
* Both callers must agree on this: an omitted port is 80 or 443, not nothing.
*/
const loopbackPort = (url: string): number => {
try {
const parsed = new URL(url);
const port = Number.parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
return Number.isInteger(port) && port > 0 ? port : 0;
} catch {
return 0;
}
};
const rewriteToLocalPort = (url: string, localPort: number): string => {
try {
const parsed = new URL(url);
parsed.protocol = 'http:';
parsed.hostname = '127.0.0.1';
parsed.port = String(localPort);
return parsed.toString();
} catch {
return url;
}
};
/** Thrown when a remote dev server exists but could not be reached from here. */
export class DevTunnelUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'DevTunnelUnavailableError';
}
}
/**
* Returns the URL the browser view should actually load.
*
* A failure to tunnel is reported rather than papered over. Loading the
* original loopback URL instead would not be "the same outcome without this
* mechanism": on a remote instance it changes which machine answers, so the
* user would be shown whatever happens to run on that port here possibly a
* different application under the address they asked for. The refusal is
* often authoritative, too: discovery unavailable, port not offered,
* authentication rejected. None of that should look like a page.
*/
export const resolveBrowsableUrl = async (url: string): Promise<string> => {
if (!url || !isDesktopRuntime() || !isLoopbackUrl(url)) return url;
const baseUrl = getRuntimeApiBaseUrl();
if (!isRemoteRuntime(baseUrl)) return url;
const port = loopbackPort(url);
if (!port) return url;
const key = `${baseUrl}|${port}`;
const cached = localPortByTarget.get(key);
if (cached) {
try {
originByLocalPort.set(cached, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
return rewriteToLocalPort(url, cached);
}
try {
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
baseUrl,
port,
clientToken: getRuntimeBearerTokenSync(),
requestHeaders: getRuntimeExtraHeadersSync(),
});
if (!result || !Number.isInteger(result.localPort) || result.localPort <= 0) {
throw new DevTunnelUnavailableError(url);
}
localPortByTarget.set(key, result.localPort);
try {
originByLocalPort.set(result.localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
return rewriteToLocalPort(url, result.localPort);
} catch (error) {
if (error instanceof DevTunnelUnavailableError) throw error;
throw new DevTunnelUnavailableError(url);
}
};
/**
* True when a loopback URL belongs to the machine OpenChamber runs on rather
* than to this one.
*
* A page served through a tunnel can send the browser to another local port
* a docs server behind a dev gateway, an API on its own port and that
* navigation happens inside the view, where nothing resolved it. Without this
* the address would be looked for on the user's own machine, where it is either
* nothing at all or, worse, a different application.
*
* A URL already pointing at a tunnel's local port is not retargeted; that one
* is this machine, deliberately.
*/
export const shouldTunnelLoopbackUrl = (url: string): boolean => {
if (!url || !isDesktopRuntime() || !isLoopbackUrl(url)) return false;
if (!isRemoteRuntime(getRuntimeApiBaseUrl())) return false;
const port = loopbackPort(url);
return port > 0 && !originByLocalPort.has(port);
};
/**
* Maps a URL the view actually loaded back to the address the user asked for.
*
* Without this the tunnel's random local port would show up in the address bar
* and, worse, be persisted as the tab's target a port that means nothing
* after a restart.
*/
export const toDisplayUrl = (url: string): string => {
if (!url) return url;
try {
const parsed = new URL(url);
if (parsed.hostname !== '127.0.0.1') return url;
const origin = originByLocalPort.get(Number.parseInt(parsed.port || '0', 10));
if (!origin) return url;
return `${origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
};
/**
* Forgets cached tunnels. Cache entries are keyed by runtime base URL, so a
* switch does not make them wrong but the shell's listeners belong to the
* previous endpoint, and holding their ports would keep resolving URLs to a
* host the user has left.
*/
const resetDevTunnelCache = (): void => {
localPortByTarget.clear();
originByLocalPort.clear();
};
if (typeof window !== 'undefined') {
subscribeRuntimeEndpointChanged(resetDevTunnelCache);
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test';
import {
MAX_HISTORY_ENTRIES,
forgetVisit,
historyUrl,
recordVisit,
suggestFromHistory,
type BrowserHistoryEntry,
} from './history';
const entry = (url: string, title: string, lastVisitedAt: number): BrowserHistoryEntry => (
{ url, title, lastVisitedAt }
);
describe('what is worth remembering', () => {
test('accepts a typed address the way the panel opens it', () => {
expect(historyUrl('localhost:3000')).toBe('http://localhost:3000/');
});
test('refuses the resting state and documents with no address', () => {
expect(historyUrl('about:blank')).toBe('');
expect(historyUrl('data:text/html,<p>hi</p>')).toBe('');
expect(historyUrl('')).toBe('');
});
test('refuses an address too long to be one', () => {
expect(historyUrl(`http://example.test/${'a'.repeat(3000)}`)).toBe('');
});
});
describe('recording visits', () => {
test('keeps places rather than events', () => {
let entries = recordVisit([], { url: 'http://localhost:3000/', title: 'App', at: 1 });
entries = recordVisit(entries, { url: 'http://localhost:5173/', title: 'Docs', at: 2 });
entries = recordVisit(entries, { url: 'http://localhost:3000/', title: 'App', at: 3 });
expect(entries.map((item) => item.url)).toEqual(['http://localhost:3000/', 'http://localhost:5173/']);
expect(entries[0]?.lastVisitedAt).toBe(3);
});
test('a later visit with no title keeps the name already known', () => {
let entries = recordVisit([], { url: 'http://localhost:3000/', title: 'App', at: 1 });
entries = recordVisit(entries, { url: 'http://localhost:3000/', at: 2 });
expect(entries[0]?.title).toBe('App');
});
test('ignores a visit that is not a page', () => {
const entries = recordVisit([], { url: 'about:blank', at: 1 });
expect(entries).toEqual([]);
});
test('drops the oldest rather than growing without end', () => {
let entries: BrowserHistoryEntry[] = [];
for (let index = 0; index < MAX_HISTORY_ENTRIES + 10; index += 1) {
entries = recordVisit(entries, { url: `http://example.test/${index}`, at: index });
}
expect(entries).toHaveLength(MAX_HISTORY_ENTRIES);
expect(entries[0]?.url).toBe(`http://example.test/${MAX_HISTORY_ENTRIES + 9}`);
});
test('forgets one address without touching the rest', () => {
const entries = [entry('http://a.test/', 'A', 2), entry('http://b.test/', 'B', 1)];
expect(forgetVisit(entries, 'http://a.test').map((item) => item.url)).toEqual(['http://b.test/']);
});
});
describe('suggestions', () => {
const entries = [
entry('http://localhost:3000/', 'Storefront', 1),
entry('http://localhost:5173/docs', 'Docs', 3),
entry('https://staging.example.test/', 'Staging', 2),
];
test('an empty address bar offers the most recent places', () => {
expect(suggestFromHistory(entries, '').map((item) => item.url)).toEqual([
'http://localhost:5173/docs',
'https://staging.example.test/',
'http://localhost:3000/',
]);
});
test('matches a port or a fragment of the path, not just a prefix', () => {
expect(suggestFromHistory(entries, '5173').map((item) => item.url)).toEqual(['http://localhost:5173/docs']);
expect(suggestFromHistory(entries, 'docs').map((item) => item.url)).toEqual(['http://localhost:5173/docs']);
});
test('matches the page title as well as the address', () => {
expect(suggestFromHistory(entries, 'storefront').map((item) => item.url)).toEqual(['http://localhost:3000/']);
});
test('ignores the scheme the user did or did not type', () => {
expect(suggestFromHistory(entries, 'http://staging').map((item) => item.url))
.toEqual(['https://staging.example.test/']);
});
test('does not offer back the address already typed in full', () => {
expect(suggestFromHistory(entries, 'http://localhost:3000/')).toEqual([]);
});
test('never offers more than it was asked for', () => {
expect(suggestFromHistory(entries, '', 2)).toHaveLength(2);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Address history for the browser panel.
*
* The panel is used to return to the same handful of addresses a dev server,
* a staging URL, one page of the app being worked on so typing them out every
* time is the wrong default. History is per project, because the addresses that
* matter belong to whatever is being worked on, not to the app as a whole.
*
* The ranking and matching live here as pure functions so the behaviour can be
* reasoned about without a store, a list, or a keyboard in the way.
*/
import { normalizeBrowserUrl } from './url';
export type BrowserHistoryEntry = {
readonly url: string;
readonly title: string;
readonly lastVisitedAt: number;
};
/** Enough to cover a project's real addresses without becoming a list nobody reads. */
export const MAX_HISTORY_ENTRIES = 50;
const MAX_HISTORY_SUGGESTIONS = 6;
const MAX_URL_LENGTH = 2048;
const MAX_TITLE_LENGTH = 200;
/**
* The stored form of an address.
*
* Returns '' for anything that is not a page worth remembering. `about:blank`
* is the view's resting state, and a data URL is a document with no address to
* return to.
*/
export const historyUrl = (value: string): string => {
const normalized = normalizeBrowserUrl(String(value ?? '').trim());
if (!normalized || normalized.length > MAX_URL_LENGTH) return '';
try {
const parsed = new URL(normalized);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.toString();
} catch {
return '';
}
};
/**
* Records a visit, newest first.
*
* Revisiting an address moves it up rather than adding a second copy the list
* is places, not events. A visit with no title keeps the title already known,
* because a page often reports its address before it reports its name.
*/
export const recordVisit = (
entries: readonly BrowserHistoryEntry[],
visit: { url: string; title?: string; at: number },
): BrowserHistoryEntry[] => {
const url = historyUrl(visit.url);
if (!url) return entries as BrowserHistoryEntry[];
const previous = entries.find((entry) => entry.url === url);
const title = String(visit.title ?? '').trim().slice(0, MAX_TITLE_LENGTH) || previous?.title || '';
const next: BrowserHistoryEntry = { url, title, lastVisitedAt: visit.at };
return [next, ...entries.filter((entry) => entry.url !== url)].slice(0, MAX_HISTORY_ENTRIES);
};
export const forgetVisit = (
entries: readonly BrowserHistoryEntry[],
url: string,
): BrowserHistoryEntry[] => {
const target = historyUrl(url);
return entries.filter((entry) => entry.url !== target);
};
/** What a query matches against: the address without its scheme, plus the title. */
const searchableText = (entry: BrowserHistoryEntry): string => (
`${entry.url.replace(/^https?:\/\//, '')} ${entry.title}`.toLowerCase()
);
/**
* Suggests addresses for what has been typed so far.
*
* An empty query offers the most recent addresses, which is what an empty
* address bar is asking for. A query matches anywhere in the address or title,
* since a port or a path fragment is often all anyone remembers.
*/
export const suggestFromHistory = (
entries: readonly BrowserHistoryEntry[],
query: string,
limit = MAX_HISTORY_SUGGESTIONS,
): BrowserHistoryEntry[] => {
const needle = String(query ?? '').trim().toLowerCase();
const ordered = [...entries].sort((a, b) => b.lastVisitedAt - a.lastVisitedAt);
if (!needle) return ordered.slice(0, limit);
const scheme = needle.replace(/^https?:\/\//, '');
return ordered
.filter((entry) => {
const text = searchableText(entry);
// An address the user has fully typed is not a suggestion, it is what
// they already have; offering it back is noise.
if (entry.url === needle || text === scheme) return false;
return text.includes(scheme);
})
.slice(0, limit);
};
@@ -0,0 +1,94 @@
/**
* Resolves OpenChamber theme tokens into concrete color strings.
*
* The annotation overlay renders inside a page we do not control, so it cannot
* reference our CSS variables that page has its own `:root`. Theme tokens can
* also be authored in any color space, so string-concatenating an alpha suffix
* onto them is not safe. Both problems go away by letting the browser resolve
* the values here: a probe element carries the token, and the computed style is
* always a concrete color the overlay can use verbatim.
*/
import type { BrowserAnnotationOverlayTheme } from './annotationOverlay';
const FALLBACK: BrowserAnnotationOverlayTheme = {
colorScheme: 'dark',
primary: 'rgb(59, 130, 246)',
primarySoft: 'rgba(59, 130, 246, 0.16)',
primaryFaint: 'rgba(59, 130, 246, 0.10)',
primaryContrast: 'rgb(255, 255, 255)',
surface: 'rgb(24, 24, 27)',
surfaceElevated: 'rgb(32, 32, 36)',
glassSurface: 'rgba(32, 32, 36, 0.64)',
glassFilter: 'blur(26px) saturate(1.16)',
border: 'rgba(255, 255, 255, 0.14)',
text: 'rgb(244, 244, 245)',
mutedText: 'rgba(244, 244, 245, 0.62)',
};
type Probe = {
readonly read: (value: string) => string;
readonly readVariable: (name: string) => string;
readonly dispose: () => void;
};
const createProbe = (): Probe | null => {
if (typeof document === 'undefined' || !document.body) return null;
const element = document.createElement('div');
element.setAttribute('aria-hidden', 'true');
element.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;pointer-events:none';
document.body.appendChild(element);
return {
read: (value: string): string => {
element.style.backgroundColor = '';
element.style.backgroundColor = value;
const resolved = window.getComputedStyle(element).backgroundColor;
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : '';
},
readVariable: (name: string): string => (
window.getComputedStyle(document.documentElement).getPropertyValue(name).trim()
),
dispose: () => element.remove(),
};
};
/**
* Reads the live theme. Returns a usable palette even when probing fails, so a
* missing token can never leave the overlay invisible against the page.
*/
export const resolveAnnotationOverlayTheme = (colorScheme: 'light' | 'dark'): BrowserAnnotationOverlayTheme => {
const probe = createProbe();
if (!probe) return { ...FALLBACK, colorScheme };
try {
const read = (token: string, fallback: string): string => probe.read(`var(${token})`) || fallback;
const mix = (token: string, percent: number, fallback: string): string => (
probe.read(`color-mix(in srgb, var(${token}) ${percent}%, transparent)`) || fallback
);
// The same recipe the app's tooltips and popovers use, resolved here
// because the overlay cannot reach our stylesheet from inside the page.
const glassOpacity = probe.readVariable('--oc-glass-tooltip-opacity') || '62%';
const blur = probe.readVariable('--oc-glass-blur') || '26px';
const saturation = probe.readVariable('--oc-glass-saturation') || '1.16';
return {
colorScheme,
glassSurface: probe.read(`color-mix(in srgb, var(--surface-elevated) ${glassOpacity}, transparent)`)
|| FALLBACK.glassSurface,
glassFilter: `blur(${blur}) saturate(${saturation})`,
primary: read('--primary', FALLBACK.primary),
primarySoft: mix('--primary', 16, FALLBACK.primarySoft),
primaryFaint: mix('--primary', 10, FALLBACK.primaryFaint),
primaryContrast: read('--primary-foreground', FALLBACK.primaryContrast),
surface: read('--surface-background', FALLBACK.surface),
surfaceElevated: read('--surface-elevated', FALLBACK.surfaceElevated),
border: read('--border', FALLBACK.border),
text: read('--foreground', FALLBACK.text),
mutedText: read('--muted-foreground', FALLBACK.mutedText),
};
} catch {
return { ...FALLBACK, colorScheme };
} finally {
probe.dispose();
}
};
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import {
buildClickScript,
buildInspectScript,
buildScrollScript,
buildSnapshotScript,
buildTypeScript,
} from './pageActions';
/**
* These scripts are source text evaluated inside another page: nothing
* type-checks them, and a value interpolated without escaping either breaks the
* script or runs as code.
*/
const parses = (source: string): boolean => {
try {
new Function(source);
return true;
} catch {
return false;
}
};
describe('page action scripts', () => {
test('every script parses', () => {
expect(parses(buildSnapshotScript())).toBe(true);
expect(parses(buildSnapshotScript({ selector: '#main' }))).toBe(true);
expect(parses(buildClickScript({ selector: '#save' }))).toBe(true);
expect(parses(buildClickScript({ text: 'Save' }))).toBe(true);
expect(parses(buildTypeScript({ selector: '#q', value: 'hello', submit: true }))).toBe(true);
expect(parses(buildInspectScript({ selector: '#save' }))).toBe(true);
expect(parses(buildScrollScript({ direction: 'bottom' }))).toBe(true);
expect(parses(buildScrollScript({ selector: 'footer' }))).toBe(true);
});
test('a hostile selector is embedded as data, not as code', () => {
const hostile = `'); window.__owned = true; ('`;
const script = buildClickScript({ selector: hostile });
expect(parses(script)).toBe(true);
// Present only inside a quoted literal: that is what makes it inert.
expect(script).toContain(JSON.stringify(hostile));
});
test('a typed value is embedded as data too', () => {
const value = '"); alert(1); ("';
const script = buildTypeScript({ selector: '#q', value, submit: false });
expect(parses(script)).toBe(true);
expect(script).toContain(JSON.stringify(value));
});
test('whitespace regexes survive interpolation', () => {
// A doubled backslash here would produce a literal backslash-s and match
// nothing, silently collapsing no whitespace at all.
expect(buildSnapshotScript()).toContain('replace(/\\s+/g');
});
test('scrolling asks for instant behaviour, not the page preference', () => {
// A page with scroll-behavior: smooth would otherwise still be animating
// when the position is read.
expect(buildScrollScript({ direction: 'bottom' })).toContain("behavior: 'instant'");
expect(buildScrollScript({ selector: 'footer' })).toContain("behavior: 'instant'");
});
test('a scoped snapshot reads only the subtree it was given', () => {
const script = buildSnapshotScript({ selector: '#changelog' });
expect(script).toContain('"#changelog"');
expect(script).toContain('root.querySelectorAll');
});
});
+379
View File
@@ -0,0 +1,379 @@
/**
* Scripts the agent's browser actions run inside the page.
*
* Each builder returns a self-contained expression evaluated in the page's own
* context, so none of them may reference anything from this module at runtime.
* Inputs are embedded with `JSON.stringify`, which is what keeps a selector or
* a typed value from terminating the expression and becoming code.
*
* Every script resolves to `{ ok, ... }` instead of throwing, so a failed match
* comes back as an explainable result rather than an opaque evaluation error.
*/
/**
* Budget caps. The snapshot cost is bounded by these, not by the size of the
* page: a document with ten thousand nodes returns the same shape as one with
* two hundred, because only visible interactive elements are collected and both
* lists are cut off here. What the caps drop is always reported, so a partial
* answer never reads as a complete one.
*/
const MAX_TEXT_CHARS = 6_000;
const MAX_ELEMENTS = 120;
/** Enough to recognise a control; full labels are what made entries expensive. */
const MAX_LABEL_CHARS = 80;
/**
* Shared helpers, injected into each script. `describe` builds the same kind of
* selector the other actions accept, so a snapshot result is directly usable as
* input to click or type.
*/
const HELPERS = `
var MAX_ELEMENTS = ${MAX_ELEMENTS};
var MAX_LABEL_CHARS = ${MAX_LABEL_CHARS};
var visible = function (element) {
var rect = element.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return false;
var style = window.getComputedStyle(element);
return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) !== 0;
};
var label = function (element) {
var aria = element.getAttribute('aria-label');
if (aria) return aria.trim();
var value = element.getAttribute('value');
var text = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim();
if (text) return text.slice(0, MAX_LABEL_CHARS);
if (value) return String(value).slice(0, MAX_LABEL_CHARS);
var placeholder = element.getAttribute('placeholder');
return placeholder ? placeholder.trim().slice(0, MAX_LABEL_CHARS) : '';
};
var isUnique = function (selector) {
try {
return document.querySelectorAll(selector).length === 1;
} catch (error) {
return false;
}
};
/**
* Names an element by what it is, falling back to where it sits.
*
* A positional chain like 'main > section:nth-of-type(3) > div > a' survives
* only until the markup shifts, and says nothing about what it points at.
* Anything the page states about identity an id, a test id, an accessible
* name outlives edits and reads as the thing it selects. The chain remains
* as the last resort, because something always has to work.
*/
var cssPath = function (element) {
var tag = element.tagName.toLowerCase();
if (element.id) {
var byId = '#' + CSS.escape(element.id);
if (isUnique(byId)) return byId;
}
var stableAttrs = ['data-testid', 'data-test-id', 'data-test', 'name', 'aria-label'];
for (var a = 0; a < stableAttrs.length; a += 1) {
var value = element.getAttribute(stableAttrs[a]);
if (!value) continue;
var raw = String(value);
// A value containing a quote would need escaping for no real gain: such
// attributes are rare, and the positional chain still covers them.
if (raw.indexOf('"') !== -1) continue;
var byAttr = tag + '[' + stableAttrs[a] + '="' + raw + '"]';
if (isUnique(byAttr)) return byAttr;
}
var className = typeof element.className === 'string' ? element.className.trim() : '';
if (className) {
var classes = className.split(/\\s+/).filter(Boolean);
for (var c = 0; c < classes.length; c += 1) {
var byClass = tag + '.' + CSS.escape(classes[c]);
if (isUnique(byClass)) return byClass;
}
}
var parts = [];
var node = element;
var depth = 0;
while (node && node.nodeType === 1 && depth < 6) {
var part = node.tagName.toLowerCase();
var parent = node.parentElement;
if (!parent) { parts.unshift(part); break; }
var siblings = Array.prototype.filter.call(parent.children, function (child) {
return child.tagName === node.tagName;
});
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
parts.unshift(part);
if (node.id) { parts[0] = '#' + CSS.escape(node.id); break; }
node = parent;
depth += 1;
}
return parts.join(' > ');
};
/** What a screen reader would announce, or '' when there is nothing to say. */
var accessibleName = function (element) {
var aria = element.getAttribute('aria-label');
if (aria && aria.trim()) return aria.trim();
var labelled = element.getAttribute('aria-labelledby');
if (labelled) {
var source = document.getElementById(labelled.split(/\\s+/)[0]);
if (source && (source.innerText || '').trim()) return source.innerText.trim();
}
var title = element.getAttribute('title');
if (title && title.trim()) return title.trim();
var alt = element.getAttribute('alt');
if (alt && alt.trim()) return alt.trim();
var text = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim();
if (text) return text;
var value = element.getAttribute('value');
return value && String(value).trim() ? String(value).trim() : '';
};
var findByText = function (needle) {
var wanted = String(needle).replace(/\\s+/g, ' ').trim().toLowerCase();
var candidates = document.querySelectorAll('a, button, [role="button"], [role="link"], input[type="submit"], input[type="button"], summary, label');
var exact = null;
var partial = null;
for (var i = 0; i < candidates.length; i += 1) {
var element = candidates[i];
if (!visible(element)) continue;
var text = label(element).toLowerCase();
if (!text) continue;
if (text === wanted) { exact = element; break; }
if (!partial && text.indexOf(wanted) !== -1) partial = element;
}
return exact || partial;
};
`;
const wrap = (body: string): string => `(() => {\n${HELPERS}\n${body}\n})()`;
/**
* `selector` narrows the snapshot to one subtree.
*
* A long page truncates against the caps no matter how they are tuned, which
* leaves the agent hunting. Scoping answers that directly: ask about the part
* you mean and the caps stop mattering.
*/
export const buildSnapshotScript = ({ selector }: { selector?: string } = {}): string => wrap(`
var scopeSelector = ${JSON.stringify(selector ?? '')};
var root = document;
if (scopeSelector) {
try { root = document.querySelector(scopeSelector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + scopeSelector }; }
if (!root) return { ok: false, error: 'No element matches ' + scopeSelector };
}
var interactive = root.querySelectorAll('a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [contenteditable="true"]');
var elements = [];
var visibleTotal = 0;
for (var i = 0; i < interactive.length; i += 1) {
var element = interactive[i];
if (!visible(element)) continue;
visibleTotal += 1;
if (elements.length >= MAX_ELEMENTS) continue;
var rect = element.getBoundingClientRect();
// Empty and default-valued fields are left out rather than serialized as
// "" and false. Repeated across a hundred entries that overhead dwarfed
// the information it carried.
var entry = {
selector: cssPath(element),
tag: element.tagName.toLowerCase(),
bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }
};
// The list covers the whole document so anything can be clicked without
// scrolling to it first, which means bounds alone do not say what is on
// screen — a negative y reads as a bug otherwise.
if (rect.bottom > 0 && rect.top < window.innerHeight) entry.inViewport = true;
var type = element.getAttribute('type');
if (type) entry.type = type;
var role = element.getAttribute('role');
if (role) entry.role = role;
var labelText = label(element);
if (labelText) entry.label = labelText;
if (element.disabled === true) entry.disabled = true;
// Flagged rather than described: reporting the accessible name of every
// element would cost more than it tells, while its absence on something
// clickable is a defect worth naming.
if (!accessibleName(element)) entry.missingAccessibleName = true;
elements.push(entry);
}
var body = document.body ? (document.body.innerText || '') : '';
var text = body.replace(/\\n{3,}/g, '\\n\\n').trim();
var docEl = document.documentElement;
var result = {
ok: true,
url: String(location.href),
title: String(document.title || ''),
scope: scopeSelector || 'document',
scrollY: Math.round(window.scrollY),
maxScrollY: Math.max(0, Math.round(docEl.scrollHeight - window.innerHeight)),
text: text.slice(0, ${MAX_TEXT_CHARS}),
elements: elements
};
// State what was dropped. A capped list that reports only its own length
// reads as the whole page, and the agent acts as if it had seen everything.
if (text.length > ${MAX_TEXT_CHARS}) {
result.textTruncated = true;
result.textTotalChars = text.length;
}
if (visibleTotal > elements.length) {
result.elementsTruncated = true;
result.interactiveElementsOnPage = visibleTotal;
}
return result;
`);
export const buildClickScript = ({ selector, text }: { selector?: string; text?: string }): string => wrap(`
var selector = ${JSON.stringify(selector ?? '')};
var text = ${JSON.stringify(text ?? '')};
var target = null;
if (selector) {
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
} else {
target = findByText(text);
if (!target) return { ok: false, error: 'No clickable element has the label ' + text };
}
if (target.disabled === true) return { ok: false, error: 'Element is disabled' };
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
return { ok: true, clicked: cssPath(target), label: label(target), url: String(location.href) };
`);
export const buildTypeScript = ({ selector, value, submit }: { selector: string; value: string; submit: boolean }): string => wrap(`
var selector = ${JSON.stringify(selector)};
var value = ${JSON.stringify(value)};
var target = null;
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
var editable = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
if (!editable) return { ok: false, error: selector + ' is not a text field' };
if (target.disabled === true || target.readOnly === true) return { ok: false, error: 'Field is not editable' };
target.scrollIntoView({ block: 'center' });
target.focus();
if (target.isContentEditable) {
target.textContent = value;
} else {
// Frameworks track the value through the native setter; assigning the
// property directly leaves React and friends unaware of the change.
var prototype = target.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(prototype, 'value');
if (setter && setter.set) setter.set.call(target, value);
else target.value = value;
}
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
if (${submit ? 'true' : 'false'}) {
var enter = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true };
target.dispatchEvent(new KeyboardEvent('keydown', enter));
target.dispatchEvent(new KeyboardEvent('keyup', enter));
var form = target.form;
if (form && typeof form.requestSubmit === 'function') form.requestSubmit();
}
return { ok: true, selector: cssPath(target), url: String(location.href) };
`);
/**
* Scrolling, reported after it has actually happened.
*
* Two things made this lie. Pages commonly set `scroll-behavior: smooth`, which
* turns a programmatic scroll into an animation so the position read straight
* afterwards is the position before the scroll, and the result said nothing
* moved. And a scroll that is already at the end is indistinguishable from one
* that failed unless the limits are reported too. An agent reading "scrollY: 0"
* from a scroll that worked learns a superstition it will apply for the rest of
* the session.
*/
export const buildScrollScript = ({ selector, direction }: { selector?: string; direction?: string }): string => wrap(`
var selector = ${JSON.stringify(selector ?? '')};
var direction = ${JSON.stringify(direction ?? '')};
var settle = function (extra) {
return new Promise(function (resolve) {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
var doc = document.documentElement;
var maxScrollY = Math.max(0, doc.scrollHeight - window.innerHeight);
var scrollY = Math.round(window.scrollY);
var result = { ok: true, scrollY: scrollY, maxScrollY: Math.round(maxScrollY) };
result.atTop = scrollY <= 1;
result.atBottom = scrollY >= maxScrollY - 1;
for (var key in extra) {
if (Object.prototype.hasOwnProperty.call(extra, key)) result[key] = extra[key];
}
resolve(result);
});
});
});
};
if (selector) {
var target = null;
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
// Instant on purpose: the page's own smooth scrolling would still be
// animating when the next action runs against it.
target.scrollIntoView({ block: 'center', behavior: 'instant' });
return settle({ scrolledTo: cssPath(target) });
}
var doc = document.documentElement;
var page = Math.round(window.innerHeight * 0.85);
var bottom = Math.max(0, doc.scrollHeight - window.innerHeight);
if (direction === 'down') window.scrollTo({ top: window.scrollY + page, behavior: 'instant' });
else if (direction === 'up') window.scrollTo({ top: window.scrollY - page, behavior: 'instant' });
else if (direction === 'top') window.scrollTo({ top: 0, behavior: 'instant' });
else if (direction === 'bottom') window.scrollTo({ top: bottom, behavior: 'instant' });
else return { ok: false, error: 'Unknown scroll direction: ' + direction };
return settle({ direction: direction });
`);
/** Properties that answer "how does this look", without dumping the whole cascade. */
const INSPECTED_STYLE_PROPS = [
'color', 'background-color', 'background-image', 'opacity',
'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'text-align',
'border-radius', 'border-width', 'border-style', 'border-color', 'box-shadow',
'display', 'position', 'width', 'height', 'padding', 'margin', 'gap',
'flex-direction', 'justify-content', 'align-items', 'z-index', 'overflow', 'visibility',
];
/**
* Reads how one element actually renders.
*
* The snapshot describes structure, which leaves questions of appearance
* answerable only by reading the source and hoping the build agrees. Computed
* styles come from the live page, so a colour reported from here is the colour
* on screen and unlike a screenshot it is readable by an agent that cannot
* see images.
*/
export const buildInspectScript = ({ selector }: { selector: string }): string => wrap(`
var selector = ${JSON.stringify(selector)};
var target = null;
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
var computed = window.getComputedStyle(target);
var styles = {};
var props = ${JSON.stringify(INSPECTED_STYLE_PROPS)};
for (var i = 0; i < props.length; i += 1) {
var value = computed.getPropertyValue(props[i]);
if (value) styles[props[i]] = String(value).trim();
}
var rect = target.getBoundingClientRect();
return {
ok: true,
selector: cssPath(target),
tag: target.tagName.toLowerCase(),
label: label(target),
bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
inViewport: rect.bottom > 0 && rect.top < window.innerHeight,
styles: styles
};
`);
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test';
import { BLANK_URL, browserUrlLabel, isLoopbackUrl, isStartingServerFailure, normalizeBrowserUrl } from './url';
describe('normalizeBrowserUrl', () => {
test('keeps an explicit scheme', () => {
expect(normalizeBrowserUrl('http://example.com/a')).toBe('http://example.com/a');
expect(normalizeBrowserUrl('https://example.com/a')).toBe('https://example.com/a');
});
test('defaults a public host to https', () => {
expect(normalizeBrowserUrl('example.com')).toBe('https://example.com/');
});
test('defaults a loopback authority to http, since dev servers speak plain http', () => {
expect(normalizeBrowserUrl('localhost:5173')).toBe('http://localhost:5173/');
expect(normalizeBrowserUrl('127.0.0.1:3000/app')).toBe('http://127.0.0.1:3000/app');
expect(normalizeBrowserUrl('localhost')).toBe('http://localhost/');
});
test('does not mistake a public host that merely starts with a loopback-looking label', () => {
expect(normalizeBrowserUrl('localhost.example.com')).toBe('https://localhost.example.com/');
});
test('rejects non-http schemes rather than handing them to the browser', () => {
expect(normalizeBrowserUrl('file:///etc/passwd')).toBe(BLANK_URL);
expect(normalizeBrowserUrl('javascript://alert(1)')).toBe(BLANK_URL);
expect(normalizeBrowserUrl('data://text/html,x')).toBe(BLANK_URL);
});
test('treats empty and unparseable input as blank', () => {
expect(normalizeBrowserUrl('')).toBe(BLANK_URL);
expect(normalizeBrowserUrl(' ')).toBe(BLANK_URL);
expect(normalizeBrowserUrl('http://')).toBe(BLANK_URL);
});
});
describe('isLoopbackUrl', () => {
test('recognizes loopback hosts', () => {
expect(isLoopbackUrl('http://localhost:5173/')).toBe(true);
expect(isLoopbackUrl('http://127.0.0.1/')).toBe(true);
});
test('rejects remote hosts and garbage', () => {
expect(isLoopbackUrl('https://example.com/')).toBe(false);
expect(isLoopbackUrl('nonsense')).toBe(false);
});
});
describe('browserUrlLabel', () => {
test('shows host and port', () => {
expect(browserUrlLabel('http://localhost:5173/a/b')).toBe('localhost:5173');
});
test('is empty for a blank page', () => {
expect(browserUrlLabel(BLANK_URL)).toBe('');
expect(browserUrlLabel('')).toBe('');
});
});
describe('isStartingServerFailure', () => {
test('retries a loopback connection refusal, the usual "server not up yet"', () => {
expect(isStartingServerFailure(-102, 'http://localhost:3000/')).toBe(true);
expect(isStartingServerFailure(-104, 'http://127.0.0.1:5173/')).toBe(true);
});
test('does not retry a public host that refused the connection', () => {
expect(isStartingServerFailure(-102, 'https://example.com/')).toBe(false);
});
test('does not retry a real page-level failure', () => {
// ERR_ABORTED and certificate errors are not "not up yet".
expect(isStartingServerFailure(-3, 'http://localhost:3000/')).toBe(false);
expect(isStartingServerFailure(-201, 'http://localhost:3000/')).toBe(false);
});
test('does not retry an unparseable url', () => {
expect(isStartingServerFailure(-102, 'not-a-url')).toBe(false);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Address-bar input handling for the browser surface.
*/
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
/** `localhost:5173`, `127.0.0.1:3000` — an authority with no scheme. */
const isLoopbackAuthority = (value: string): boolean => {
const host = value.split('/')[0]?.split('?')[0] ?? '';
const hostname = host.startsWith('[')
? host.slice(0, host.indexOf(']') + 1)
: host.split(':')[0] ?? '';
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
};
export const BLANK_URL = 'about:blank';
/**
* Normalizes what the user typed into a URL the browser can load.
*
* Schemeless input defaults to `https:`, except for loopback authorities:
* dev servers overwhelmingly speak plain HTTP, and defaulting `localhost:5173`
* to HTTPS turns the single most common address in this panel into a
* connection error.
*/
export const normalizeBrowserUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return BLANK_URL;
const withScheme = trimmed.includes('://')
? trimmed
: `${isLoopbackAuthority(trimmed) ? 'http' : 'https'}://${trimmed}`;
try {
const parsed = new URL(withScheme);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return BLANK_URL;
return parsed.toString();
} catch {
return BLANK_URL;
}
};
/** True when a URL points at the machine the page is loaded from. */
export const isLoopbackUrl = (value: string): boolean => {
try {
return LOOPBACK_HOSTNAMES.has(new URL(value).hostname.toLowerCase());
} catch {
return false;
}
};
/** Short label for a tab or header: host plus port, falling back to the raw value. */
export const browserUrlLabel = (value: string): string => {
if (!value || value === BLANK_URL) return '';
try {
return new URL(value).host || value;
} catch {
return value;
}
};
/**
* Chromium network errors that mean "nothing is answering there yet".
*
* A dev server is routinely opened the moment its address appears in the
* terminal, before it accepts connections, so the first load fails as a matter
* of course. Treating that as a dead end and making the user press reload
* is the single most common way this panel feels broken.
*/
const RETRYABLE_LOAD_ERROR_CODES = new Set([
-2, // FAILED (generic; Electron reports this for some early refusals)
-7, // TIMED_OUT
-101, // CONNECTION_RESET
-102, // CONNECTION_REFUSED
-104, // CONNECTION_FAILED
-109, // ADDRESS_UNREACHABLE
-118, // CONNECTION_TIMED_OUT
-324, // EMPTY_RESPONSE
]);
/**
* Whether a failed load is worth retrying. Restricted to loopback: retrying a
* public site that refused us is just hammering somebody else's server, and a
* remote dev server is reached through a local tunnel port anyway.
*/
export const isStartingServerFailure = (code: number, url: string): boolean => (
RETRYABLE_LOAD_ERROR_CODES.has(code) && isLoopbackUrl(url)
);
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test';
import {
FILL_VIEWPORT,
MAX_VIEWPORT_SIZE,
MIN_VIEWPORT_SIZE,
VIEWPORT_PRESETS,
clampViewportSize,
describeViewport,
fitViewport,
presetViewport,
isViewportMode,
rotateViewport,
viewportForMode,
viewportSize,
viewportSummary,
} from './viewport';
describe('viewport size', () => {
test('fill has no size of its own', () => {
expect(viewportSize(FILL_VIEWPORT)).toBeNull();
expect(fitViewport(FILL_VIEWPORT, { width: 800, height: 600 })).toBeNull();
});
test('clamps to a range a page can actually be laid out in', () => {
expect(clampViewportSize(10)).toBe(MIN_VIEWPORT_SIZE);
expect(clampViewportSize(99_999)).toBe(MAX_VIEWPORT_SIZE);
expect(clampViewportSize(390.6)).toBe(391);
expect(clampViewportSize(Number.NaN)).toBe(MIN_VIEWPORT_SIZE);
});
});
describe('presets', () => {
test('resolves a known preset', () => {
expect(presetViewport('iphone-14')).toEqual({ kind: 'preset', id: 'iphone-14', width: 390, height: 844 });
});
test('returns nothing for an unknown id', () => {
expect(presetViewport('nokia-3310')).toBeNull();
});
test('every preset is within the layout range', () => {
for (const preset of VIEWPORT_PRESETS) {
expect(clampViewportSize(preset.width)).toBe(preset.width);
expect(clampViewportSize(preset.height)).toBe(preset.height);
}
});
test('names the current preset and nothing else', () => {
expect(describeViewport(presetViewport('ipad-mini')!)).toBe('iPad mini');
expect(describeViewport({ kind: 'custom', width: 500, height: 500 })).toBe('');
expect(describeViewport(FILL_VIEWPORT)).toBe('');
});
});
describe('rotation', () => {
test('swaps the sides', () => {
expect(rotateViewport({ kind: 'custom', width: 390, height: 844 }))
.toEqual({ kind: 'custom', width: 844, height: 390 });
});
test('a rotated preset stops claiming to be that preset', () => {
const rotated = rotateViewport(presetViewport('iphone-14')!);
expect(rotated.kind).toBe('custom');
expect(describeViewport(rotated)).toBe('');
});
test('fill has no orientation', () => {
expect(rotateViewport(FILL_VIEWPORT)).toEqual(FILL_VIEWPORT);
});
});
describe('fitting', () => {
const viewport = { kind: 'custom', width: 400, height: 800 } as const;
test('keeps the chosen size and scales down to fit', () => {
const layout = fitViewport(viewport, { width: 200, height: 800 });
expect(layout).toEqual({ width: 400, height: 800, scale: 0.5 });
});
test('fits by whichever side runs out first', () => {
expect(fitViewport(viewport, { width: 800, height: 400 })?.scale).toBe(0.5);
});
test('never enlarges, which would misrepresent the size asked for', () => {
expect(fitViewport(viewport, { width: 4000, height: 4000 })?.scale).toBe(1);
});
test('survives a container measured at zero mid-layout', () => {
const layout = fitViewport(viewport, { width: 0, height: 0 });
expect(layout?.width).toBe(400);
expect(layout && layout.scale > 0).toBe(true);
});
});
describe('agent viewport vocabulary', () => {
test('each named mode resolves to a real size', () => {
for (const mode of ['mobile', 'tablet', 'desktop'] as const) {
const size = viewportSize(viewportForMode(mode));
expect(size !== null).toBe(true);
}
});
test('fill has no size, as the agent should expect', () => {
expect(viewportSize(viewportForMode('fill'))).toBeNull();
});
test('accepts only the vocabulary it published', () => {
expect(isViewportMode('mobile')).toBe(true);
expect(isViewportMode('phone')).toBe(false);
expect(isViewportMode(390)).toBe(false);
});
test('reports back in the same words it accepts', () => {
expect(viewportSummary(viewportForMode('mobile')).mode).toBe('mobile');
expect(viewportSummary(FILL_VIEWPORT).mode).toBe('fill');
});
test('calls a hand-typed size custom rather than the nearest name', () => {
const summary = viewportSummary({ kind: 'custom', width: 500, height: 900 });
expect(summary.mode).toBe('custom');
expect(summary.width).toBe(500);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Viewport sizing for the browser panel.
*
* The page is rendered at a chosen size and scaled down to fit the panel when
* it does not. Scaling is visual only: the view still lays out at the chosen
* width, which is the whole point a 390px layout has to be measured at 390px,
* not at whatever the panel happens to be.
*/
export type BrowserViewport =
| { readonly kind: 'fill' }
| { readonly kind: 'preset'; readonly id: string; readonly width: number; readonly height: number }
| { readonly kind: 'custom'; readonly width: number; readonly height: number };
export const FILL_VIEWPORT: BrowserViewport = { kind: 'fill' };
export const MIN_VIEWPORT_SIZE = 240;
export const MAX_VIEWPORT_SIZE = 3840;
export type ViewportPreset = {
readonly id: string;
readonly label: string;
readonly width: number;
readonly height: number;
};
/**
* Sizes worth having, not every device ever made. A long list is harder to pick
* from than it is useful, and anything missing can be typed in directly.
*/
export const VIEWPORT_PRESETS: readonly ViewportPreset[] = [
{ id: 'iphone-se', label: 'iPhone SE', width: 375, height: 667 },
{ id: 'iphone-14', label: 'iPhone 14', width: 390, height: 844 },
{ id: 'iphone-14-pro-max', label: 'iPhone 14 Pro Max', width: 430, height: 932 },
{ id: 'pixel-7', label: 'Pixel 7', width: 412, height: 915 },
{ id: 'ipad-mini', label: 'iPad mini', width: 768, height: 1024 },
{ id: 'ipad-pro', label: 'iPad Pro', width: 1024, height: 1366 },
{ id: 'laptop', label: 'Laptop', width: 1280, height: 800 },
{ id: 'desktop', label: 'Desktop', width: 1440, height: 900 },
];
export const clampViewportSize = (value: number): number => {
if (!Number.isFinite(value)) return MIN_VIEWPORT_SIZE;
return Math.round(Math.min(MAX_VIEWPORT_SIZE, Math.max(MIN_VIEWPORT_SIZE, value)));
};
export const viewportSize = (
viewport: BrowserViewport,
): { width: number; height: number } | null => (
viewport.kind === 'fill' ? null : { width: viewport.width, height: viewport.height }
);
/** Turns a preset id into a viewport, or null when the id is unknown. */
export const presetViewport = (id: string): BrowserViewport | null => {
const preset = VIEWPORT_PRESETS.find((entry) => entry.id === id);
if (!preset) return null;
return { kind: 'preset', id: preset.id, width: preset.width, height: preset.height };
};
export const rotateViewport = (viewport: BrowserViewport): BrowserViewport => {
if (viewport.kind === 'fill') return viewport;
// Rotating a preset stops it being that preset: an iPhone on its side is no
// longer the entry in the list, and pretending otherwise makes the picker lie.
return { kind: 'custom', width: viewport.height, height: viewport.width };
};
export type ViewportLayout = {
/** Size to lay the page out at, in CSS pixels. */
readonly width: number;
readonly height: number;
/** Visual scale, ≤ 1. Applied with a transform; the page never learns of it. */
readonly scale: number;
};
/**
* Fits a chosen viewport into the space available.
*
* Only ever scales down. Enlarging a small viewport to fill a big panel would
* misrepresent the very thing the user asked to see.
*/
export const fitViewport = (
viewport: BrowserViewport,
available: { width: number; height: number },
): ViewportLayout | null => {
const size = viewportSize(viewport);
if (!size) return null;
const usableWidth = Math.max(1, available.width);
const usableHeight = Math.max(1, available.height);
const scale = Math.min(1, usableWidth / size.width, usableHeight / size.height);
return { width: size.width, height: size.height, scale };
};
/** Label for the current viewport, for the size control. */
export const describeViewport = (viewport: BrowserViewport): string => {
if (viewport.kind === 'fill') return '';
if (viewport.kind === 'preset') {
return VIEWPORT_PRESETS.find((entry) => entry.id === viewport.id)?.label ?? '';
}
return '';
};
/**
* The vocabulary the agent gets.
*
* Named sizes rather than pixel dimensions: an agent asked to "check the mobile
* layout" should not have to invent a width, and a number it invented tells the
* user nothing about what was actually checked.
*/
const VIEWPORT_MODES = ['mobile', 'tablet', 'desktop', 'fill'] as const;
export type BrowserViewportMode = (typeof VIEWPORT_MODES)[number];
const MODE_PRESETS: Record<Exclude<BrowserViewportMode, 'fill'>, string> = {
mobile: 'iphone-14',
tablet: 'ipad-mini',
desktop: 'desktop',
};
export const isViewportMode = (value: unknown): value is BrowserViewportMode => (
typeof value === 'string' && (VIEWPORT_MODES as readonly string[]).includes(value)
);
export const viewportForMode = (mode: BrowserViewportMode): BrowserViewport => (
mode === 'fill' ? FILL_VIEWPORT : presetViewport(MODE_PRESETS[mode]) ?? FILL_VIEWPORT
);
/**
* Reports the current viewport in the agent's own vocabulary, so a snapshot
* states which layout it describes.
*/
export const viewportSummary = (viewport: BrowserViewport): {
mode: BrowserViewportMode | 'custom';
width: number | null;
height: number | null;
} => {
const size = viewportSize(viewport);
if (!size) return { mode: 'fill', width: null, height: null };
for (const mode of ['mobile', 'tablet', 'desktop'] as const) {
const preset = viewportForMode(mode);
const presetSize = viewportSize(preset);
if (presetSize && presetSize.width === size.width && presetSize.height === size.height) {
return { mode, width: size.width, height: size.height };
}
}
return { mode: 'custom', width: size.width, height: size.height };
};
+1
View File
@@ -154,6 +154,7 @@ export type DesktopSettings = {
inputSpellcheckEnabled?: boolean;
showOpenCodeUpdateNotifications?: boolean;
agentControlToolEnabled?: boolean;
agentWebToolEnabled?: boolean;
optimizeSystemPrompt?: boolean;
openCodeUpdateToastDismissedVersion?: string;
showToolFileIcons?: boolean;
@@ -943,6 +943,13 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': 'Verhindern des Schlafens wird auf diesem System nicht unterstützt',
'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': 'Fehler beim Aktualisieren der Einstellung für Wachbleiben',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'OpenChamber-Werkzeuge',
'settings.openchamber.tools.field.agentControlTool': 'Agenten-Steuerungswerkzeug',
'settings.openchamber.tools.field.agentControlToolAria': 'Das Agenten-Steuerungswerkzeug aktivieren',
'settings.openchamber.tools.field.agentControlToolInfo': 'Lässt Agenten deine Arbeit per Chat orchestrieren: Sitzungen und Arbeitsbereiche starten, Prompts an andere Agenten delegieren und geplante Aufgaben verwalten. Fügt jeder Sitzung eine kleine Werkzeugbeschreibung hinzu. Gilt nach einem Neustart von OpenCode.',
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber-Web-Werkzeug',
'settings.openchamber.tools.field.agentWebToolAria': 'Das OpenChamber-Web-Werkzeug aktivieren',
'settings.openchamber.tools.field.agentWebToolInfo': 'Lässt Agenten die Seite im Browser-Panel von OpenChamber ansehen und bedienen: eine URL öffnen, den Inhalt lesen, klicken, tippen, scrollen und zwischen mobiler und Desktop-Ansicht wechseln. Fügt jeder Sitzung eine kleine Werkzeugbeschreibung hinzu. Gilt nach einem Neustart von OpenCode.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Optionaler absoluter Pfad zur',
'settings.openchamber.opencodeCli.tooltipSuffix': 'Binary.',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode Binary-Pfad',
@@ -2092,9 +2099,6 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Erfordert einen Neustart der App. Wenn deaktiviert, erstellt OpenChamber weder den Menüleisten-Eintrag noch führt es dessen Sitzungs-, Genehmigungs- und Nutzungsaktualisierungen aus.',
'settings.openchamber.desktopPassword.actions.showPassword': 'Passwort anzeigen',
'settings.openchamber.desktopPassword.actions.hidePassword': 'Passwort verbergen',
'settings.openchamber.opencodeCli.field.agentControlTool': 'Agenten-Steuerungswerkzeug',
'settings.openchamber.opencodeCli.field.agentControlToolAria': 'Das Agenten-Steuerungswerkzeug aktivieren',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': 'Lässt Agenten deine Arbeit per Chat orchestrieren: Sitzungen und Arbeitsbereiche starten, Prompts an andere Agenten delegieren und geplante Aufgaben verwalten. Fügt jeder Sitzung eine kleine Werkzeugbeschreibung hinzu. Gilt nach Speichern + Neuladen.',
'settings.openchamber.defaults.walkthroughModel.title': 'Walkthrough-Modell ändern',
'settings.openchamber.defaults.walkthroughModel.description': 'Die KI-Prüfung deiner Änderungen benötigt strukturierten Output und Platz für einen ganzen Diff, den ein günstiges kleines Modell oft nicht liefern kann. Modelle, die der Katalog als nicht in der Lage zu strukturiertem Output meldet, werden in diesem Auswahlfeld ausgeblendet. Lasse es leer, dann wird das kleine Modell verwendet.',
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough-Modell',
+53
View File
@@ -1046,9 +1046,62 @@ export const dict = {
'contextPanel.mode.browser': 'Browser',
'contextPanel.browser.open': 'Browser-Panel öffnen',
'contextPanel.browser.addressAria': 'Browser-Adresse',
'contextPanel.browser.history.label': 'Zuletzt besuchte Adressen',
'contextPanel.browser.history.forget': 'Aus dem Verlauf entfernen',
'contextPanel.browser.newTab': 'Neuer Browser-Tab',
'contextPanel.browser.empty': 'Webbrowser',
'contextPanel.browser.emptyHint': 'Geben Sie oben eine Adresse ein, um mit dem Surfen im Web zu beginnen',
'contextPanel.browser.inspectUnavailable': 'Diese Seite kann nicht aus dem Browser-Panel inspectiert werden.',
'contextPanel.browser.back': 'Zurück',
'contextPanel.browser.forward': 'Vorwärts',
'contextPanel.browser.reload': 'Neu laden',
'contextPanel.browser.hardReload': 'Ohne Cache neu laden',
'contextPanel.browser.zoomIn': 'Vergrößern',
'contextPanel.browser.zoomOut': 'Verkleinern',
'contextPanel.browser.zoomReset': 'Zoom zurücksetzen',
'contextPanel.browser.clearCookies': 'Cookies löschen',
'contextPanel.browser.clearCache': 'Cache löschen',
'contextPanel.browser.clearedCookies': 'Cookies gelöscht',
'contextPanel.browser.clearedCache': 'Cache gelöscht',
'contextPanel.browser.clearFailed': 'Browserdaten konnten nicht gelöscht werden',
'contextPanel.browser.stop': 'Stoppen',
'contextPanel.browser.openExternal': 'Im Systembrowser öffnen',
'contextPanel.browser.devTools': 'DevTools öffnen',
'contextPanel.browser.deviceToolbar': 'Geräteleiste umschalten',
'contextPanel.browser.device.preset': 'Gerätevorlage',
'contextPanel.browser.device.responsive': 'Responsiv',
'contextPanel.browser.device.width': 'Viewport-Breite',
'contextPanel.browser.device.height': 'Viewport-Höhe',
'contextPanel.browser.device.rotate': 'Drehen',
'contextPanel.browser.device.schemeSystem': 'Auto',
'contextPanel.browser.device.schemeLight': 'Hell',
'contextPanel.browser.device.schemeDark': 'Dunkel',
'contextPanel.browser.device.schemeFailed': 'Das Seitenaussehen konnte nicht geändert werden',
'contextPanel.browser.frameTitle': 'Browser',
'contextPanel.browser.loadFailed': 'Diese Seite konnte nicht geladen werden',
'contextPanel.browser.waitingForServer': 'Warte auf den Dev-Server',
'contextPanel.browser.waitingForServerHint': 'Er nimmt noch keine Verbindungen an. Die Seite lädt, sobald er es tut.',
'contextPanel.browser.tunnelFailed': 'Dieser Dev-Server war nicht erreichbar',
'contextPanel.browser.tunnelFailedHint': '{url} läuft auf der Maschine, auf der OpenChamber läuft, und die Verbindung dorthin ließ sich nicht öffnen. Von deiner eigenen Maschine wird hier nichts angezeigt.',
'contextPanel.browser.loadFailedUnknown': 'Die Seite war nicht erreichbar.',
'contextPanel.browser.crashed': 'Diese Seite reagiert nicht mehr',
'contextPanel.browser.crashedHint': 'Die Seite ist mehrfach abgestürzt. Lade neu, um es erneut zu versuchen.',
'contextPanel.browser.devServers.title': 'Laufende Dev-Server',
'projectActions.toast.multipleServers': 'Mehrere Server gestartet — wähle einen im Browser-Panel',
'contextPanel.browser.devServers.justStarted': 'Gerade gestartet',
'contextPanel.browser.devServers.unavailable': 'Laufende Dev-Server konnten nicht geprüft werden.',
'contextPanel.browser.devServers.remoteOnly': 'Diese laufen auf dem Rechner, der OpenChamber hostet. Zum Öffnen wird die Desktop-App benötigt.',
'contextPanel.browser.annotate.toggle': 'Seite annotieren',
'contextPanel.browser.annotate.intro': 'Dies ist eine annotierte Auswahl aus dem integrierten Browser.',
'contextPanel.browser.annotate.attached': 'Annotation an den Chat angehängt',
'contextPanel.browser.annotate.noSession': 'Öffne eine Chat-Sitzung, bevor du eine Annotation anhängst',
'contextPanel.browser.annotate.noPage': 'Lade eine Seite, bevor du annotierst',
'contextPanel.browser.annotate.failed': 'Diese Seite konnte nicht annotiert werden',
'contextPanel.browser.annotate.tool.element': 'Element',
'contextPanel.browser.annotate.tool.region': 'Bereich',
'contextPanel.browser.annotate.tool.draw': 'Zeichnen',
'contextPanel.browser.annotate.commentPlaceholder': 'Änderung beschreiben...',
'contextPanel.browser.annotate.submit': 'Anhängen',
'contextPanel.browser.trustNotice': 'Seiten, die hier geöffnet werden, laufen mit vollständigem Zugriff auf OpenChamber — erforderlich für Inspect und Screenshots. Öffnen Sie nur Seiten, denen Sie vertrauen: Eine bösartige Seite könnte Ihre Daten lesen oder in Ihrem Namen handeln.',
'contextPanel.tab.closeTabAria': '{label}-Registerkarte schließen',
'contextPanel.actions.collapsePanel': 'Panel einklappen',
@@ -1005,15 +1005,19 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': 'Preventing sleep is not supported on this system',
'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': 'Failed to update keep awake setting',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'OpenChamber Tools',
'settings.openchamber.tools.field.agentControlTool': 'Agent control tool',
'settings.openchamber.tools.field.agentControlToolAria': 'Enable the agent control tool',
'settings.openchamber.tools.field.agentControlToolInfo': 'Let agents orchestrate your work from chat: spin up sessions and worktrees, delegate prompts to other agents, and manage scheduled tasks. Adds a small tool description to each session. Applies after OpenCode restarts.',
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web tool',
'settings.openchamber.tools.field.agentWebToolAria': 'Enable the OpenChamber Web tool',
'settings.openchamber.tools.field.agentWebToolInfo': 'Let agents look at and interact with the page in OpenChamber\'s browser panel: open a URL, read the page, click, type, scroll, and switch between mobile and desktop layouts. Adds a small tool description to each session. Applies after OpenCode restarts.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Optional absolute path to the',
'settings.openchamber.opencodeCli.tooltipSuffix': 'binary.',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode Binary Path',
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'Show OpenCode update notifications',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'Show OpenCode update notifications',
'settings.openchamber.opencodeCli.field.agentControlTool': 'Agent control tool',
'settings.openchamber.opencodeCli.field.agentControlToolAria': 'Enable the agent control tool',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': 'Let agents orchestrate your work from chat: spin up sessions and worktrees, delegate prompts to other agents, and manage scheduled tasks. Adds a small tool description to each session. Applies after Save + Reload.',
'settings.openchamber.opencodeCli.actions.browseAria': 'Browse for OpenCode binary path',
'settings.openchamber.opencodeCli.actions.browse': 'Browse',
'settings.openchamber.opencodeCli.actions.saveAndReload': 'Save + Reload',
+53
View File
@@ -1195,9 +1195,62 @@ export const dict = {
'contextRail.editorTree.toggle': 'Toggle file tree',
'contextPanel.browser.open': 'Open browser panel',
'contextPanel.browser.addressAria': 'Browser address',
'contextPanel.browser.history.label': 'Recent addresses',
'contextPanel.browser.history.forget': 'Remove from history',
'contextPanel.browser.newTab': 'New browser tab',
'contextPanel.browser.empty': 'Web browser',
'contextPanel.browser.emptyHint': 'Enter an address above to start browsing the web',
'contextPanel.browser.inspectUnavailable': 'This page cannot be inspected from the browser panel.',
'contextPanel.browser.back': 'Back',
'contextPanel.browser.forward': 'Forward',
'contextPanel.browser.reload': 'Reload',
'contextPanel.browser.hardReload': 'Reload ignoring cache',
'contextPanel.browser.zoomIn': 'Zoom in',
'contextPanel.browser.zoomOut': 'Zoom out',
'contextPanel.browser.zoomReset': 'Reset zoom',
'contextPanel.browser.clearCookies': 'Clear cookies',
'contextPanel.browser.clearCache': 'Clear cache',
'contextPanel.browser.clearedCookies': 'Cookies cleared',
'contextPanel.browser.clearedCache': 'Cache cleared',
'contextPanel.browser.clearFailed': 'Could not clear browsing data',
'contextPanel.browser.stop': 'Stop',
'contextPanel.browser.openExternal': 'Open in system browser',
'contextPanel.browser.devTools': 'Open DevTools',
'contextPanel.browser.deviceToolbar': 'Toggle device toolbar',
'contextPanel.browser.device.preset': 'Device preset',
'contextPanel.browser.device.responsive': 'Responsive',
'contextPanel.browser.device.width': 'Viewport width',
'contextPanel.browser.device.height': 'Viewport height',
'contextPanel.browser.device.rotate': 'Rotate',
'contextPanel.browser.device.schemeSystem': 'Auto',
'contextPanel.browser.device.schemeLight': 'Light',
'contextPanel.browser.device.schemeDark': 'Dark',
'contextPanel.browser.device.schemeFailed': 'Could not change the page appearance',
'contextPanel.browser.frameTitle': 'Browser',
'contextPanel.browser.loadFailed': 'This page could not be loaded',
'contextPanel.browser.waitingForServer': 'Waiting for the dev server',
'contextPanel.browser.waitingForServerHint': 'It is not accepting connections yet. This page will load as soon as it does.',
'contextPanel.browser.tunnelFailed': 'This dev server could not be reached',
'contextPanel.browser.tunnelFailedHint': '{url} runs on the machine OpenChamber is on, and the connection to it could not be opened. Nothing from your own machine is shown here.',
'contextPanel.browser.loadFailedUnknown': 'The page could not be reached.',
'contextPanel.browser.crashed': 'This page stopped responding',
'contextPanel.browser.crashedHint': 'The page crashed repeatedly. Reload to try again.',
'contextPanel.browser.devServers.title': 'Running dev servers',
'projectActions.toast.multipleServers': 'Several servers started — pick one in the browser panel',
'contextPanel.browser.devServers.justStarted': 'Just started',
'contextPanel.browser.devServers.unavailable': 'Could not check for running dev servers.',
'contextPanel.browser.devServers.remoteOnly': 'These run on the machine hosting OpenChamber. Opening them needs the desktop app.',
'contextPanel.browser.annotate.toggle': 'Annotate page',
'contextPanel.browser.annotate.intro': 'This is an annotated selection from the in-app browser.',
'contextPanel.browser.annotate.attached': 'Annotation attached to chat',
'contextPanel.browser.annotate.noSession': 'Open a chat session before attaching an annotation',
'contextPanel.browser.annotate.noPage': 'Load a page before annotating',
'contextPanel.browser.annotate.failed': 'Could not annotate this page',
'contextPanel.browser.annotate.tool.element': 'Element',
'contextPanel.browser.annotate.tool.region': 'Region',
'contextPanel.browser.annotate.tool.draw': 'Draw',
'contextPanel.browser.annotate.commentPlaceholder': 'Describe the change...',
'contextPanel.browser.annotate.submit': 'Attach',
'contextPanel.browser.trustNotice': 'Pages opened here run with full access to OpenChamber — needed for inspect and screenshots. Only open sites you trust: a malicious page could read your data or act on your behalf.',
'contextPanel.tab.closeTabAria': 'Close {label} tab',
'contextPanel.actions.collapsePanel': 'Collapse panel',
@@ -973,15 +973,19 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.error.keepAwakeUnsupported": "Evitar la suspensión no es compatible con este sistema",
"settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed": "No se pudo actualizar la opción de mantener activo",
"settings.openchamber.opencodeCli.title": "CLI de OpenCode",
"settings.openchamber.tools.title": "Herramientas de OpenChamber",
"settings.openchamber.tools.field.agentControlTool": "Herramienta de control para agentes",
"settings.openchamber.tools.field.agentControlToolAria": "Activar la herramienta de control para agentes",
"settings.openchamber.tools.field.agentControlToolInfo": "Permite que los agentes orquesten tu trabajo desde el chat: crear sesiones y worktrees, delegar prompts a otros agentes y gestionar tareas programadas. Añade una pequeña descripción de herramienta a cada sesión. Se aplica tras reiniciar OpenCode.",
"settings.openchamber.tools.field.agentWebTool": "Herramienta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolAria": "Activar la herramienta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolInfo": "Permite que los agentes vean la página en el panel de navegador de OpenChamber e interactúen con ella: abrir una URL, leer el contenido, hacer clic, escribir, desplazarse y alternar entre diseño móvil y de escritorio. Añade una pequeña descripción de herramienta a cada sesión. Se aplica tras reiniciar OpenCode.",
"settings.openchamber.opencodeCli.tooltipPrefix": "Ruta absoluta opcional al",
"settings.openchamber.opencodeCli.tooltipSuffix": "ejecutable.",
"settings.openchamber.opencodeCli.field.binaryPath": "Ruta del ejecutable de OpenCode",
"settings.openchamber.opencodeCli.field.binaryPathPlaceholder": "/Users/you/.bun/bin/opencode",
"settings.openchamber.opencodeCli.field.showUpdateNotifications": "Mostrar notificaciones de actualización de OpenCode",
"settings.openchamber.opencodeCli.field.showUpdateNotificationsAria": "Mostrar notificaciones de actualización de OpenCode",
"settings.openchamber.opencodeCli.field.agentControlTool": "Herramienta de control para agentes",
"settings.openchamber.opencodeCli.field.agentControlToolAria": "Activar la herramienta de control para agentes",
"settings.openchamber.opencodeCli.field.agentControlToolInfo": "Permite que los agentes orquesten tu trabajo desde el chat: crear sesiones y worktrees, delegar prompts a otros agentes y gestionar tareas programadas. Añade una pequeña descripción de herramienta a cada sesión. Se aplica tras Save + Reload.",
"settings.openchamber.opencodeCli.actions.browseAria": "Buscar ruta del ejecutable de OpenCode",
"settings.openchamber.opencodeCli.actions.browse": "Buscar",
"settings.openchamber.opencodeCli.actions.saveAndReload": "Guardar + Recargar",
+53
View File
@@ -1196,9 +1196,62 @@ export const dict: Record<I18nKey, string> = {
"contextRail.editorTree.toggle": "Alternar árbol de archivos",
"contextPanel.browser.open": "Abrir panel del navegador",
"contextPanel.browser.addressAria": "Dirección del navegador",
"contextPanel.browser.history.label": "Direcciones recientes",
"contextPanel.browser.history.forget": "Quitar del historial",
"contextPanel.browser.newTab": "Nueva pestaña del navegador",
"contextPanel.browser.empty": "Navegador web",
"contextPanel.browser.emptyHint": "Ingrese una dirección arriba para comenzar a navegar",
"contextPanel.browser.inspectUnavailable": "Esta página no se puede inspeccionar desde el panel del navegador.",
"contextPanel.browser.back": "Atrás",
"contextPanel.browser.forward": "Adelante",
"contextPanel.browser.reload": "Recargar",
"contextPanel.browser.hardReload": "Recargar ignorando la caché",
"contextPanel.browser.zoomIn": "Acercar",
"contextPanel.browser.zoomOut": "Alejar",
"contextPanel.browser.zoomReset": "Restablecer zoom",
"contextPanel.browser.clearCookies": "Borrar cookies",
"contextPanel.browser.clearCache": "Borrar caché",
"contextPanel.browser.clearedCookies": "Cookies borradas",
"contextPanel.browser.clearedCache": "Caché borrada",
"contextPanel.browser.clearFailed": "No se pudieron borrar los datos de navegación",
"contextPanel.browser.stop": "Detener",
"contextPanel.browser.openExternal": "Abrir en el navegador del sistema",
"contextPanel.browser.devTools": "Abrir DevTools",
"contextPanel.browser.deviceToolbar": "Barra de dispositivos",
"contextPanel.browser.device.preset": "Preajuste de dispositivo",
"contextPanel.browser.device.responsive": "Adaptable",
"contextPanel.browser.device.width": "Ancho del viewport",
"contextPanel.browser.device.height": "Alto del viewport",
"contextPanel.browser.device.rotate": "Rotar",
"contextPanel.browser.device.schemeSystem": "Auto",
"contextPanel.browser.device.schemeLight": "Claro",
"contextPanel.browser.device.schemeDark": "Oscuro",
"contextPanel.browser.device.schemeFailed": "No se pudo cambiar la apariencia de la página",
"contextPanel.browser.frameTitle": "Navegador",
"contextPanel.browser.loadFailed": "No se pudo cargar esta página",
"contextPanel.browser.waitingForServer": "Esperando al servidor de desarrollo",
"contextPanel.browser.waitingForServerHint": "Todavía no acepta conexiones. La página se cargará en cuanto lo haga.",
"contextPanel.browser.tunnelFailed": "No se pudo alcanzar este servidor de desarrollo",
"contextPanel.browser.tunnelFailedHint": "{url} se ejecuta en la máquina donde está OpenChamber y no se pudo abrir la conexión con ella. Aquí no se muestra nada de tu propia máquina.",
"contextPanel.browser.loadFailedUnknown": "No se pudo acceder a la página.",
"contextPanel.browser.crashed": "Esta página dejó de responder",
"contextPanel.browser.crashedHint": "La página falló varias veces seguidas. Recarga para volver a intentarlo.",
"contextPanel.browser.devServers.title": "Servidores de desarrollo activos",
"projectActions.toast.multipleServers": "Se iniciaron varios servidores: elige uno en el panel del navegador",
"contextPanel.browser.devServers.justStarted": "Recién iniciados",
"contextPanel.browser.devServers.unavailable": "No se pudieron comprobar los servidores de desarrollo activos.",
"contextPanel.browser.devServers.remoteOnly": "Se ejecutan en la máquina que aloja OpenChamber. Abrirlos requiere la aplicación de escritorio.",
"contextPanel.browser.annotate.toggle": "Anotar página",
"contextPanel.browser.annotate.intro": "Esta es una selección anotada del navegador integrado.",
"contextPanel.browser.annotate.attached": "Anotación adjuntada al chat",
"contextPanel.browser.annotate.noSession": "Abre una sesión de chat antes de adjuntar una anotación",
"contextPanel.browser.annotate.noPage": "Carga una página antes de anotar",
"contextPanel.browser.annotate.failed": "No se pudo anotar esta página",
"contextPanel.browser.annotate.tool.element": "Elemento",
"contextPanel.browser.annotate.tool.region": "Región",
"contextPanel.browser.annotate.tool.draw": "Dibujar",
"contextPanel.browser.annotate.commentPlaceholder": "Describe el cambio...",
"contextPanel.browser.annotate.submit": "Adjuntar",
"contextPanel.browser.trustNotice": "Las páginas que abras aquí se ejecutan con acceso completo a OpenChamber: necesario para la inspección y las capturas. Abre solo sitios de confianza: una página maliciosa podría leer tus datos o actuar en tu nombre.",
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
"contextPanel.actions.collapsePanel": "Colapsar panel",
@@ -891,15 +891,19 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': 'La prévention de la veille n\'est pas prise en charge sur ce système',
'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': 'Échec de la mise à jour du paramètre de maintien en éveil',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'Outils OpenChamber',
'settings.openchamber.tools.field.agentControlTool': 'Outil de contrôle pour les agents',
'settings.openchamber.tools.field.agentControlToolAria': 'Activer loutil de contrôle pour les agents',
'settings.openchamber.tools.field.agentControlToolInfo': 'Laissez les agents orchestrer votre travail depuis le chat : créer des sessions et des worktrees, déléguer des prompts à dautres agents et gérer les tâches planifiées. Ajoute une courte description doutil à chaque session. Appliqué après le redémarrage dOpenCode.',
'settings.openchamber.tools.field.agentWebTool': 'Outil OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolAria': 'Activer loutil OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolInfo': 'Laissez les agents consulter la page dans le panneau navigateur dOpenChamber et interagir avec elle : ouvrir une URL, lire le contenu, cliquer, saisir du texte, faire défiler et basculer entre les mises en page mobile et bureau. Ajoute une courte description doutil à chaque session. Appliqué après le redémarrage dOpenCode.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Chemin absolu facultatif vers le',
'settings.openchamber.opencodeCli.tooltipSuffix': 'binaire.',
'settings.openchamber.opencodeCli.field.binaryPath': 'Chemin binaire OpenCode',
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'Afficher les notifications de mise à jour de OpenCode',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'Afficher les notifications de mise à jour de OpenCode',
'settings.openchamber.opencodeCli.field.agentControlTool': 'Outil de contrôle pour les agents',
'settings.openchamber.opencodeCli.field.agentControlToolAria': 'Activer loutil de contrôle pour les agents',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': 'Laissez les agents orchestrer votre travail depuis le chat : créer des sessions et des worktrees, déléguer des prompts à dautres agents et gérer les tâches planifiées. Ajoute une courte description doutil à chaque session. Appliqué après Save + Reload.',
'settings.openchamber.opencodeCli.actions.browseAria': 'Rechercher le chemin binaire OpenCode',
'settings.openchamber.opencodeCli.actions.browse': 'Parcourir',
'settings.openchamber.opencodeCli.actions.saveAndReload': 'Enregistrer + Recharger',
+53
View File
@@ -1015,6 +1015,9 @@ export const dict = {
'contextRail.editorTree.toggle': 'Afficher/masquer larborescence de fichiers',
'contextPanel.browser.open': 'Ouvrir le panneau du navigateur',
'contextPanel.browser.addressAria': 'Adresse du navigateur',
'contextPanel.browser.history.label': 'Adresses récentes',
'contextPanel.browser.history.forget': 'Retirer de lhistorique',
'contextPanel.browser.newTab': 'Nouvel onglet du navigateur',
'contextPanel.browser.empty': 'Navigateur Internet',
'contextPanel.browser.emptyHint': 'Entrez une adresse ci-dessus pour commencer à naviguer sur le Web',
'contextPanel.tab.closeTabAria': 'Fermer l\'onglet {label}',
@@ -2869,6 +2872,56 @@ export const dict = {
'worktree.bootstrap.toast.failedDescription': 'Le worktree a été créé, mais la configuration en arrière-plan ne sest pas terminée.',
'worktree.bootstrap.toast.timeoutDescription': 'Le worktree a été créé, mais la configuration en arrière-plan a expiré.',
'contextPanel.browser.inspectUnavailable': 'Cette page ne peut pas être inspectée depuis le panneau de navigateur.',
'contextPanel.browser.back': 'Retour',
'contextPanel.browser.forward': 'Suivant',
'contextPanel.browser.reload': 'Recharger',
'contextPanel.browser.hardReload': 'Recharger en ignorant le cache',
'contextPanel.browser.zoomIn': 'Zoom avant',
'contextPanel.browser.zoomOut': 'Zoom arrière',
'contextPanel.browser.zoomReset': 'Réinitialiser le zoom',
'contextPanel.browser.clearCookies': 'Effacer les cookies',
'contextPanel.browser.clearCache': 'Vider le cache',
'contextPanel.browser.clearedCookies': 'Cookies effacés',
'contextPanel.browser.clearedCache': 'Cache vidé',
'contextPanel.browser.clearFailed': 'Impossible deffacer les données de navigation',
'contextPanel.browser.stop': 'Arrêter',
'contextPanel.browser.openExternal': 'Ouvrir dans le navigateur système',
'contextPanel.browser.devTools': 'Ouvrir les DevTools',
'contextPanel.browser.deviceToolbar': 'Barre dappareils',
'contextPanel.browser.device.preset': 'Préréglage dappareil',
'contextPanel.browser.device.responsive': 'Adaptatif',
'contextPanel.browser.device.width': 'Largeur du viewport',
'contextPanel.browser.device.height': 'Hauteur du viewport',
'contextPanel.browser.device.rotate': 'Pivoter',
'contextPanel.browser.device.schemeSystem': 'Auto',
'contextPanel.browser.device.schemeLight': 'Clair',
'contextPanel.browser.device.schemeDark': 'Sombre',
'contextPanel.browser.device.schemeFailed': 'Impossible de changer lapparence de la page',
'contextPanel.browser.frameTitle': 'Navigateur',
'contextPanel.browser.loadFailed': 'Impossible de charger cette page',
'contextPanel.browser.waitingForServer': 'En attente du serveur de développement',
'contextPanel.browser.waitingForServerHint': 'Il naccepte pas encore de connexions. La page se chargera dès que ce sera le cas.',
'contextPanel.browser.tunnelFailed': 'Ce serveur de dev na pas pu être atteint',
'contextPanel.browser.tunnelFailedHint': '{url} tourne sur la machine où se trouve OpenChamber, et la connexion vers elle na pas pu être ouverte. Rien de votre propre machine nest affiché ici.',
'contextPanel.browser.loadFailedUnknown': 'La page na pas pu être atteinte.',
'contextPanel.browser.crashed': 'Cette page ne répond plus',
'contextPanel.browser.crashedHint': 'La page a planté plusieurs fois de suite. Rechargez pour réessayer.',
'contextPanel.browser.devServers.title': 'Serveurs de développement actifs',
'projectActions.toast.multipleServers': 'Plusieurs serveurs ont démarré — choisissez-en un dans le panneau navigateur',
'contextPanel.browser.devServers.justStarted': 'Démarrés à linstant',
'contextPanel.browser.devServers.unavailable': 'Impossible de vérifier les serveurs de développement actifs.',
'contextPanel.browser.devServers.remoteOnly': 'Ils tournent sur la machine qui héberge OpenChamber. Les ouvrir nécessite lapplication de bureau.',
'contextPanel.browser.annotate.toggle': 'Annoter la page',
'contextPanel.browser.annotate.intro': 'Ceci est une sélection annotée du navigateur intégré.',
'contextPanel.browser.annotate.attached': 'Annotation jointe à la discussion',
'contextPanel.browser.annotate.noSession': 'Ouvrez une session de discussion avant de joindre une annotation',
'contextPanel.browser.annotate.noPage': 'Chargez une page avant dannoter',
'contextPanel.browser.annotate.failed': 'Impossible dannoter cette page',
'contextPanel.browser.annotate.tool.element': 'Élément',
'contextPanel.browser.annotate.tool.region': 'Zone',
'contextPanel.browser.annotate.tool.draw': 'Dessin',
'contextPanel.browser.annotate.commentPlaceholder': 'Décrivez le changement...',
'contextPanel.browser.annotate.submit': 'Joindre',
'contextPanel.browser.trustNotice': 'Les pages ouvertes ici sexécutent avec un accès complet à OpenChamber — nécessaire pour linspection et les captures d’écran. Nouvrez que des sites de confiance : une page malveillante pourrait lire vos données ou agir en votre nom.',
'filesView.diagram.closeDiagramView': 'Fermer la vue diagramme',
'filesView.diagram.saveDiagram': 'Enregistrer le diagramme',
@@ -1006,15 +1006,19 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': 'このシステムではスリープ防止はサポートされていません',
'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': 'スリープ防止設定の更新に失敗しました',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'OpenChamber ツール',
'settings.openchamber.tools.field.agentControlTool': 'エージェント制御ツール',
'settings.openchamber.tools.field.agentControlToolAria': 'エージェント制御ツールを有効にする',
'settings.openchamber.tools.field.agentControlToolInfo': 'エージェントがチャットから作業をオーケストレーションできるようにします。セッションや worktree の作成、他のエージェントへのプロンプト委任、スケジュールタスクの管理が可能です。各セッションに小さなツール説明が追加されます。OpenCode の再起動後に適用されます。',
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web ツール',
'settings.openchamber.tools.field.agentWebToolAria': 'OpenChamber Web ツールを有効にする',
'settings.openchamber.tools.field.agentWebToolInfo': 'エージェントが OpenChamber のブラウザーパネルでページを確認し操作できるようにします。URL を開く、内容を読む、クリック、入力、スクロール、モバイルとデスクトップのレイアウト切り替えが可能です。各セッションに小さなツール説明が追加されます。OpenCode の再起動後に適用されます。',
'settings.openchamber.opencodeCli.tooltipPrefix': '以下への絶対パス(任意):',
'settings.openchamber.opencodeCli.tooltipSuffix': 'バイナリ。',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode バイナリパス',
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'OpenCode のアップデート通知を表示',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'OpenCode のアップデート通知を表示',
'settings.openchamber.opencodeCli.field.agentControlTool': 'エージェント制御ツール',
'settings.openchamber.opencodeCli.field.agentControlToolAria': 'エージェント制御ツールを有効にする',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': 'エージェントがチャットから作業をオーケストレーションできるようにします。セッションや worktree の作成、他のエージェントへのプロンプト委任、スケジュールタスクの管理が可能です。各セッションに小さなツール説明が追加されます。Save + Reload 後に適用されます。',
'settings.openchamber.opencodeCli.actions.browseAria': 'OpenCode バイナリパスを参照',
'settings.openchamber.opencodeCli.actions.browse': '参照',
'settings.openchamber.opencodeCli.actions.saveAndReload': '保存して再読み込み',
+53
View File
@@ -1192,9 +1192,62 @@ export const dict: Record<I18nKey, string> = {
'contextRail.editorTree.toggle': 'ファイルツリーの表示切替',
'contextPanel.browser.open': 'ブラウザパネルを開く',
'contextPanel.browser.addressAria': 'ブラウザアドレス',
'contextPanel.browser.history.label': '最近のアドレス',
'contextPanel.browser.history.forget': '履歴から削除',
'contextPanel.browser.newTab': '新しいブラウザタブ',
'contextPanel.browser.empty': 'ウェブブラウザ',
'contextPanel.browser.emptyHint': '上のアドレスバーにURLを入力してウェブを閲覧',
'contextPanel.browser.inspectUnavailable': 'このページはブラウザパネルから検査できません。',
'contextPanel.browser.back': '戻る',
'contextPanel.browser.forward': '進む',
'contextPanel.browser.reload': '再読み込み',
'contextPanel.browser.hardReload': 'キャッシュを無視して再読み込み',
'contextPanel.browser.zoomIn': '拡大',
'contextPanel.browser.zoomOut': '縮小',
'contextPanel.browser.zoomReset': 'ズームをリセット',
'contextPanel.browser.clearCookies': 'Cookie を削除',
'contextPanel.browser.clearCache': 'キャッシュを削除',
'contextPanel.browser.clearedCookies': 'Cookie を削除しました',
'contextPanel.browser.clearedCache': 'キャッシュを削除しました',
'contextPanel.browser.clearFailed': '閲覧データを削除できませんでした',
'contextPanel.browser.stop': '停止',
'contextPanel.browser.openExternal': 'システムブラウザで開く',
'contextPanel.browser.devTools': 'DevTools を開く',
'contextPanel.browser.deviceToolbar': 'デバイスツールバー',
'contextPanel.browser.device.preset': 'デバイスプリセット',
'contextPanel.browser.device.responsive': 'レスポンシブ',
'contextPanel.browser.device.width': 'ビューポートの幅',
'contextPanel.browser.device.height': 'ビューポートの高さ',
'contextPanel.browser.device.rotate': '回転',
'contextPanel.browser.device.schemeSystem': '自動',
'contextPanel.browser.device.schemeLight': 'ライト',
'contextPanel.browser.device.schemeDark': 'ダーク',
'contextPanel.browser.device.schemeFailed': 'ページの外観を変更できませんでした',
'contextPanel.browser.frameTitle': 'ブラウザ',
'contextPanel.browser.loadFailed': 'このページを読み込めませんでした',
'contextPanel.browser.waitingForServer': '開発サーバーを待っています',
'contextPanel.browser.waitingForServerHint': 'まだ接続を受け付けていません。受け付け次第このページを読み込みます。',
'contextPanel.browser.tunnelFailed': 'この開発サーバーに接続できませんでした',
'contextPanel.browser.tunnelFailedHint': '{url} は OpenChamber が動いているマシン上にあり、そこへの接続を開けませんでした。あなたのマシンの内容はここには表示されません。',
'contextPanel.browser.loadFailedUnknown': 'ページに到達できませんでした。',
'contextPanel.browser.crashed': 'このページは応答しなくなりました',
'contextPanel.browser.crashedHint': 'ページが繰り返しクラッシュしました。再読み込みして、もう一度お試しください。',
'contextPanel.browser.devServers.title': '実行中の開発サーバー',
'projectActions.toast.multipleServers': '複数のサーバーが起動しました。ブラウザパネルで選んでください',
'contextPanel.browser.devServers.justStarted': '起動したばかり',
'contextPanel.browser.devServers.unavailable': '実行中の開発サーバーを確認できませんでした。',
'contextPanel.browser.devServers.remoteOnly': 'これらは OpenChamber を動かしているマシン上で動作しています。開くにはデスクトップアプリが必要です。',
'contextPanel.browser.annotate.toggle': 'ページに注釈を付ける',
'contextPanel.browser.annotate.intro': 'これはアプリ内ブラウザで注釈を付けた選択範囲です。',
'contextPanel.browser.annotate.attached': '注釈をチャットに添付しました',
'contextPanel.browser.annotate.noSession': '注釈を添付する前にチャットセッションを開いてください',
'contextPanel.browser.annotate.noPage': '注釈を付ける前にページを読み込んでください',
'contextPanel.browser.annotate.failed': 'このページに注釈を付けられませんでした',
'contextPanel.browser.annotate.tool.element': '要素',
'contextPanel.browser.annotate.tool.region': '範囲',
'contextPanel.browser.annotate.tool.draw': '描画',
'contextPanel.browser.annotate.commentPlaceholder': '変更内容を記述...',
'contextPanel.browser.annotate.submit': '添付',
'contextPanel.browser.trustNotice': 'ここで開かれたページはOpenChamberへの完全なアクセス権を持ちます — 検査とスクリーンショットに必要です。信頼できるサイトのみを開いてください: 悪意のあるページがデータを読み取ったりあなたの代わりに行動したりする可能性があります。',
'contextPanel.tab.closeTabAria': '{label}タブを閉じる',
'contextPanel.actions.collapsePanel': 'パネルを折りたたむ',
@@ -973,15 +973,19 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': '이 시스템에서는 절전 방지를 지원하지 않습니다',
'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': '절전 방지 설정을 업데이트하지 못했습니다',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'OpenChamber 도구',
'settings.openchamber.tools.field.agentControlTool': '에이전트 제어 도구',
'settings.openchamber.tools.field.agentControlToolAria': '에이전트 제어 도구 활성화',
'settings.openchamber.tools.field.agentControlToolInfo': '에이전트가 채팅에서 작업을 오케스트레이션할 수 있습니다. 세션과 worktree 생성, 다른 에이전트에게 프롬프트 위임, 예약 작업 관리가 가능합니다. 각 세션에 작은 도구 설명이 추가됩니다. OpenCode를 다시 시작하면 적용됩니다.',
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web 도구',
'settings.openchamber.tools.field.agentWebToolAria': 'OpenChamber Web 도구 활성화',
'settings.openchamber.tools.field.agentWebToolInfo': '에이전트가 OpenChamber 브라우저 패널에서 페이지를 확인하고 조작할 수 있습니다. URL 열기, 내용 읽기, 클릭, 입력, 스크롤, 모바일과 데스크톱 레이아웃 전환이 가능합니다. 각 세션에 작은 도구 설명이 추가됩니다. OpenCode를 다시 시작하면 적용됩니다.',
'settings.openchamber.opencodeCli.tooltipPrefix': '선택적 절대 경로:',
'settings.openchamber.opencodeCli.tooltipSuffix': 'binary.',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode binary 경로',
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'OpenCode 업데이트 알림 표시',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'OpenCode 업데이트 알림 표시',
'settings.openchamber.opencodeCli.field.agentControlTool': '에이전트 제어 도구',
'settings.openchamber.opencodeCli.field.agentControlToolAria': '에이전트 제어 도구 활성화',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': '에이전트가 채팅에서 작업을 오케스트레이션할 수 있습니다. 세션과 worktree 생성, 다른 에이전트에게 프롬프트 위임, 예약 작업 관리가 가능합니다. 각 세션에 작은 도구 설명이 추가됩니다. Save + Reload 후 적용됩니다.',
'settings.openchamber.opencodeCli.actions.browseAria': 'OpenCode binary 경로 찾아보기',
'settings.openchamber.opencodeCli.actions.browse': '찾아보기',
'settings.openchamber.opencodeCli.actions.saveAndReload': '저장 + 다시 로드',
+53
View File
@@ -1196,9 +1196,62 @@ export const dict: Record<I18nKey, string> = {
'contextRail.editorTree.toggle': '파일 트리 표시 전환',
'contextPanel.browser.open': '브라우저 패널 열기',
'contextPanel.browser.addressAria': '브라우저 주소',
'contextPanel.browser.history.label': '최근 주소',
'contextPanel.browser.history.forget': '기록에서 제거',
'contextPanel.browser.newTab': '새 브라우저 탭',
'contextPanel.browser.empty': '웹 브라우저',
'contextPanel.browser.emptyHint': '위에 주소를 입력하여 탐색을 시작하세요',
'contextPanel.browser.inspectUnavailable': '브라우저 패널에서 이 페이지를 검사할 수 없습니다.',
'contextPanel.browser.back': '뒤로',
'contextPanel.browser.forward': '앞으로',
'contextPanel.browser.reload': '새로고침',
'contextPanel.browser.hardReload': '캐시를 무시하고 새로고침',
'contextPanel.browser.zoomIn': '확대',
'contextPanel.browser.zoomOut': '축소',
'contextPanel.browser.zoomReset': '확대/축소 초기화',
'contextPanel.browser.clearCookies': '쿠키 삭제',
'contextPanel.browser.clearCache': '캐시 삭제',
'contextPanel.browser.clearedCookies': '쿠키를 삭제했습니다',
'contextPanel.browser.clearedCache': '캐시를 삭제했습니다',
'contextPanel.browser.clearFailed': '브라우징 데이터를 삭제하지 못했습니다',
'contextPanel.browser.stop': '중지',
'contextPanel.browser.openExternal': '시스템 브라우저에서 열기',
'contextPanel.browser.devTools': 'DevTools 열기',
'contextPanel.browser.deviceToolbar': '디바이스 도구 모음',
'contextPanel.browser.device.preset': '디바이스 프리셋',
'contextPanel.browser.device.responsive': '반응형',
'contextPanel.browser.device.width': '뷰포트 너비',
'contextPanel.browser.device.height': '뷰포트 높이',
'contextPanel.browser.device.rotate': '회전',
'contextPanel.browser.device.schemeSystem': '자동',
'contextPanel.browser.device.schemeLight': '라이트',
'contextPanel.browser.device.schemeDark': '다크',
'contextPanel.browser.device.schemeFailed': '페이지 외관을 바꾸지 못했습니다',
'contextPanel.browser.frameTitle': '브라우저',
'contextPanel.browser.loadFailed': '이 페이지를 불러오지 못했습니다',
'contextPanel.browser.waitingForServer': '개발 서버를 기다리는 중',
'contextPanel.browser.waitingForServerHint': '아직 연결을 받지 않습니다. 준비되는 대로 이 페이지를 불러옵니다.',
'contextPanel.browser.tunnelFailed': '이 개발 서버에 연결하지 못했습니다',
'contextPanel.browser.tunnelFailedHint': '{url} 은(는) OpenChamber가 실행 중인 컴퓨터에 있으며, 그곳으로의 연결을 열지 못했습니다. 이 화면에는 사용자 컴퓨터의 내용이 표시되지 않습니다.',
'contextPanel.browser.loadFailedUnknown': '페이지에 접근할 수 없습니다.',
'contextPanel.browser.crashed': '이 페이지가 응답하지 않습니다',
'contextPanel.browser.crashedHint': '페이지가 반복해서 중단되었습니다. 다시 시도하려면 새로 고치세요.',
'contextPanel.browser.devServers.title': '실행 중인 개발 서버',
'projectActions.toast.multipleServers': '여러 서버가 시작되었습니다. 브라우저 패널에서 선택하세요',
'contextPanel.browser.devServers.justStarted': '방금 시작됨',
'contextPanel.browser.devServers.unavailable': '실행 중인 개발 서버를 확인하지 못했습니다.',
'contextPanel.browser.devServers.remoteOnly': '이 서버들은 OpenChamber가 실행 중인 컴퓨터에 있습니다. 열려면 데스크톱 앱이 필요합니다.',
'contextPanel.browser.annotate.toggle': '페이지에 주석 달기',
'contextPanel.browser.annotate.intro': '앱 내 브라우저에서 주석을 단 선택 영역입니다.',
'contextPanel.browser.annotate.attached': '주석을 채팅에 첨부했습니다',
'contextPanel.browser.annotate.noSession': '주석을 첨부하기 전에 채팅 세션을 여세요',
'contextPanel.browser.annotate.noPage': '주석을 달기 전에 페이지를 불러오세요',
'contextPanel.browser.annotate.failed': '이 페이지에 주석을 달 수 없습니다',
'contextPanel.browser.annotate.tool.element': '요소',
'contextPanel.browser.annotate.tool.region': '영역',
'contextPanel.browser.annotate.tool.draw': '그리기',
'contextPanel.browser.annotate.commentPlaceholder': '변경 사항을 설명하세요...',
'contextPanel.browser.annotate.submit': '첨부',
'contextPanel.browser.trustNotice': '여기서 여는 페이지는 OpenChamber에 대한 전체 액세스 권한으로 실행됩니다 — 검사와 스크린샷에 필요합니다. 신뢰하는 사이트만 여세요: 악성 페이지가 데이터를 읽거나 사용자를 대신해 동작할 수 있습니다.',
'contextPanel.preview.actions.reload': '미리보기 새로고침',
'contextPanel.preview.actions.openExternal': '브라우저에서 열기',
@@ -854,12 +854,16 @@ export const settingsDict = {
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'Pokazuj powiadomienia o aktualizacjach OpenCode',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'Pokazuj powiadomienia o aktualizacjach OpenCode',
'settings.openchamber.opencodeCli.field.agentControlTool': 'Narzędzie sterowania dla agentów',
'settings.openchamber.opencodeCli.field.agentControlToolAria': 'Włącz narzędzie sterowania dla agentów',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': 'Pozwól agentom orkiestrować Twoją pracę z czatu: tworzyć sesje i worktree, delegować prompty innym agentom oraz zarządzać zaplanowanymi zadaniami. Dodaje krótki opis narzędzia do każdej sesji. Zastosowane po Save + Reload.',
'settings.openchamber.opencodeCli.tipMiddle': 'zmienna środowiskowa, ale to ustawienie jest zapisywane w',
'settings.openchamber.opencodeCli.tipPrefix': 'Wskazówka: możesz również użyć',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'Narzędzia OpenChamber',
'settings.openchamber.tools.field.agentControlTool': 'Narzędzie sterowania dla agentów',
'settings.openchamber.tools.field.agentControlToolAria': 'Włącz narzędzie sterowania dla agentów',
'settings.openchamber.tools.field.agentControlToolInfo': 'Pozwól agentom orkiestrować Twoją pracę z czatu: tworzyć sesje i worktree, delegować prompty innym agentom oraz zarządzać zaplanowanymi zadaniami. Dodaje krótki opis narzędzia do każdej sesji. Zastosowane po ponownym uruchomieniu OpenCode.',
'settings.openchamber.tools.field.agentWebTool': 'Narzędzie OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolAria': 'Włącz narzędzie OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolInfo': 'Pozwól agentom oglądać stronę w panelu przeglądarki OpenChamber i wchodzić z nią w interakcję: otwierać adres URL, czytać treść, klikać, pisać, przewijać i przełączać między układem mobilnym a desktopowym. Dodaje krótki opis narzędzia do każdej sesji. Zastosowane po ponownym uruchomieniu OpenCode.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Opcjonalna ścieżka absolutna do',
'settings.openchamber.opencodeCli.tooltipSuffix': 'pliku binarnego.',
'settings.openchamber.passkeys.actions.add': 'Dodaj klucz dostępu (passkey)',
+53
View File
@@ -1510,9 +1510,62 @@ export const dict: Record<I18nKey, string> = {
'contextRail.editorTree.toggle': 'Przełącz drzewo plików',
'contextPanel.browser.open': 'Otwórz panel przeglądarki',
'contextPanel.browser.addressAria': 'Adres przeglądarki',
'contextPanel.browser.history.label': 'Ostatnie adresy',
'contextPanel.browser.history.forget': 'Usuń z historii',
'contextPanel.browser.newTab': 'Nowa karta przeglądarki',
'contextPanel.browser.empty': 'Przeglądarka internetowa',
'contextPanel.browser.emptyHint': 'Wprowadź adres powyżej, aby rozpocząć przeglądanie',
'contextPanel.browser.inspectUnavailable': 'Nie można sprawdzić tej strony z panelu przeglądarki.',
'contextPanel.browser.back': 'Wstecz',
'contextPanel.browser.forward': 'Dalej',
'contextPanel.browser.reload': 'Odśwież',
'contextPanel.browser.hardReload': 'Przeładuj z pominięciem pamięci podręcznej',
'contextPanel.browser.zoomIn': 'Powiększ',
'contextPanel.browser.zoomOut': 'Pomniejsz',
'contextPanel.browser.zoomReset': 'Zresetuj powiększenie',
'contextPanel.browser.clearCookies': 'Wyczyść pliki cookie',
'contextPanel.browser.clearCache': 'Wyczyść pamięć podręczną',
'contextPanel.browser.clearedCookies': 'Pliki cookie wyczyszczone',
'contextPanel.browser.clearedCache': 'Pamięć podręczna wyczyszczona',
'contextPanel.browser.clearFailed': 'Nie udało się wyczyścić danych przeglądania',
'contextPanel.browser.stop': 'Zatrzymaj',
'contextPanel.browser.openExternal': 'Otwórz w przeglądarce systemowej',
'contextPanel.browser.devTools': 'Otwórz DevTools',
'contextPanel.browser.deviceToolbar': 'Pasek urządzeń',
'contextPanel.browser.device.preset': 'Ustawienie urządzenia',
'contextPanel.browser.device.responsive': 'Responsywny',
'contextPanel.browser.device.width': 'Szerokość widoku',
'contextPanel.browser.device.height': 'Wysokość widoku',
'contextPanel.browser.device.rotate': 'Obróć',
'contextPanel.browser.device.schemeSystem': 'Auto',
'contextPanel.browser.device.schemeLight': 'Jasny',
'contextPanel.browser.device.schemeDark': 'Ciemny',
'contextPanel.browser.device.schemeFailed': 'Nie udało się zmienić wyglądu strony',
'contextPanel.browser.frameTitle': 'Przeglądarka',
'contextPanel.browser.loadFailed': 'Nie udało się wczytać tej strony',
'contextPanel.browser.waitingForServer': 'Czekanie na serwer deweloperski',
'contextPanel.browser.waitingForServerHint': 'Jeszcze nie przyjmuje połączeń. Strona wczyta się, gdy tylko zacznie.',
'contextPanel.browser.tunnelFailed': 'Nie udało się połączyć z tym serwerem deweloperskim',
'contextPanel.browser.tunnelFailedHint': '{url} działa na maszynie, na której jest OpenChamber, i nie udało się otworzyć do niej połączenia. Nic z twojej własnej maszyny nie jest tu pokazywane.',
'contextPanel.browser.loadFailedUnknown': 'Nie udało się połączyć ze stroną.',
'contextPanel.browser.crashed': 'Ta strona przestała odpowiadać',
'contextPanel.browser.crashedHint': 'Strona kilkakrotnie uległa awarii. Odśwież, aby spróbować ponownie.',
'contextPanel.browser.devServers.title': 'Działające serwery deweloperskie',
'projectActions.toast.multipleServers': 'Uruchomiło się kilka serwerów — wybierz jeden w panelu przeglądarki',
'contextPanel.browser.devServers.justStarted': 'Właśnie uruchomione',
'contextPanel.browser.devServers.unavailable': 'Nie udało się sprawdzić działających serwerów deweloperskich.',
'contextPanel.browser.devServers.remoteOnly': 'Działają na komputerze, na którym uruchomiony jest OpenChamber. Do ich otwarcia potrzebna jest aplikacja desktopowa.',
'contextPanel.browser.annotate.toggle': 'Dodaj adnotację do strony',
'contextPanel.browser.annotate.intro': 'To jest opatrzony adnotacją fragment z wbudowanej przeglądarki.',
'contextPanel.browser.annotate.attached': 'Adnotacja dołączona do czatu',
'contextPanel.browser.annotate.noSession': 'Otwórz sesję czatu przed dołączeniem adnotacji',
'contextPanel.browser.annotate.noPage': 'Wczytaj stronę przed dodaniem adnotacji',
'contextPanel.browser.annotate.failed': 'Nie udało się dodać adnotacji do tej strony',
'contextPanel.browser.annotate.tool.element': 'Element',
'contextPanel.browser.annotate.tool.region': 'Obszar',
'contextPanel.browser.annotate.tool.draw': 'Rysuj',
'contextPanel.browser.annotate.commentPlaceholder': 'Opisz zmianę...',
'contextPanel.browser.annotate.submit': 'Dołącz',
'contextPanel.browser.trustNotice': 'Strony otwierane tutaj działają z pełnym dostępem do OpenChamber — jest to wymagane do inspekcji i zrzutów ekranu. Otwieraj tylko zaufane witryny: złośliwa strona może odczytać Twoje dane lub działać w Twoim imieniu.',
'contextPanel.preview.actions.openExternal': 'Otwórz w przeglądarce',
'contextPanel.preview.actions.reload': 'Odśwież podgląd',
@@ -973,15 +973,19 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.error.keepAwakeUnsupported": "Impedir suspensão não é compatível com este sistema",
"settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed": "Não foi possível atualizar a opção de manter ativo",
"settings.openchamber.opencodeCli.title": "CLI do OpenCode",
"settings.openchamber.tools.title": "Ferramentas do OpenChamber",
"settings.openchamber.tools.field.agentControlTool": "Ferramenta de controle para agentes",
"settings.openchamber.tools.field.agentControlToolAria": "Ativar a ferramenta de controle para agentes",
"settings.openchamber.tools.field.agentControlToolInfo": "Permita que agentes orquestrem seu trabalho pelo chat: criar sessões e worktrees, delegar prompts a outros agentes e gerenciar tarefas agendadas. Adiciona uma pequena descrição de ferramenta a cada sessão. Aplicado após reiniciar o OpenCode.",
"settings.openchamber.tools.field.agentWebTool": "Ferramenta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolAria": "Ativar a ferramenta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolInfo": "Permita que agentes vejam a página no painel de navegador do OpenChamber e interajam com ela: abrir uma URL, ler o conteúdo, clicar, digitar, rolar e alternar entre layout móvel e desktop. Adiciona uma pequena descrição de ferramenta a cada sessão. Aplicado após reiniciar o OpenCode.",
"settings.openchamber.opencodeCli.tooltipPrefix": "Caminho absoluto opcional para o",
"settings.openchamber.opencodeCli.tooltipSuffix": "executável.",
"settings.openchamber.opencodeCli.field.binaryPath": "Caminho do executável do OpenCode",
"settings.openchamber.opencodeCli.field.binaryPathPlaceholder": "/Users/you/.bun/bin/opencode",
"settings.openchamber.opencodeCli.field.showUpdateNotifications": "Mostrar notificações de atualização do OpenCode",
"settings.openchamber.opencodeCli.field.showUpdateNotificationsAria": "Mostrar notificações de atualização do OpenCode",
"settings.openchamber.opencodeCli.field.agentControlTool": "Ferramenta de controle para agentes",
"settings.openchamber.opencodeCli.field.agentControlToolAria": "Ativar a ferramenta de controle para agentes",
"settings.openchamber.opencodeCli.field.agentControlToolInfo": "Permita que agentes orquestrem seu trabalho pelo chat: criar sessões e worktrees, delegar prompts a outros agentes e gerenciar tarefas agendadas. Adiciona uma pequena descrição de ferramenta a cada sessão. Aplicado após Save + Reload.",
"settings.openchamber.opencodeCli.actions.browseAria": "Buscar caminho do executável do OpenCode",
"settings.openchamber.opencodeCli.actions.browse": "Buscar",
"settings.openchamber.opencodeCli.actions.saveAndReload": "Salvar + Recarregar",
@@ -1196,9 +1196,62 @@ export const dict: Record<I18nKey, string> = {
"contextRail.editorTree.toggle": "Alternar árvore de arquivos",
"contextPanel.browser.open": "Abrir painel do navegador",
"contextPanel.browser.addressAria": "Endereço do navegador",
"contextPanel.browser.history.label": "Endereços recentes",
"contextPanel.browser.history.forget": "Remover do histórico",
"contextPanel.browser.newTab": "Nova aba do navegador",
"contextPanel.browser.empty": "Navegador web",
"contextPanel.browser.emptyHint": "Digite um endereço acima para começar a navegar",
"contextPanel.browser.inspectUnavailable": "Esta página não pode ser inspecionada pelo painel do navegador.",
"contextPanel.browser.back": "Voltar",
"contextPanel.browser.forward": "Avançar",
"contextPanel.browser.reload": "Recarregar",
"contextPanel.browser.hardReload": "Recarregar ignorando o cache",
"contextPanel.browser.zoomIn": "Ampliar",
"contextPanel.browser.zoomOut": "Reduzir",
"contextPanel.browser.zoomReset": "Redefinir zoom",
"contextPanel.browser.clearCookies": "Limpar cookies",
"contextPanel.browser.clearCache": "Limpar cache",
"contextPanel.browser.clearedCookies": "Cookies limpos",
"contextPanel.browser.clearedCache": "Cache limpo",
"contextPanel.browser.clearFailed": "Não foi possível limpar os dados de navegação",
"contextPanel.browser.stop": "Parar",
"contextPanel.browser.openExternal": "Abrir no navegador do sistema",
"contextPanel.browser.devTools": "Abrir DevTools",
"contextPanel.browser.deviceToolbar": "Barra de dispositivos",
"contextPanel.browser.device.preset": "Predefinição de dispositivo",
"contextPanel.browser.device.responsive": "Responsivo",
"contextPanel.browser.device.width": "Largura da viewport",
"contextPanel.browser.device.height": "Altura da viewport",
"contextPanel.browser.device.rotate": "Girar",
"contextPanel.browser.device.schemeSystem": "Auto",
"contextPanel.browser.device.schemeLight": "Claro",
"contextPanel.browser.device.schemeDark": "Escuro",
"contextPanel.browser.device.schemeFailed": "Não foi possível mudar a aparência da página",
"contextPanel.browser.frameTitle": "Navegador",
"contextPanel.browser.loadFailed": "Não foi possível carregar esta página",
"contextPanel.browser.waitingForServer": "Aguardando o servidor de desenvolvimento",
"contextPanel.browser.waitingForServerHint": "Ele ainda não aceita conexões. A página carregará assim que aceitar.",
"contextPanel.browser.tunnelFailed": "Não foi possível alcançar este servidor de desenvolvimento",
"contextPanel.browser.tunnelFailedHint": "{url} roda na máquina onde o OpenChamber está e não foi possível abrir a conexão com ela. Nada da sua própria máquina é mostrado aqui.",
"contextPanel.browser.loadFailedUnknown": "Não foi possível acessar a página.",
"contextPanel.browser.crashed": "Esta página parou de responder",
"contextPanel.browser.crashedHint": "A página falhou várias vezes seguidas. Recarregue para tentar de novo.",
"contextPanel.browser.devServers.title": "Servidores de desenvolvimento em execução",
"projectActions.toast.multipleServers": "Vários servidores iniciaram — escolha um no painel do navegador",
"contextPanel.browser.devServers.justStarted": "Recém-iniciados",
"contextPanel.browser.devServers.unavailable": "Não foi possível verificar os servidores de desenvolvimento em execução.",
"contextPanel.browser.devServers.remoteOnly": "Eles rodam na máquina que hospeda o OpenChamber. Abri-los exige o aplicativo desktop.",
"contextPanel.browser.annotate.toggle": "Anotar página",
"contextPanel.browser.annotate.intro": "Esta é uma seleção anotada do navegador integrado.",
"contextPanel.browser.annotate.attached": "Anotação anexada ao chat",
"contextPanel.browser.annotate.noSession": "Abra uma sessão de chat antes de anexar uma anotação",
"contextPanel.browser.annotate.noPage": "Carregue uma página antes de anotar",
"contextPanel.browser.annotate.failed": "Não foi possível anotar esta página",
"contextPanel.browser.annotate.tool.element": "Elemento",
"contextPanel.browser.annotate.tool.region": "Região",
"contextPanel.browser.annotate.tool.draw": "Desenhar",
"contextPanel.browser.annotate.commentPlaceholder": "Descreva a mudança...",
"contextPanel.browser.annotate.submit": "Anexar",
"contextPanel.browser.trustNotice": "As páginas abertas aqui são executadas com acesso total ao OpenChamber — necessário para inspeção e capturas de tela. Abra apenas sites confiáveis: uma página maliciosa pode ler seus dados ou agir em seu nome.",
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
"contextPanel.actions.collapsePanel": "Recolher painel",
@@ -973,15 +973,19 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.error.keepAwakeUnsupported": "Запобігання сну не підтримується на цій системі",
"settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed": "Не вдалося оновити налаштування утримання комп’ютера активним",
"settings.openchamber.opencodeCli.title": "OpenCode CLI",
"settings.openchamber.tools.title": "Інструменти OpenChamber",
"settings.openchamber.tools.field.agentControlTool": "Інструмент керування для агентів",
"settings.openchamber.tools.field.agentControlToolAria": "Увімкнути інструмент керування для агентів",
"settings.openchamber.tools.field.agentControlToolInfo": "Дозвольте агентам оркеструвати вашу роботу з чату: створювати сесії та worktree, делегувати запити іншим агентам і керувати запланованими задачами. Додає невеликий опис інструмента до кожної сесії. Застосовується після перезапуску OpenCode.",
"settings.openchamber.tools.field.agentWebTool": "Інструмент OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolAria": "Увімкнути інструмент OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolInfo": "Дозвольте агентам переглядати сторінку в панелі браузера OpenChamber і взаємодіяти з нею: відкривати URL, читати вміст, клікати, вводити текст, гортати та перемикатися між мобільним і десктопним виглядом. Додає невеликий опис інструмента до кожної сесії. Застосовується після перезапуску OpenCode.",
"settings.openchamber.opencodeCli.tooltipPrefix": "Додатковий абсолютний шлях до",
"settings.openchamber.opencodeCli.tooltipSuffix": "бінарного файлу.",
"settings.openchamber.opencodeCli.field.binaryPath": "Шлях до бінарного файлу OpenCode",
"settings.openchamber.opencodeCli.field.binaryPathPlaceholder": "/Users/you/.bun/bin/opencode",
"settings.openchamber.opencodeCli.field.showUpdateNotifications": "Показувати сповіщення про оновлення OpenCode",
"settings.openchamber.opencodeCli.field.showUpdateNotificationsAria": "Показувати сповіщення про оновлення OpenCode",
"settings.openchamber.opencodeCli.field.agentControlTool": "Інструмент керування для агентів",
"settings.openchamber.opencodeCli.field.agentControlToolAria": "Увімкнути інструмент керування для агентів",
"settings.openchamber.opencodeCli.field.agentControlToolInfo": "Дозвольте агентам оркеструвати вашу роботу з чату: створювати сесії та worktree, делегувати запити іншим агентам і керувати запланованими задачами. Додає невеликий опис інструмента до кожної сесії. Застосовується після Save + Reload.",
"settings.openchamber.opencodeCli.actions.browseAria": "Вибрати шлях до виконуваного файла OpenCode",
"settings.openchamber.opencodeCli.actions.browse": "Огляд",
"settings.openchamber.opencodeCli.actions.saveAndReload": "Зберегти й перезавантажити",
+53
View File
@@ -1196,9 +1196,62 @@ export const dict: Record<I18nKey, string> = {
"contextRail.editorTree.toggle": "Перемкнути дерево файлів",
"contextPanel.browser.open": "Відкрити панель браузера",
"contextPanel.browser.addressAria": "Адреса браузера",
"contextPanel.browser.history.label": "Нещодавні адреси",
"contextPanel.browser.history.forget": "Прибрати з історії",
"contextPanel.browser.newTab": "Нова вкладка браузера",
"contextPanel.browser.empty": "Веб-браузер",
"contextPanel.browser.emptyHint": "Введіть адресу вище, щоб почати перегляд",
"contextPanel.browser.inspectUnavailable": "Цю сторінку неможливо інспектувати з панелі браузера.",
"contextPanel.browser.back": "Назад",
"contextPanel.browser.forward": "Вперед",
"contextPanel.browser.reload": "Перезавантажити",
"contextPanel.browser.hardReload": "Перезавантажити без кешу",
"contextPanel.browser.zoomIn": "Збільшити",
"contextPanel.browser.zoomOut": "Зменшити",
"contextPanel.browser.zoomReset": "Скинути масштаб",
"contextPanel.browser.clearCookies": "Очистити cookies",
"contextPanel.browser.clearCache": "Очистити кеш",
"contextPanel.browser.clearedCookies": "Cookies очищено",
"contextPanel.browser.clearedCache": "Кеш очищено",
"contextPanel.browser.clearFailed": "Не вдалося очистити дані браузера",
"contextPanel.browser.stop": "Зупинити",
"contextPanel.browser.openExternal": "Відкрити в системному браузері",
"contextPanel.browser.devTools": "Відкрити DevTools",
"contextPanel.browser.deviceToolbar": "Панель пристроїв",
"contextPanel.browser.device.preset": "Пресет пристрою",
"contextPanel.browser.device.responsive": "Адаптивно",
"contextPanel.browser.device.width": "Ширина вьюпорта",
"contextPanel.browser.device.height": "Висота вьюпорта",
"contextPanel.browser.device.rotate": "Повернути",
"contextPanel.browser.device.schemeSystem": "Авто",
"contextPanel.browser.device.schemeLight": "Світла",
"contextPanel.browser.device.schemeDark": "Темна",
"contextPanel.browser.device.schemeFailed": "Не вдалося змінити оформлення сторінки",
"contextPanel.browser.frameTitle": "Браузер",
"contextPanel.browser.loadFailed": "Не вдалося завантажити цю сторінку",
"contextPanel.browser.waitingForServer": "Очікування dev-сервера",
"contextPanel.browser.waitingForServerHint": "Він ще не приймає зʼєднання. Сторінка завантажиться, щойно почне.",
"contextPanel.browser.tunnelFailed": "Не вдалося дістатися цього dev-сервера",
"contextPanel.browser.tunnelFailedHint": "{url} працює на машині, де запущено OpenChamber, і зʼєднання з ним відкрити не вдалося. Нічого зі своєї машини тут не показано.",
"contextPanel.browser.loadFailedUnknown": "Не вдалося звернутися до сторінки.",
"contextPanel.browser.crashed": "Ця сторінка перестала відповідати",
"contextPanel.browser.crashedHint": "Сторінка аварійно завершувалась кілька разів поспіль. Перезавантажте, щоб спробувати ще раз.",
"contextPanel.browser.devServers.title": "Запущені dev-сервери",
"projectActions.toast.multipleServers": "Запустилося кілька серверів — оберіть у панелі браузера",
"contextPanel.browser.devServers.justStarted": "Щойно запущені",
"contextPanel.browser.devServers.unavailable": "Не вдалося перевірити запущені dev-сервери.",
"contextPanel.browser.devServers.remoteOnly": "Вони працюють на машині, де запущено OpenChamber. Щоб їх відкрити, потрібен десктопний застосунок.",
"contextPanel.browser.annotate.toggle": "Анотувати сторінку",
"contextPanel.browser.annotate.intro": "Це анотований фрагмент із вбудованого браузера.",
"contextPanel.browser.annotate.attached": "Анотацію додано до чату",
"contextPanel.browser.annotate.noSession": "Відкрийте сесію чату, перш ніж додавати анотацію",
"contextPanel.browser.annotate.noPage": "Завантажте сторінку, перш ніж анотувати",
"contextPanel.browser.annotate.failed": "Не вдалося анотувати цю сторінку",
"contextPanel.browser.annotate.tool.element": "Елемент",
"contextPanel.browser.annotate.tool.region": "Область",
"contextPanel.browser.annotate.tool.draw": "Малювання",
"contextPanel.browser.annotate.commentPlaceholder": "Опишіть зміну...",
"contextPanel.browser.annotate.submit": "Додати",
"contextPanel.browser.trustNotice": "Сторінки, відкриті тут, працюють із повним доступом до OpenChamber — це потрібно для inspect і скріншотів. Відкривайте лише сайти, яким довіряєте: шкідлива сторінка може прочитати ваші дані чи діяти від вашого імені.",
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
"contextPanel.actions.collapsePanel": "Згорнути панель",
@@ -973,15 +973,19 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': '此系统不支持防止睡眠',
'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': '更新保持唤醒设置失败',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'OpenChamber 工具',
'settings.openchamber.tools.field.agentControlTool': '智能体控制工具',
'settings.openchamber.tools.field.agentControlToolAria': '启用智能体控制工具',
'settings.openchamber.tools.field.agentControlToolInfo': '让智能体在聊天中编排你的工作:创建会话和 worktree、将提示委派给其他智能体、管理计划任务。会为每个会话添加少量工具说明。在 OpenCode 重启后生效。',
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolAria': '启用 OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolInfo': '让智能体在 OpenChamber 浏览器面板中查看并操作页面:打开网址、读取内容、点击、输入、滚动,以及在移动端与桌面端布局之间切换。会为每个会话添加少量工具说明。在 OpenCode 重启后生效。',
'settings.openchamber.opencodeCli.tooltipPrefix': '可选的',
'settings.openchamber.opencodeCli.tooltipSuffix': '二进制绝对路径。',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode 可执行文件路径',
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': '显示 OpenCode 更新通知',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': '显示 OpenCode 更新通知',
'settings.openchamber.opencodeCli.field.agentControlTool': '智能体控制工具',
'settings.openchamber.opencodeCli.field.agentControlToolAria': '启用智能体控制工具',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': '让智能体在聊天中编排你的工作:创建会话和 worktree、将提示委派给其他智能体、管理计划任务。会为每个会话添加少量工具说明。在 Save + Reload 后生效。',
'settings.openchamber.opencodeCli.actions.browseAria': '浏览 OpenCode 可执行文件路径',
'settings.openchamber.opencodeCli.actions.browse': '浏览',
'settings.openchamber.opencodeCli.actions.saveAndReload': '保存并重载',
@@ -1196,9 +1196,62 @@ export const dict: Record<I18nKey, string> = {
'contextRail.editorTree.toggle': '切换文件树',
'contextPanel.browser.open': '打开浏览器面板',
'contextPanel.browser.addressAria': '浏览器地址',
'contextPanel.browser.history.label': '最近访问的地址',
'contextPanel.browser.history.forget': '从历史记录中移除',
'contextPanel.browser.newTab': '新建浏览器标签页',
'contextPanel.browser.empty': '网页浏览器',
'contextPanel.browser.emptyHint': '在上方输入网址开始浏览',
'contextPanel.browser.inspectUnavailable': '无法从浏览器面板检查此页面。',
'contextPanel.browser.back': '后退',
'contextPanel.browser.forward': '前进',
'contextPanel.browser.reload': '重新加载',
'contextPanel.browser.hardReload': '忽略缓存重新加载',
'contextPanel.browser.zoomIn': '放大',
'contextPanel.browser.zoomOut': '缩小',
'contextPanel.browser.zoomReset': '重置缩放',
'contextPanel.browser.clearCookies': '清除 Cookie',
'contextPanel.browser.clearCache': '清除缓存',
'contextPanel.browser.clearedCookies': '已清除 Cookie',
'contextPanel.browser.clearedCache': '已清除缓存',
'contextPanel.browser.clearFailed': '无法清除浏览数据',
'contextPanel.browser.stop': '停止',
'contextPanel.browser.openExternal': '在系统浏览器中打开',
'contextPanel.browser.devTools': '打开开发者工具',
'contextPanel.browser.deviceToolbar': '设备工具栏',
'contextPanel.browser.device.preset': '设备预设',
'contextPanel.browser.device.responsive': '自适应',
'contextPanel.browser.device.width': '视口宽度',
'contextPanel.browser.device.height': '视口高度',
'contextPanel.browser.device.rotate': '旋转',
'contextPanel.browser.device.schemeSystem': '自动',
'contextPanel.browser.device.schemeLight': '浅色',
'contextPanel.browser.device.schemeDark': '深色',
'contextPanel.browser.device.schemeFailed': '无法更改页面外观',
'contextPanel.browser.frameTitle': '浏览器',
'contextPanel.browser.loadFailed': '无法加载此页面',
'contextPanel.browser.waitingForServer': '正在等待开发服务器',
'contextPanel.browser.waitingForServerHint': '它还没有开始接受连接。一旦可用,本页就会加载。',
'contextPanel.browser.tunnelFailed': '无法连接到这个开发服务器',
'contextPanel.browser.tunnelFailedHint': '{url} 运行在 OpenChamber 所在的机器上,无法与其建立连接。这里不会显示你本机上的任何内容。',
'contextPanel.browser.loadFailedUnknown': '无法访问该页面。',
'contextPanel.browser.crashed': '此页面已停止响应',
'contextPanel.browser.crashedHint': '页面反复崩溃。请重新加载后再试。',
'contextPanel.browser.devServers.title': '正在运行的开发服务器',
'projectActions.toast.multipleServers': '启动了多个服务器 — 请在浏览器面板中选择',
'contextPanel.browser.devServers.justStarted': '刚刚启动',
'contextPanel.browser.devServers.unavailable': '无法检查正在运行的开发服务器。',
'contextPanel.browser.devServers.remoteOnly': '它们运行在托管 OpenChamber 的那台机器上。要打开它们需要桌面应用。',
'contextPanel.browser.annotate.toggle': '标注页面',
'contextPanel.browser.annotate.intro': '这是来自应用内浏览器的标注选区。',
'contextPanel.browser.annotate.attached': '标注已附加到聊天',
'contextPanel.browser.annotate.noSession': '附加标注前请先打开聊天会话',
'contextPanel.browser.annotate.noPage': '标注前请先加载页面',
'contextPanel.browser.annotate.failed': '无法标注此页面',
'contextPanel.browser.annotate.tool.element': '元素',
'contextPanel.browser.annotate.tool.region': '区域',
'contextPanel.browser.annotate.tool.draw': '绘制',
'contextPanel.browser.annotate.commentPlaceholder': '描述所需更改...',
'contextPanel.browser.annotate.submit': '附加',
'contextPanel.browser.trustNotice': '在此打开的页面以对 OpenChamber 的完全访问权限运行 — 检查和截图需要此权限。仅打开你信任的站点:恶意页面可能读取你的数据或以你的身份执行操作。',
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
'contextPanel.actions.collapsePanel': '折叠面板',
@@ -947,15 +947,19 @@
'settings.openchamber.desktopNetwork.error.saveFailed': '儲存桌面設定失敗',
'settings.openchamber.desktopNetwork.error.savedRestartFailed': '已儲存,但重新啟動應用程式失敗',
'settings.openchamber.opencodeCli.title': 'OpenCode CLI',
'settings.openchamber.tools.title': 'OpenChamber 工具',
'settings.openchamber.tools.field.agentControlTool': '智慧代理控制工具',
'settings.openchamber.tools.field.agentControlToolAria': '啟用智慧代理控制工具',
'settings.openchamber.tools.field.agentControlToolInfo': '讓代理從聊天中協調你的工作:建立工作階段與 worktree、將提示委派給其他代理、管理排程任務。會為每個工作階段加入少量工具說明。在 OpenCode 重新啟動後生效。',
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolAria': '啟用 OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolInfo': '讓代理在 OpenChamber 瀏覽器面板中檢視並操作頁面:開啟網址、讀取內容、點擊、輸入、捲動,以及在行動版與桌面版版面之間切換。會為每個工作階段加入少量工具說明。在 OpenCode 重新啟動後生效。',
'settings.openchamber.opencodeCli.tooltipPrefix': '可選的',
'settings.openchamber.opencodeCli.tooltipSuffix': '二進位檔絕對路徑。',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode 可執行檔路徑',
'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode',
'settings.openchamber.opencodeCli.field.showUpdateNotifications': '顯示 OpenCode 更新通知',
'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': '顯示 OpenCode 更新通知',
'settings.openchamber.opencodeCli.field.agentControlTool': '智慧代理控制工具',
'settings.openchamber.opencodeCli.field.agentControlToolAria': '啟用智慧代理控制工具',
'settings.openchamber.opencodeCli.field.agentControlToolInfo': '讓代理從聊天中協調你的工作:建立工作階段與 worktree、將提示委派給其他代理、管理排程任務。會為每個工作階段加入少量工具說明。在 Save + Reload 後生效。',
'settings.openchamber.opencodeCli.actions.browseAria': '瀏覽 OpenCode 可執行檔路徑',
'settings.openchamber.opencodeCli.actions.browse': '瀏覽',
'settings.openchamber.opencodeCli.actions.saveAndReload': '儲存並重新載入',
@@ -1208,9 +1208,62 @@ export const dict: Record<I18nKey, string> = {
'contextRail.editorTree.toggle': '切換檔案樹',
'contextPanel.browser.open': '開啟瀏覽器面板',
'contextPanel.browser.addressAria': '瀏覽器網址',
'contextPanel.browser.history.label': '最近造訪的網址',
'contextPanel.browser.history.forget': '從歷史紀錄中移除',
'contextPanel.browser.newTab': '新增瀏覽器分頁',
'contextPanel.browser.empty': '網頁瀏覽器',
'contextPanel.browser.emptyHint': '在上方輸入網址開始瀏覽',
'contextPanel.browser.inspectUnavailable': '無法從瀏覽器面板檢查此頁面。',
'contextPanel.browser.back': '上一頁',
'contextPanel.browser.forward': '下一頁',
'contextPanel.browser.reload': '重新載入',
'contextPanel.browser.hardReload': '忽略快取重新載入',
'contextPanel.browser.zoomIn': '放大',
'contextPanel.browser.zoomOut': '縮小',
'contextPanel.browser.zoomReset': '重設縮放',
'contextPanel.browser.clearCookies': '清除 Cookie',
'contextPanel.browser.clearCache': '清除快取',
'contextPanel.browser.clearedCookies': '已清除 Cookie',
'contextPanel.browser.clearedCache': '已清除快取',
'contextPanel.browser.clearFailed': '無法清除瀏覽資料',
'contextPanel.browser.stop': '停止',
'contextPanel.browser.openExternal': '在系統瀏覽器中開啟',
'contextPanel.browser.devTools': '開啟開發者工具',
'contextPanel.browser.deviceToolbar': '裝置工具列',
'contextPanel.browser.device.preset': '裝置預設',
'contextPanel.browser.device.responsive': '自適應',
'contextPanel.browser.device.width': '視口寬度',
'contextPanel.browser.device.height': '視口高度',
'contextPanel.browser.device.rotate': '旋轉',
'contextPanel.browser.device.schemeSystem': '自動',
'contextPanel.browser.device.schemeLight': '淺色',
'contextPanel.browser.device.schemeDark': '深色',
'contextPanel.browser.device.schemeFailed': '無法變更頁面外觀',
'contextPanel.browser.frameTitle': '瀏覽器',
'contextPanel.browser.loadFailed': '無法載入此頁面',
'contextPanel.browser.waitingForServer': '正在等待開發伺服器',
'contextPanel.browser.waitingForServerHint': '它尚未開始接受連線。一旦可用,本頁就會載入。',
'contextPanel.browser.tunnelFailed': '無法連線到這個開發伺服器',
'contextPanel.browser.tunnelFailedHint': '{url} 執行在 OpenChamber 所在的機器上,無法與其建立連線。這裡不會顯示你本機上的任何內容。',
'contextPanel.browser.loadFailedUnknown': '無法連線到該頁面。',
'contextPanel.browser.crashed': '此頁面已停止回應',
'contextPanel.browser.crashedHint': '頁面反覆當機。請重新載入後再試。',
'contextPanel.browser.devServers.title': '執行中的開發伺服器',
'projectActions.toast.multipleServers': '啟動了多個伺服器 — 請在瀏覽器面板中選擇',
'contextPanel.browser.devServers.justStarted': '剛剛啟動',
'contextPanel.browser.devServers.unavailable': '無法檢查執行中的開發伺服器。',
'contextPanel.browser.devServers.remoteOnly': '它們執行在託管 OpenChamber 的那台機器上。要開啟它們需要桌面應用程式。',
'contextPanel.browser.annotate.toggle': '標註頁面',
'contextPanel.browser.annotate.intro': '這是來自應用程式內建瀏覽器的標註選取範圍。',
'contextPanel.browser.annotate.attached': '標註已附加至聊天',
'contextPanel.browser.annotate.noSession': '附加標註前請先開啟聊天工作階段',
'contextPanel.browser.annotate.noPage': '標註前請先載入頁面',
'contextPanel.browser.annotate.failed': '無法標註此頁面',
'contextPanel.browser.annotate.tool.element': '元素',
'contextPanel.browser.annotate.tool.region': '區域',
'contextPanel.browser.annotate.tool.draw': '繪製',
'contextPanel.browser.annotate.commentPlaceholder': '描述所需變更...',
'contextPanel.browser.annotate.submit': '附加',
'contextPanel.browser.trustNotice': '在此開啟的頁面以對 OpenChamber 的完整存取權限執行 — 檢查與截圖需要此權限。僅開啟你信任的網站:惡意頁面可能讀取你的資料或以你的身分執行操作。',
'contextPanel.tab.closeTabAria': '關閉 {label} 分頁',
'contextPanel.actions.collapsePanel': '摺疊面板',
+44 -2
View File
@@ -20,7 +20,18 @@ type SessionCreatedEvent = {
dispatchedAsCommand: boolean;
};
type OpenChamberEvent = ScheduledTaskRanEvent | SessionCreatedEvent;
/**
* One in-app browser action requested by the agent tool. Broadcast to every
* connected client; only the one owning a browser view answers.
*/
type BrowserControlRequestEvent = {
type: 'browser-control-request';
requestId: string;
action: string;
parameters: Record<string, unknown>;
};
type OpenChamberEvent = ScheduledTaskRanEvent | SessionCreatedEvent | BrowserControlRequestEvent;
type Listener = (event: OpenChamberEvent) => void;
let eventSource: EventSource | null = null;
@@ -132,6 +143,29 @@ const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) =
return;
}
if (envelope.type === 'openchamber:browser-control-request') {
const properties = getEventProperties(envelope.properties);
const requestId = typeof properties?.requestId === 'string' ? properties.requestId : '';
const action = typeof properties?.action === 'string' ? properties.action : '';
if (!requestId || !action) {
return;
}
const rawParameters = properties?.parameters;
const nextEvent: BrowserControlRequestEvent = {
type: 'browser-control-request',
requestId,
action,
parameters: rawParameters && typeof rawParameters === 'object' && !Array.isArray(rawParameters)
? rawParameters as Record<string, unknown>
: {},
};
for (const listener of listeners) {
listener(nextEvent);
}
return;
}
if (envelope.type !== 'openchamber:scheduled-task-ran') {
return;
}
@@ -175,7 +209,15 @@ const connect = () => {
cleanupSource();
const source = new EventSource(getRuntimeUrlResolver().sse('/api/openchamber/events'));
// Tell the server what this client can do while the connection lasts. Only a
// Chromium host can drive a page; a browser tab can display one but not be
// driven, and the agent tool needs to know which it is talking to without a
// setting anyone has to remember to change.
const canControlBrowser = typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__);
const source = new EventSource(getRuntimeUrlResolver().sse(
'/api/openchamber/events',
canControlBrowser ? { browser: '1' } : undefined,
));
source.onopen = () => {
resetHeartbeatTimer();
};
+10
View File
@@ -553,6 +553,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
agentControlToolEnabled: defaults.agentControlToolEnabled,
agentWebToolEnabled: defaults.agentWebToolEnabled,
showToolFileIcons: defaults.showToolFileIcons,
codeBlockLineWrap: defaults.codeBlockLineWrap,
showTurnChangedFiles: defaults.showTurnChangedFiles,
@@ -729,6 +730,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
) {
store.setAgentControlToolEnabled(settings.agentControlToolEnabled);
}
if (
typeof settings.agentWebToolEnabled === 'boolean'
&& settings.agentWebToolEnabled !== store.agentWebToolEnabled
) {
store.setAgentWebToolEnabled(settings.agentWebToolEnabled);
}
if (typeof settings.showToolFileIcons === 'boolean' && settings.showToolFileIcons !== store.showToolFileIcons) {
store.setShowToolFileIcons(settings.showToolFileIcons);
}
@@ -1375,6 +1382,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.agentControlToolEnabled === 'boolean') {
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
}
if (typeof candidate.agentWebToolEnabled === 'boolean') {
result.agentWebToolEnabled = candidate.agentWebToolEnabled;
}
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
result.openCodeUpdateToastDismissedVersion = candidate.openCodeUpdateToastDismissedVersion.trim().slice(0, 128);
}
@@ -1,28 +0,0 @@
import { describe, expect, test } from 'bun:test';
import {
getPreviewTargetErrorCode,
getPreviewTargetRecoveryAction,
PREVIEW_TARGET_ERROR_HEADER,
} from './proxy-response';
describe('preview proxy response classification', () => {
test('recognizes proxy-owned target failures', () => {
for (const code of ['missing', 'expired', 'invalid-token'] as const) {
const headers = new Headers({ [PREVIEW_TARGET_ERROR_HEADER]: code });
expect(getPreviewTargetErrorCode(headers)).toBe(code);
}
});
test('does not classify ordinary upstream responses as target failures', () => {
expect(getPreviewTargetErrorCode(new Headers())).toBeNull();
expect(getPreviewTargetErrorCode(new Headers({ [PREVIEW_TARGET_ERROR_HEADER]: 'unknown' }))).toBeNull();
expect(getPreviewTargetRecoveryAction(new Headers(), false)).toBe('none');
});
test('bounds automatic target recovery to one registration retry', () => {
const headers = new Headers({ [PREVIEW_TARGET_ERROR_HEADER]: 'expired' });
expect(getPreviewTargetRecoveryAction(headers, false)).toBe('retry-registration');
expect(getPreviewTargetRecoveryAction(headers, true)).toBe('stop-retrying');
});
});
@@ -1,18 +0,0 @@
export const PREVIEW_TARGET_ERROR_HEADER = 'x-openchamber-preview-target-error';
type PreviewTargetErrorCode = 'missing' | 'expired' | 'invalid-token';
export const getPreviewTargetErrorCode = (headers: Pick<Headers, 'get'>): PreviewTargetErrorCode | null => {
const value = headers.get(PREVIEW_TARGET_ERROR_HEADER);
return value === 'missing' || value === 'expired' || value === 'invalid-token'
? value
: null;
};
export const getPreviewTargetRecoveryAction = (
headers: Pick<Headers, 'get'>,
recoveryAttempted: boolean,
): 'none' | 'retry-registration' | 'stop-retrying' => {
if (!getPreviewTargetErrorCode(headers)) return 'none';
return recoveryAttempted ? 'stop-retrying' : 'retry-registration';
};
@@ -1,964 +0,0 @@
import { invokeDesktop } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
export type PreviewElementMetadata = {
frame: 'top';
tag: string;
text: string;
selector: string;
path: string;
bounds: { x: number; y: number; width: number; height: number };
center: { x: number; y: number };
attributes: Record<string, string>;
computedStyle: Record<string, string>;
ancestry: Array<{ tag: string; id?: string; className?: string; selectorPart: string }>;
};
const isXYRecord = (value: unknown): value is { x: number; y: number } => {
if (!value || typeof value !== 'object') return false;
const record = value as { x?: unknown; y?: unknown };
return typeof record.x === 'number' && typeof record.y === 'number';
};
const isStringRecord = (value: unknown): value is Record<string, string> => {
if (!value || typeof value !== 'object') return false;
return Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'string');
};
// Bridge messages arrive via postMessage from a (possibly untrusted) proxied page,
// so validate the full shape every downstream consumer touches — not just bounds.
// formatPreviewAnnotationMarkdown dereferences text/attributes/center/computedStyle/
// ancestry, so a partially-valid payload would otherwise throw at format time.
export const isPreviewElementMetadata = (value: unknown): value is PreviewElementMetadata => {
if (!value || typeof value !== 'object') return false;
const record = value as Partial<PreviewElementMetadata>;
const bounds = record.bounds;
return typeof record.tag === 'string'
&& typeof record.text === 'string'
&& typeof record.selector === 'string'
&& typeof record.path === 'string'
&& Boolean(bounds)
&& typeof bounds?.x === 'number'
&& typeof bounds?.y === 'number'
&& typeof bounds?.width === 'number'
&& typeof bounds?.height === 'number'
&& isXYRecord(record.center)
&& isStringRecord(record.attributes)
&& isStringRecord(record.computedStyle)
&& Array.isArray(record.ancestry);
};
export const formatPreviewAnnotationMarkdown = ({
pageUrl,
viewport,
devicePixelRatio,
target,
screenshotAttached,
intro,
}: {
pageUrl: string;
viewport: { width: number; height: number };
devicePixelRatio: number;
target: PreviewElementMetadata;
screenshotAttached: boolean;
intro: string;
}): string => {
const text = target.text.trim();
const attributes = Object.entries(target.attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
const styles = target.computedStyle;
const bounds = target.bounds;
const center = target.center;
const introLabel = intro.replace(/[.:]+$/g, '');
const ancestry = target.ancestry
.map((entry) => entry.selectorPart)
.join(' > ');
return [
`${introLabel}:`,
`Page: ${pageUrl || 'preview'}`,
`Viewport: ${viewport.width}x${viewport.height}, DPR ${devicePixelRatio}`,
`Screenshot: ${screenshotAttached ? 'attached' : 'not attached'}`,
`Element: ${target.tag}`,
text ? `Text: ${text}` : null,
`- Selector: ${target.selector}`,
`- Path: ${target.path}`,
ancestry ? `- Ancestry: ${ancestry}` : null,
attributes ? `- Attributes: ${attributes}` : null,
`- Bounds: x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`,
`- Center: x=${Math.round(center.x)}, y=${Math.round(center.y)}`,
`Styles: display=${styles.display}; position=${styles.position}; font=${styles.fontWeight} ${styles.fontSize} / ${styles.lineHeight} ${styles.fontFamily}; color=${styles.color}; background=${styles.backgroundColor}; z-index=${styles.zIndex}`,
].filter((line): line is string => typeof line === 'string').join('\n');
};
export const renderPreviewScreenshot = async (
iframe: HTMLIFrameElement,
target: PreviewElementMetadata,
): Promise<File | null> => {
if (typeof window !== 'undefined') {
try {
const rect = iframe.getBoundingClientRect();
const capture = await invokeDesktop<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
});
if (!capture) throw new Error('Desktop screenshot capture is not available');
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error('Failed to load desktop preview screenshot'));
image.src = `data:${capture.mime};base64,${capture.base64}`;
});
const width = Math.max(1, image.naturalWidth || capture.width || Math.floor(rect.width));
const height = Math.max(1, image.naturalHeight || capture.height || Math.floor(rect.height));
const maxOutputWidth = 1200;
const outputScale = Math.min(1, maxOutputWidth / width);
const canvas = document.createElement('canvas');
canvas.width = Math.floor(width * outputScale);
canvas.height = Math.floor(height * outputScale);
const context = canvas.getContext('2d');
if (!context) return null;
context.scale(outputScale, outputScale);
context.drawImage(image, 0, 0, width, height);
const xScale = width / Math.max(1, rect.width);
const yScale = height / Math.max(1, rect.height);
context.fillStyle = 'rgba(37, 99, 235, 0.28)';
context.strokeStyle = 'rgb(37, 99, 235)';
context.lineWidth = Math.max(2, 2 * xScale);
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
if (!blob) return null;
return new File([blob], `preview-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
} catch (error) {
console.warn('[preview] failed to capture annotation screenshot:', error);
return null;
}
}
return await captureIframeDomScreenshot(iframe, target);
};
export const desktopAnnotationToFile = async (
base64: string,
screenshotWidth: number,
screenshotHeight: number,
cssWidth: number,
cssHeight: number,
target: PreviewElementMetadata,
): Promise<File | null> => {
if (!base64) return null;
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error('Failed to load desktop browser screenshot'));
image.src = `data:image/jpeg;base64,${base64}`;
});
const width = Math.max(1, image.naturalWidth || screenshotWidth);
const height = Math.max(1, image.naturalHeight || screenshotHeight);
const maxOutputWidth = 1200;
const outputScale = Math.min(1, maxOutputWidth / width);
const canvas = document.createElement('canvas');
canvas.width = Math.floor(width * outputScale);
canvas.height = Math.floor(height * outputScale);
const context = canvas.getContext('2d');
if (!context) return null;
context.scale(outputScale, outputScale);
context.drawImage(image, 0, 0, width, height);
const xScale = width / Math.max(1, cssWidth || width);
const yScale = height / Math.max(1, cssHeight || height);
context.fillStyle = 'rgba(37, 99, 235, 0.14)';
context.strokeStyle = 'rgb(37, 99, 235)';
context.lineWidth = Math.max(2, 2 * xScale);
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
if (!blob) return null;
return new File([blob], `browser-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
} catch {
return null;
}
};
const TRANSPARENT_IMAGE_PLACEHOLDER = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
// Module-scoped, in-memory cache of registered proxy targets keyed by the
// fully-qualified upstream URL. Survives PreviewPane unmount/remount and tab
// switches, but intentionally does NOT survive a full page reload: the server
// holds the target map in memory and the auth cookie is HttpOnly + scoped to
// the proxy id, so a stale persisted entry would 404 after a server restart.
// Entries are evicted on registration error or when the preview proxy marks a
// target as missing, expired, or unauthorized. Upstream 4xx responses are not
// cache failures.
export type CachedProxyTarget = { proxyBasePath: string; previewToken?: string; expiresAt: number };
export const previewProxyTargetCache = new Map<string, CachedProxyTarget>();
const previewProxyTargetRequests = new Map<string, Promise<CachedProxyTarget | null>>();
const PREVIEW_PROXY_CACHE_SAFETY_MS = 30_000;
export const getCachedProxyTarget = (url: string): CachedProxyTarget | null => {
const entry = previewProxyTargetCache.get(url);
if (!entry) return null;
if (entry.expiresAt - Date.now() <= PREVIEW_PROXY_CACHE_SAFETY_MS) {
previewProxyTargetCache.delete(url);
return null;
}
return entry;
};
export const getBrowserProxyTargetKey = (url: string): string => {
try {
return new URL(url).origin;
} catch {
return url;
}
};
function getCaptureBackgroundColor(document: Document): string {
const fallback = '#ffffff';
const view = document.defaultView ?? window;
try {
const bodyColor = document.body ? view.getComputedStyle(document.body).backgroundColor : '';
if (bodyColor && bodyColor !== 'rgba(0, 0, 0, 0)' && bodyColor !== 'transparent') return bodyColor;
const rootColor = view.getComputedStyle(document.documentElement).backgroundColor;
if (rootColor && rootColor !== 'rgba(0, 0, 0, 0)' && rootColor !== 'transparent') return rootColor;
} catch {
// Ignore style access failures and use a stable background.
}
return fallback;
}
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : TRANSPARENT_IMAGE_PLACEHOLDER);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read image blob'));
reader.readAsDataURL(blob);
});
const canvasToJpegBase64 = async (canvas: HTMLCanvasElement, quality = 0.82): Promise<string> => {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
if (!blob) return '';
return (await blobToDataUrl(blob)).split(',', 2)[1] || '';
};
const isPreviewCaptureDebugEnabled = (): boolean => {
try {
return Boolean((window as unknown as { __previewCaptureDebug?: boolean }).__previewCaptureDebug);
} catch {
return false;
}
};
const previewCaptureDebug = (...args: unknown[]): void => {
if (!isPreviewCaptureDebugEnabled()) return;
console.info('[preview-capture]', ...args);
};
type ScrolledElementInfo = {
selector: string;
scrollTop: number;
scrollLeft: number;
clientWidth: number;
clientHeight: number;
scrollWidth: number;
scrollHeight: number;
};
const describeScrolledElements = (doc: Document, limit = 8): ScrolledElementInfo[] => {
const found: ScrolledElementInfo[] = [];
try {
const all = doc.querySelectorAll<HTMLElement>('*');
for (const el of all) {
const scrollTop = el.scrollTop || 0;
const scrollLeft = el.scrollLeft || 0;
if (scrollTop <= 0 && scrollLeft <= 0) continue;
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : '';
const cls = typeof el.className === 'string' && el.className
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
: '';
found.push({
selector: `${tag}${id}${cls}`,
scrollTop,
scrollLeft,
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
});
if (found.length >= limit) break;
}
} catch { /* best-effort diagnostics */ }
return found;
};
const FIXED_PIN_ATTR = 'data-oc-fixed-pin';
// snapDOM repositions `position: sticky` (freezeSticky) but leaves `position: fixed`
// alone. In the full-document SVG foreignObject a fixed element resolves against the
// document box, not the viewport — so `top`/`bottom` anchors and sizes are wrong
// (e.g. a `top:nav; bottom:0` sidebar stretches to the full doc height) and cropping
// shifts/clips it. We can't fix this by mutating the LIVE element: changing its
// position/height resets the scrollTop of any overflow container (the sidebar jumps
// to the top during capture). Instead we only *tag* fixed elements here — a plain
// attribute write that never resets scroll — recording their measured viewport rect
// in document coordinates. The actual repositioning happens on snapDOM's CLONE via
// the afterClone plugin below, leaving the live DOM (and its scroll) untouched.
const tagFixedElementsForClonePinning = (doc: Document, scrollX: number, scrollY: number): (() => void) => {
if (scrollX <= 0 && scrollY <= 0) return () => { /* nothing scrolled */ };
const view = doc.defaultView;
if (!view) return () => { /* no view */ };
const tagged: HTMLElement[] = [];
const debugInfo: Array<Record<string, number | string>> = [];
try {
for (const el of doc.querySelectorAll<HTMLElement>('*')) {
if (view.getComputedStyle(el).position !== 'fixed') continue;
const rect = el.getBoundingClientRect();
if (!(rect.width > 0 && rect.height > 0)) continue;
el.setAttribute(FIXED_PIN_ATTR, JSON.stringify({
top: rect.top + scrollY,
left: rect.left + scrollX,
width: rect.width,
height: rect.height,
}));
tagged.push(el);
const tag = el.tagName.toLowerCase();
const cls = typeof el.className === 'string' && el.className
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
: '';
debugInfo.push({ selector: `${tag}${cls}`, top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height) });
}
} catch { /* best-effort: leave fixed elements untagged */ }
previewCaptureDebug('tagged fixed elements', debugInfo);
return () => {
for (const el of tagged) {
try { el.removeAttribute(FIXED_PIN_ATTR); } catch { /* best-effort */ }
}
};
};
// Runs inside snapDOM after the clone is built (and after prepareClone has baked
// nested scroll via translate). We re-anchor tagged fixed elements on the CLONE to
// their measured viewport rect in document coordinates, so cropping at the scroll
// offset lands them in the right place at the right size — without ever touching the
// live DOM. snapDOM's own scroll-translate wrapper on the clone is preserved, so the
// sidebar's internal scroll position stays baked in.
const snapdomFixedPinPlugin = {
name: 'oc-fixed-pin',
afterClone(context: { clone?: Element | null }): void {
const clone = context?.clone;
if (!clone || typeof (clone as Element).querySelectorAll !== 'function') return;
for (const el of (clone as Element).querySelectorAll<HTMLElement>(`[${FIXED_PIN_ATTR}]`)) {
let spec: { top: number; left: number; width: number; height: number };
try { spec = JSON.parse(el.getAttribute(FIXED_PIN_ATTR) || ''); } catch { continue; }
el.style.setProperty('position', 'absolute', 'important');
el.style.setProperty('top', `${spec.top}px`, 'important');
el.style.setProperty('left', `${spec.left}px`, 'important');
el.style.setProperty('right', 'auto', 'important');
el.style.setProperty('bottom', 'auto', 'important');
el.style.setProperty('width', `${spec.width}px`, 'important');
el.style.setProperty('height', `${spec.height}px`, 'important');
el.removeAttribute(FIXED_PIN_ATTR);
}
},
};
const NESTED_SCROLL_ATTR = 'data-oc-scroll-pin';
// Preparing the capture (asset inlining, layout reflows) resets the scrollTop of
// overflow containers like the fixed Starlight `.sidebar-pane`. snapDOM bakes nested
// scroll into the clone using the LIVE scrollTop *at clone time* — which by then has
// been reset to 0, so the sidebar renders from the top. We can't reliably keep the
// live scroll pinned through async asset inlining, so instead we snapshot each
// container's scroll up front (reliable values) and tag it with a data attribute.
// snapdomNestedScrollPlugin.afterClone then re-bakes the scroll on the CLONE from
// these snapshot values, independent of whatever the live scrollTop was. We skip the
// root/body — document scroll is handled by the viewport crop, not by baking.
const captureNestedScrollState = (doc: Document): { reapply: () => void; cleanup: () => void; snapshot: ScrolledElementInfo[] } => {
const entries: Array<{ el: HTMLElement; top: number; left: number; tagged: boolean }> = [];
const snapshot = describeScrolledElements(doc, 64);
try {
const root = doc.documentElement;
const body = doc.body;
for (const el of doc.querySelectorAll<HTMLElement>('*')) {
const top = el.scrollTop || 0;
const left = el.scrollLeft || 0;
if (top <= 0 && left <= 0) continue;
const tagged = el !== root && el !== body;
if (tagged) el.setAttribute(NESTED_SCROLL_ATTR, JSON.stringify({ top, left }));
entries.push({ el, top, left, tagged });
}
} catch { /* best-effort: no nested scroll preservation */ }
const reapply = () => {
for (const entry of entries) {
try {
void entry.el.scrollHeight;
if (entry.el.scrollTop !== entry.top) entry.el.scrollTop = entry.top;
if (entry.el.scrollLeft !== entry.left) entry.el.scrollLeft = entry.left;
} catch { /* best-effort restore */ }
}
};
const cleanup = () => {
for (const entry of entries) {
if (!entry.tagged) continue;
try { entry.el.removeAttribute(NESTED_SCROLL_ATTR); } catch { /* best-effort */ }
}
};
return { reapply, cleanup, snapshot };
};
// Re-bake nested scroll on the clone from the reliable snapshot values (see above).
// snapDOM wraps a scrolled element's children in a single inner div with
// `transform: translate(...)` + `will-change: transform`. We override that transform
// when present, or create the wrapper ourselves if snapDOM saw scrollTop 0 at clone
// time. Runs after the fixed-pin pass so sidebar gets both correct box and scroll.
const snapdomNestedScrollPlugin = {
name: 'oc-nested-scroll',
afterClone(context: { clone?: Element | null }): void {
const clone = context?.clone;
if (!clone || typeof (clone as Element).querySelectorAll !== 'function') return;
const ownerDoc = (clone as Element).ownerDocument;
if (!ownerDoc) return;
for (const el of (clone as Element).querySelectorAll<HTMLElement>(`[${NESTED_SCROLL_ATTR}]`)) {
let spec: { top: number; left: number };
try { spec = JSON.parse(el.getAttribute(NESTED_SCROLL_ATTR) || ''); } catch { el.removeAttribute(NESTED_SCROLL_ATTR); continue; }
const transform = `translate(${-spec.left}px, ${-spec.top}px)`;
const existingWrapper = el.children.length === 1 && el.firstElementChild instanceof HTMLElement && el.firstElementChild.style.willChange === 'transform'
? el.firstElementChild
: null;
if (existingWrapper) {
existingWrapper.style.transform = transform;
} else {
el.style.overflow = 'hidden';
const inner = ownerDoc.createElement('div');
inner.style.transform = transform;
inner.style.willChange = 'transform';
inner.style.display = 'inline-block';
inner.style.width = '100%';
while (el.firstChild) inner.appendChild(el.firstChild);
el.appendChild(inner);
}
el.removeAttribute(NESTED_SCROLL_ATTR);
}
},
};
const fetchUrlAsDataUrl = async (url: string, credentials: RequestCredentials): Promise<string | null> => {
try {
const response = await fetch(url, { credentials });
if (!response.ok) return null;
return await blobToDataUrl(await response.blob());
} catch {
return null;
}
};
const getExternalResourceProxyUrl = async (url: URL): Promise<string> => {
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
const targetKey = url.origin;
const cached = getCachedProxyTarget(targetKey);
if (cached) {
return `${cached.proxyBasePath}${url.pathname}${url.search}${url.hash}`;
}
const existingRequest = previewProxyTargetRequests.get(targetKey);
const request = existingRequest ?? (async () => {
try {
const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: url.toString(), allowExternal: true }),
});
if (!response.ok) {
previewProxyTargetCache.delete(targetKey);
return null;
}
const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
if (!proxyBasePath) {
previewProxyTargetCache.delete(targetKey);
return null;
}
const target = { proxyBasePath, expiresAt };
previewProxyTargetCache.set(targetKey, target);
return target;
} catch {
previewProxyTargetCache.delete(targetKey);
return null;
} finally {
previewProxyTargetRequests.delete(targetKey);
}
})();
if (!existingRequest) {
previewProxyTargetRequests.set(targetKey, request);
}
const target = await request;
return target ? `${target.proxyBasePath}${url.pathname}${url.search}${url.hash}` : '';
};
const fetchFrameResourceAsDataUrl = async (rawUrl: string, document: Document): Promise<string> => {
if (!rawUrl || rawUrl.startsWith('data:')) return rawUrl;
try {
const url = new URL(rawUrl, document.baseURI);
if (url.origin === window.location.origin || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
return await fetchUrlAsDataUrl(url.toString(), 'include') ?? TRANSPARENT_IMAGE_PLACEHOLDER;
}
const proxyUrl = await getExternalResourceProxyUrl(url);
const proxied = proxyUrl ? await fetchUrlAsDataUrl(proxyUrl, 'include') : null;
if (proxied) return proxied;
return await fetchUrlAsDataUrl(url.toString(), 'omit') ?? TRANSPARENT_IMAGE_PLACEHOLDER;
} catch {
return TRANSPARENT_IMAGE_PLACEHOLDER;
}
};
const inlineCssImageUrls = async (value: string, document: Document): Promise<string> => {
if (!value || value === 'none' || !value.includes('url(')) return value;
const matches = Array.from(value.matchAll(/url\((['"]?)(.*?)\1\)/g));
let nextValue = value;
for (const match of matches) {
const rawUrl = match[2] || '';
if (!rawUrl || rawUrl.startsWith('data:')) continue;
const dataUrl = await fetchFrameResourceAsDataUrl(rawUrl, document);
nextValue = nextValue.replace(match[0], `url("${dataUrl}")`);
}
return nextValue;
};
const waitForImage = (image: HTMLImageElement): Promise<void> => {
if (image.complete) return Promise.resolve();
return new Promise((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
});
};
const getElementStyleRestore = (element: HTMLElement): (() => void) => {
const cssText = element.style.cssText;
return () => { element.style.cssText = cssText; };
};
const getLineHeight = (style: CSSStyleDeclaration): number => {
const lineHeight = Number.parseFloat(style.lineHeight);
if (Number.isFinite(lineHeight) && lineHeight > 0) return lineHeight;
const fontSize = Number.parseFloat(style.fontSize);
return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 1.2 : 16;
};
const preserveSingleLineTextElements = (
document: Document,
viewportWidth: number,
viewportHeight: number,
): (() => void) => {
const restoreCallbacks: Array<() => void> = [];
const view = document.defaultView ?? window;
const controlsSelector = 'button, a, summary, label, [role="button"], [role="link"], [role="menuitem"], [role="tab"], nav *, header *';
const elements = Array.from(document.querySelectorAll<HTMLElement>(controlsSelector));
for (const element of elements) {
const text = element.textContent?.replace(/\s+/g, ' ').trim() ?? '';
if (!text || !text.includes(' ')) continue;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
if (rect.right < 0 || rect.bottom < 0 || rect.left > viewportWidth || rect.top > viewportHeight) continue;
const style = view.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
const textWrap = style.getPropertyValue('text-wrap');
const textWrapMode = style.getPropertyValue('text-wrap-mode');
const alreadyNoWrap = style.whiteSpace.includes('nowrap') || textWrap === 'nowrap' || textWrapMode === 'nowrap';
const isSingleLine = rect.height <= getLineHeight(style) * 1.7;
if (!alreadyNoWrap && (!isSingleLine || rect.width > viewportWidth * 0.72)) continue;
restoreCallbacks.push(getElementStyleRestore(element));
element.style.whiteSpace = 'nowrap';
element.style.overflowWrap = 'normal';
element.style.wordBreak = 'normal';
element.style.setProperty('text-wrap', 'nowrap');
element.style.setProperty('text-wrap-mode', 'nowrap');
}
return () => {
for (let index = restoreCallbacks.length - 1; index >= 0; index -= 1) {
restoreCallbacks[index]?.();
}
};
};
const freezeViewportPositionedElements = (
document: Document,
viewportWidth: number,
viewportHeight: number,
frozenElements?: WeakSet<HTMLElement>,
): (() => void) => {
const restoreCallbacks: Array<() => void> = [];
const view = document.defaultView ?? window;
const scrollX = view.scrollX || document.documentElement.scrollLeft || document.body?.scrollLeft || 0;
const scrollY = view.scrollY || document.documentElement.scrollTop || document.body?.scrollTop || 0;
const candidates = Array.from(document.querySelectorAll<HTMLElement>('*'))
.filter((element) => {
const style = view.getComputedStyle(element);
if (style.position !== 'fixed') return false;
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0
&& rect.height > 0
&& rect.right >= 0
&& rect.bottom >= 0
&& rect.left <= viewportWidth
&& rect.top <= viewportHeight;
})
.filter((element, index, elements) => {
return !elements.some((candidate, candidateIndex) => candidateIndex < index && candidate.contains(element));
});
for (const element of candidates) {
const rect = element.getBoundingClientRect();
const computed = view.getComputedStyle(element);
const borderBoxWidth = Math.ceil(rect.width) + 8;
const borderBoxHeight = Math.ceil(rect.height) + 2;
frozenElements?.add(element);
restoreCallbacks.push(getElementStyleRestore(element));
element.style.position = 'absolute';
element.style.top = `${rect.top + scrollY}px`;
element.style.left = `${rect.left + scrollX}px`;
element.style.right = 'auto';
element.style.bottom = 'auto';
element.style.width = `${borderBoxWidth}px`;
element.style.minWidth = `${borderBoxWidth}px`;
element.style.height = `${borderBoxHeight}px`;
element.style.minHeight = `${borderBoxHeight}px`;
element.style.margin = '0';
element.style.boxSizing = 'border-box';
element.style.transform = 'none';
if (computed.zIndex !== 'auto') element.style.zIndex = computed.zIndex;
}
return () => {
for (let index = restoreCallbacks.length - 1; index >= 0; index -= 1) {
restoreCallbacks[index]?.();
}
};
};
const inlineIframeCaptureAssets = async (
document: Document,
viewportWidth: number,
viewportHeight: number,
options: { applyLayoutWorkarounds?: boolean } = {},
): Promise<() => void> => {
const restoreCallbacks: Array<() => void> = [];
const view = document.defaultView ?? window;
const isVisibleInViewport = (element: Element): boolean => {
if (element === document.documentElement || element === document.body) return true;
try {
const rect = element.getBoundingClientRect();
return rect.width > 0
&& rect.height > 0
&& rect.right >= 0
&& rect.bottom >= 0
&& rect.left <= viewportWidth
&& rect.top <= viewportHeight;
} catch {
return false;
}
};
if (options.applyLayoutWorkarounds) {
const frozenElements = new WeakSet<HTMLElement>();
restoreCallbacks.push(freezeViewportPositionedElements(document, viewportWidth, viewportHeight, frozenElements));
restoreCallbacks.push(preserveSingleLineTextElements(document, viewportWidth, viewportHeight));
}
const imageSourceUrls = new Map<HTMLImageElement, string>();
for (const image of Array.from(document.images)) {
imageSourceUrls.set(image, image.currentSrc || image.src || image.getAttribute('src') || '');
}
const pictures = Array.from(document.querySelectorAll('picture'));
for (const picture of pictures) {
const sources = Array.from(picture.querySelectorAll('source'));
if (sources.length === 0) continue;
const previous = sources.map((source) => ({ source, srcset: source.getAttribute('srcset'), sizes: source.getAttribute('sizes') }));
restoreCallbacks.push(() => {
for (const item of previous) {
if (item.srcset === null) item.source.removeAttribute('srcset');
else item.source.setAttribute('srcset', item.srcset);
if (item.sizes === null) item.source.removeAttribute('sizes');
else item.source.setAttribute('sizes', item.sizes);
}
});
for (const source of sources) {
source.removeAttribute('srcset');
source.removeAttribute('sizes');
}
}
const images = Array.from(document.images).filter((image) => isVisibleInViewport(image));
await Promise.all(images.map(async (image) => {
const sourceUrl = imageSourceUrls.get(image) || image.currentSrc || image.src || image.getAttribute('src') || '';
if (!sourceUrl) return;
await waitForImage(image);
const dataUrl = await fetchFrameResourceAsDataUrl(sourceUrl, document);
const previous = {
src: image.getAttribute('src'),
srcset: image.getAttribute('srcset'),
sizes: image.getAttribute('sizes'),
};
restoreCallbacks.push(() => {
if (previous.src === null) image.removeAttribute('src');
else image.setAttribute('src', previous.src);
if (previous.srcset === null) image.removeAttribute('srcset');
else image.setAttribute('srcset', previous.srcset);
if (previous.sizes === null) image.removeAttribute('sizes');
else image.setAttribute('sizes', previous.sizes);
});
image.removeAttribute('srcset');
image.removeAttribute('sizes');
image.setAttribute('src', dataUrl || TRANSPARENT_IMAGE_PLACEHOLDER);
await waitForImage(image);
}));
const elements = Array.from(document.querySelectorAll<HTMLElement>('*')).filter(isVisibleInViewport);
await Promise.all(elements.map(async (element) => {
const backgroundImage = view.getComputedStyle(element).backgroundImage;
if (!backgroundImage || backgroundImage === 'none' || !backgroundImage.includes('url(')) return;
const nextBackgroundImage = await inlineCssImageUrls(backgroundImage, document);
if (nextBackgroundImage === backgroundImage) return;
const previous = element.style.backgroundImage;
restoreCallbacks.push(() => { element.style.backgroundImage = previous; });
element.style.backgroundImage = nextBackgroundImage;
}));
return () => {
for (let index = restoreCallbacks.length - 1; index >= 0; index -= 1) {
try { restoreCallbacks[index]?.(); } catch { /* best-effort restore */ }
}
};
};
async function captureIframeSnapdomScreenshot(
iframe: HTMLIFrameElement,
target: PreviewElementMetadata,
): Promise<File | null> {
try {
const frameWindow = iframe.contentWindow;
const document = iframe.contentDocument ?? frameWindow?.document;
const root = document?.documentElement;
if (!frameWindow || !document || !root) return null;
const iframeRect = iframe.getBoundingClientRect();
const viewportWidth = Math.max(1, Math.ceil(frameWindow.innerWidth || iframe.clientWidth || iframeRect.width));
const viewportHeight = Math.max(1, Math.ceil(frameWindow.innerHeight || iframe.clientHeight || iframeRect.height));
const body = document.body;
const scrollingElement = document.scrollingElement instanceof HTMLElement ? document.scrollingElement : null;
const windowScrollX = frameWindow.scrollX || 0;
const windowScrollY = frameWindow.scrollY || 0;
const pageScrollX = frameWindow.pageXOffset || 0;
const pageScrollY = frameWindow.pageYOffset || 0;
const visualViewportScrollX = frameWindow.visualViewport?.pageLeft || frameWindow.visualViewport?.offsetLeft || 0;
const visualViewportScrollY = frameWindow.visualViewport?.pageTop || frameWindow.visualViewport?.offsetTop || 0;
const rootScrollX = root.scrollLeft || 0;
const rootScrollY = root.scrollTop || 0;
const bodyScrollX = body?.scrollLeft || 0;
const bodyScrollY = body?.scrollTop || 0;
const scrollingElementScrollX = scrollingElement?.scrollLeft || 0;
const scrollingElementScrollY = scrollingElement?.scrollTop || 0;
const scrollX = Math.max(windowScrollX, pageScrollX, visualViewportScrollX, rootScrollX, bodyScrollX, scrollingElementScrollX);
const scrollY = Math.max(windowScrollY, pageScrollY, visualViewportScrollY, rootScrollY, bodyScrollY, scrollingElementScrollY);
previewCaptureDebug('scroll sources', {
windowScrollX, windowScrollY,
pageScrollX, pageScrollY,
visualViewportScrollX, visualViewportScrollY,
rootScrollX, rootScrollY,
bodyScrollX, bodyScrollY,
scrollingElementScrollX, scrollingElementScrollY,
scrollingElementTag: scrollingElement?.tagName?.toLowerCase() ?? null,
resolvedScrollX: scrollX, resolvedScrollY: scrollY,
nestedScrolledElements: describeScrolledElements(document),
});
const captureWidth = Math.max(viewportWidth, root.scrollWidth || 0, body?.scrollWidth || 0, Math.ceil(root.getBoundingClientRect().width || 0));
const captureHeight = Math.max(viewportHeight, root.scrollHeight || 0, body?.scrollHeight || 0, Math.ceil(root.getBoundingClientRect().height || 0));
const pixelRatio = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
const previousRootScrollBehavior = root.style.scrollBehavior;
const previousBodyScrollBehavior = body?.style.scrollBehavior ?? '';
root.style.scrollBehavior = 'auto';
if (body) body.style.scrollBehavior = 'auto';
// Snapshot nested scroll positions before any mutation resets them.
const nestedScroll = captureNestedScrollState(document);
previewCaptureDebug('nested scroll snapshot', nestedScroll.snapshot);
let restoreAssets = () => { /* no-op until capture preparation mutates DOM */ };
let restoreFixedElements = () => { /* no-op until fixed elements are tagged */ };
try {
await document.fonts?.ready.catch(() => undefined);
restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: false });
frameWindow.scrollTo(scrollX, scrollY);
// Tag-only (no style mutation), so the sidebar's scroll is never disturbed; the
// pinning happens on the clone via snapdomFixedPinPlugin.afterClone.
restoreFixedElements = tagFixedElementsForClonePinning(document, scrollX, scrollY);
// Defensive: undo any nested-scroll drift from asset inlining before capture.
nestedScroll.reapply();
// Lazy-load snapDOM: only needed when actually capturing a preview
// annotation screenshot, so keep it out of the eager app shell.
const { snapdom } = await import('@zumer/snapdom');
const snapdomOptions = {
backgroundColor: getCaptureBackgroundColor(document),
cache: 'disabled' as const,
dpr: pixelRatio,
embedFonts: true,
fast: false,
height: captureHeight,
outerShadows: true,
outerTransforms: true,
placeholders: true,
plugins: [snapdomFixedPinPlugin, snapdomNestedScrollPlugin],
quality: 0.82,
width: captureWidth,
};
const capture = await snapdom(root, snapdomOptions);
const fullCanvas = await capture.toCanvas();
if (!fullCanvas.width || !fullCanvas.height) return null;
const xScale = fullCanvas.width / Math.max(1, captureWidth);
const yScale = fullCanvas.height / Math.max(1, captureHeight);
const sourceWidth = Math.min(fullCanvas.width, Math.max(1, Math.round(viewportWidth * xScale)));
const sourceHeight = Math.min(fullCanvas.height, Math.max(1, Math.round(viewportHeight * yScale)));
const maxSourceX = Math.max(0, fullCanvas.width - sourceWidth);
const maxSourceY = Math.max(0, fullCanvas.height - sourceHeight);
// snapDOM bakes scroll into nested overflow containers via translate(), but
// NOT into document-level scroll (documentElement/body): wrapping <html>'s
// children in a translate <div> is invalid and renders no offset. So the
// document scroll is never baked, and we always crop at the scroll offset.
// (An earlier heuristic scanned the raw SVG for a matching translate and
// cropped from 0 when found — but a nested scroller at the same offset could
// false-match and re-introduce top-of-page screenshots, so it's gone.)
const sourceX = Math.min(maxSourceX, Math.max(0, Math.round(scrollX * xScale)));
const sourceY = Math.min(maxSourceY, Math.max(0, Math.round(scrollY * yScale)));
previewCaptureDebug('capture geometry', {
viewportWidth, viewportHeight,
captureWidth, captureHeight,
canvasWidth: fullCanvas.width, canvasHeight: fullCanvas.height,
xScale, yScale,
sourceX, sourceY, sourceWidth, sourceHeight,
});
const viewportCanvas = document.createElement('canvas');
viewportCanvas.width = sourceWidth;
viewportCanvas.height = sourceHeight;
const context = viewportCanvas.getContext('2d');
if (!context) return null;
context.drawImage(fullCanvas, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
const base64 = await canvasToJpegBase64(viewportCanvas, 0.82);
if (!base64) return null;
return await desktopAnnotationToFile(base64, viewportWidth, viewportHeight, viewportWidth, viewportHeight, target);
} finally {
restoreFixedElements();
nestedScroll.cleanup();
restoreAssets();
frameWindow.scrollTo(scrollX, scrollY);
nestedScroll.reapply();
root.style.scrollBehavior = previousRootScrollBehavior;
if (body) body.style.scrollBehavior = previousBodyScrollBehavior;
}
} catch (error) {
console.warn('[preview] failed to capture iframe DOM screenshot with snapDOM:', error);
return null;
}
}
async function captureIframeDomScreenshot(
iframe: HTMLIFrameElement,
target: PreviewElementMetadata,
): Promise<File | null> {
const snapdomScreenshot = await captureIframeSnapdomScreenshot(iframe, target);
if (snapdomScreenshot) return snapdomScreenshot;
try {
const frameWindow = iframe.contentWindow;
const document = iframe.contentDocument ?? frameWindow?.document;
const root = document?.documentElement;
if (!frameWindow || !document || !root) return null;
const iframeRect = iframe.getBoundingClientRect();
const viewportWidth = Math.max(1, Math.ceil(frameWindow.innerWidth || iframe.clientWidth || iframeRect.width));
const viewportHeight = Math.max(1, Math.ceil(frameWindow.innerHeight || iframe.clientHeight || iframeRect.height));
const scrollX = frameWindow.scrollX || document.documentElement.scrollLeft || document.body?.scrollLeft || 0;
const scrollY = frameWindow.scrollY || document.documentElement.scrollTop || document.body?.scrollTop || 0;
const body = document.body;
const captureHeight = Math.max(viewportHeight, root.scrollHeight || 0, body?.scrollHeight || 0);
const pixelRatio = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
const previousRootScrollBehavior = root.style.scrollBehavior;
const previousBodyScrollBehavior = body?.style.scrollBehavior ?? '';
root.style.scrollBehavior = 'auto';
if (body) body.style.scrollBehavior = 'auto';
let dataUrl = '';
let restoreAssets = () => { /* no-op until capture preparation mutates DOM */ };
try {
await document.fonts?.ready.catch(() => undefined);
restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: true });
frameWindow.scrollTo(scrollX, scrollY);
// Lazy-load html-to-image: only the fallback DOM-capture path needs it.
const { getFontEmbedCSS, toJpeg } = await import('html-to-image');
const fontEmbedCSS = await getFontEmbedCSS(root).catch(() => '');
dataUrl = await toJpeg(root, {
quality: 0.82,
pixelRatio,
width: viewportWidth,
height: viewportHeight,
backgroundColor: getCaptureBackgroundColor(document),
imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER,
fontEmbedCSS: fontEmbedCSS || undefined,
style: {
transform: `translate(${-scrollX}px, ${-scrollY}px)`,
transformOrigin: 'top left',
minWidth: `${viewportWidth}px`,
minHeight: `${captureHeight}px`,
},
cacheBust: true,
});
} finally {
restoreAssets();
frameWindow.scrollTo(scrollX, scrollY);
root.style.scrollBehavior = previousRootScrollBehavior;
if (body) body.style.scrollBehavior = previousBodyScrollBehavior;
}
const base64 = dataUrl.split(',', 2)[1] || '';
if (!base64) return null;
return await desktopAnnotationToFile(base64, viewportWidth, viewportHeight, viewportWidth, viewportHeight, target);
} catch (error) {
console.warn('[preview] failed to capture iframe DOM screenshot:', error);
return null;
}
}
+10 -2
View File
@@ -476,11 +476,19 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
{
id: 'sessions.agent-control-tool',
page: 'general',
titleKey: 'settings.openchamber.opencodeCli.field.agentControlTool',
descriptionKey: 'settings.openchamber.opencodeCli.field.agentControlToolInfo',
titleKey: 'settings.openchamber.tools.field.agentControlTool',
descriptionKey: 'settings.openchamber.tools.field.agentControlToolInfo',
keywords: ['agent', 'tool', 'orchestration', 'openchamber', 'sessions', 'schedule', 'control'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'sessions.agent-web-tool',
page: 'general',
titleKey: 'settings.openchamber.tools.field.agentWebTool',
descriptionKey: 'settings.openchamber.tools.field.agentWebToolInfo',
keywords: ['agent', 'tool', 'web', 'browser', 'page', 'preview', 'openchamber'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'git.github-account',
page: 'git',
@@ -11,7 +11,7 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
- A surface maps 1:1 to a `ContextPanelMode` tab mode in `useUIStore`.
- `availability: 'always'` surfaces are always present on the rail.
`availability: 'has-content'` surfaces (preview, chat) are hidden from the
`availability: 'has-content'` surfaces (chat) are hidden from the
rail until a tab of their mode exists, and stay visible for as long as one
does — they must not disappear while in use.
- `defaultWidthFraction` is the panel width as a fraction of the content area,
@@ -50,7 +50,7 @@ the `openContext*` actions in `useUIStore`.
positions). Chat tab records stay open, but only the active chat iframe is
mounted while the panel is open. A selected chat restores its state from
the session stores. A closed panel mounts no chat iframe.
Singleton surfaces (git, pr, notes, plan, context) and preview tabs remount
on switch. These surfaces must restore their state from stores or snapshots.
Singleton surfaces (git, pr, notes, plan, context) remount on switch. These
surfaces must restore their state from stores or snapshots.
- Runtime scope: desktop/web `MainLayout` only. VS Code and the dedicated
mobile shell have their own layouts and do not consume this registry.
+19 -6
View File
@@ -36,14 +36,27 @@ describe('getVisibleContextRailSurfaces', () => {
).toBe(true);
});
test('offers no browser surface inside VS Code', () => {
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'browser')).toBe(true);
// Nothing that makes the panel worth having works there, so offering it
// would promise the panel people see on the desktop.
expect(getVisibleContextRailSurfaces({ ...baseOptions, isVSCode: true }).some((s) => s.id === 'browser')).toBe(false);
});
test('hides content-driven surfaces until a matching tab exists', () => {
const preview = CONTEXT_SURFACES.find((surface) => surface.id === 'preview');
if (!preview) {
throw new Error('preview surface missing from registry');
const chat = CONTEXT_SURFACES.find((surface) => surface.id === 'chat');
if (!chat) {
throw new Error('chat surface missing from registry');
}
expect(preview.availability).toBe('has-content');
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'preview')).toBe(false);
expect(getVisibleContextRailSurfaces({ ...baseOptions, tabs: [{ mode: preview.mode }] }).some((s) => s.id === 'preview')).toBe(true);
expect(chat.availability).toBe('has-content');
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'chat')).toBe(false);
expect(getVisibleContextRailSurfaces({ ...baseOptions, tabs: [{ mode: chat.mode }] }).some((s) => s.id === 'chat')).toBe(true);
});
test('the browser surface can be opened from the rail with no tab yet', () => {
const browser = CONTEXT_SURFACES.find((surface) => surface.id === 'browser');
expect(browser?.availability).toBe('always');
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'browser')).toBe(true);
});
test('respects the persisted user rail order', () => {
+10 -12
View File
@@ -13,7 +13,6 @@ export type ContextSurfaceId =
| 'notes'
| 'context'
| 'browser'
| 'preview'
| 'chat';
export type ContextSurfaceDescriptor = {
@@ -25,8 +24,8 @@ export type ContextSurfaceDescriptor = {
/**
* 'always' surfaces can be opened empty from the rail.
* 'has-content' surfaces are content-driven: they need an existing tab of
* their mode (a preview URL emitted, a split session) and stay hidden on
* the rail until one exists.
* their mode (a split session, a diff to show) and stay hidden on the rail
* until one exists.
*/
availability: 'always' | 'has-content';
/** Short tooltip explanation shown on the rail. */
@@ -129,15 +128,6 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
labelKey: 'contextPanel.mode.browser',
availability: 'always',
},
{
id: 'preview',
descriptionKey: 'contextRail.surface.preview.description',
defaultWidthFraction: 0.45,
mode: 'preview',
icon: 'window',
labelKey: 'contextPanel.mode.preview',
availability: 'has-content',
},
{
id: 'chat',
descriptionKey: 'contextRail.surface.chat.description',
@@ -218,6 +208,14 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption
if (surface.id === 'walkthrough' && (options.isVSCode || options.screenWidth < WALKTHROUGH_MIN_WIDTH)) {
return false;
}
// VS Code already is an editor with a browser next to it. What OpenChamber
// could add there is a bare frame: no annotation, no agent control, no
// remote dev servers — all of which need a Chromium host the extension does
// not have. Offering the surface anyway would promise the panel people see
// on the desktop.
if (surface.id === 'browser' && options.isVSCode) {
return false;
}
if (surface.availability === 'has-content') {
return options.tabs.some((tab) => tab.mode === surface.mode);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'bun:test';
import { extractAnnouncedUrls, extractProjectActionUrl, extractTerminalPreviewUrl } from './terminalPreview';
/**
* Real output from a project running four Astro apps behind a dev gateway. The
* gateway logs its own routing table, so the output is full of URLs that look
* perfectly openable but are backends the user should never be sent to and
* two of the apps are served under a base path.
*/
const GATEWAY_LOG = [
'[gateway] OpenChamber website dev gateway ready on http://localhost:3000',
'[gateway] - site -> http://127.0.0.1:4321',
'[gateway] - docs -> http://127.0.0.1:4322',
'[gateway] - analytics -> http://127.0.0.1:4323',
'[gateway] - api -> http://127.0.0.1:8787',
].join('\n');
const ANALYTICS_LOG = [
'[analytics] astro v5.18.1 ready in 1531 ms',
'[analytics]',
'[analytics] ┃ Local http://localhost:4323/__analytics',
'[analytics] ┃ Network http://192.168.1.115:4323/__analytics',
].join('\n');
describe('announced preview url', () => {
test('takes the gateway front door over the backends it lists', () => {
expect(extractTerminalPreviewUrl(GATEWAY_LOG)).toBe('http://localhost:3000');
});
test('keeps the base path an app is served under', () => {
expect(extractTerminalPreviewUrl(ANALYTICS_LOG)).toBe('http://localhost:4323/__analytics');
});
test('prefers the LAN-independent local address over the network one', () => {
expect(extractTerminalPreviewUrl(ANALYTICS_LOG)).not.toContain('192.168');
});
test('ignores a routing-table line, which announces nothing', () => {
expect(extractTerminalPreviewUrl('[gateway] - analytics -> http://127.0.0.1:4323')).toBeNull();
});
});
describe('project action url', () => {
test('opens the gateway, not a backend from its routing table', () => {
expect(extractProjectActionUrl(GATEWAY_LOG + '\n' + ANALYTICS_LOG)).toBe('http://localhost:3000');
});
test('keeps the base path instead of stripping it to the origin', () => {
// Dropping the path is what lands the user on the app's own 404.
expect(extractProjectActionUrl(ANALYTICS_LOG)).toBe('http://localhost:4323/__analytics');
});
test('falls back to scoring when nothing announces itself', () => {
const output = 'starting\nhttp://127.0.0.1:4323/__analytics\n';
expect(extractProjectActionUrl(output)).toBe('http://127.0.0.1:4323/__analytics');
});
test('prefers a loopback candidate over a public one', () => {
const output = 'see https://example.com:8443/app and http://127.0.0.1:5173/ui';
expect(extractProjectActionUrl(output)).toBe('http://127.0.0.1:5173/ui');
});
test('returns nothing when the output has no url with a port', () => {
expect(extractProjectActionUrl('building...\ndone\n')).toBeNull();
expect(extractProjectActionUrl('')).toBeNull();
});
test('normalizes a wildcard bind to an address the browser can reach', () => {
expect(extractProjectActionUrl('server listening on http://0.0.0.0:4000/app'))
.toBe('http://127.0.0.1:4000/app');
});
});
describe('auto-discovery url', () => {
const ROUTING_TABLE_CHUNK = [
'[gateway] - site -> http://127.0.0.1:4321',
'[gateway] - docs -> http://127.0.0.1:4322',
'[gateway] - analytics -> http://127.0.0.1:4323',
].join('\n');
test('waits rather than opening a backend from a routing table', () => {
// Whether this chunk or the announcement arrives first depends on where the
// terminal split its output, so guessing here is guessing differently each run.
expect(extractProjectActionUrl(ROUTING_TABLE_CHUNK, { requireAnnounced: true })).toBeNull();
});
test('opens the gateway once it announces itself', () => {
expect(extractProjectActionUrl(GATEWAY_LOG, { requireAnnounced: true })).toBe('http://localhost:3000');
});
test('a configured action may still open a bare url it printed, taking the first', () => {
expect(extractProjectActionUrl(ROUTING_TABLE_CHUNK)).toBe('http://127.0.0.1:4321/');
});
});
describe('every announced url', () => {
test('returns each server that announced itself, in order', () => {
const output = [
'[gateway] OpenChamber website dev gateway ready on http://localhost:3000',
'[gateway] - site -> http://127.0.0.1:4321',
'[api] ⚡ API dev server running on http://localhost:8787',
'[analytics] ┃ Local http://localhost:4323/__analytics',
'[docs] ┃ Local http://localhost:4322/docs',
].join('\n');
expect(extractAnnouncedUrls(output)).toEqual([
'http://localhost:3000',
'http://localhost:8787',
'http://localhost:4323/__analytics',
'http://localhost:4322/docs',
]);
});
test('leaves out routing-table entries, which announce nothing', () => {
expect(extractAnnouncedUrls('[gateway] - site -> http://127.0.0.1:4321')).toEqual([]);
});
test('does not repeat an address announced twice', () => {
const output = [
'[site] ┃ Local http://localhost:4321/',
'[site] server running on http://localhost:4321/',
].join('\n');
expect(extractAnnouncedUrls(output)).toEqual(['http://localhost:4321/']);
});
test('is empty for output with no announcement', () => {
expect(extractAnnouncedUrls('building...\ndone')).toEqual([]);
expect(extractAnnouncedUrls('')).toEqual([]);
});
});
+81 -13
View File
@@ -37,28 +37,39 @@ const normalizeLoopbackUrl = (url: string): string => {
return normalized;
};
export const extractTerminalPreviewUrl = (text: string): string | null => {
if (!text) return null;
/**
* Every address a server announced in this output, in the order announced.
*
* A project can start several servers at once a gateway and the apps behind
* it, an API alongside a site and each announces itself. Taking the first is
* a coin toss decided by which chunk the terminal emitted first, so callers
* that must choose are given all of them instead.
*/
export const extractAnnouncedUrls = (text: string): string[] => {
if (!text) return [];
const cleaned = text.replace(ANSI_ESCAPE_PATTERN, '');
const found: string[] = [];
const seen = new Set<string>();
const add = (url: string) => {
if (seen.has(url)) return;
seen.add(url);
found.push(url);
};
const pythonMatch = cleaned.match(PYTHON_HTTP_SERVER_PATTERN);
if (pythonMatch?.[1]) {
const port = Number.parseInt(pythonMatch[1], 10);
if (Number.isFinite(port) && port > 0 && port <= 65535) {
return `http://127.0.0.1:${port}/`;
add(`http://127.0.0.1:${port}/`);
}
}
const lines = cleaned.split('\n');
for (const line of lines) {
if (!PREVIEW_OUTPUT_PATTERN.test(line)) {
continue;
}
for (const line of cleaned.split('\n')) {
if (!PREVIEW_OUTPUT_PATTERN.test(line)) continue;
const matches = Array.from(line.matchAll(LOOPBACK_URL_PATTERN));
if (matches.length === 0) {
continue;
}
if (matches.length === 0) continue;
const withPort = matches.find((match) => {
try {
@@ -67,12 +78,16 @@ export const extractTerminalPreviewUrl = (text: string): string | null => {
return false;
}
});
return normalizeLoopbackUrl((withPort ?? matches[0])[1]);
add(normalizeLoopbackUrl((withPort ?? matches[0])[1]));
}
return null;
return found;
};
export const extractTerminalPreviewUrl = (text: string): string | null => (
extractAnnouncedUrls(text)[0] ?? null
);
export const isTerminalPreviewUrlAvailable = async (url: string, timeoutMs = 1500): Promise<boolean> => {
if (!url) return false;
if (typeof window === 'undefined') return false;
@@ -115,3 +130,56 @@ export const isTerminalPreviewUrlAvailable = async (url: string, timeoutMs = 150
window.clearTimeout(timeout);
}
};
const ANY_URL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
/**
* Finds the URL a project action wants opened.
*
* Prefers the announcement line `Local`, `ready on`, `serving` because that
* is the address the server is telling you to visit. The path is part of that
* answer: an app served under a base path announces it, and dropping it lands
* the user on that app's own 404.
*
* `requireAnnounced` refuses to guess at all. Output is scanned in whatever
* chunks the terminal emits and the first match wins, so scoring loose URLs
* makes the result depend on where those chunk boundaries happened to fall a
* dev gateway logging its routing table offers several backends that all look
* openable. Where the command itself was inferred rather than configured,
* waiting for a server to announce itself is the only answer that is the same
* every time.
*/
export const extractProjectActionUrl = (
text: string,
{ requireAnnounced = false }: { requireAnnounced?: boolean } = {},
): string | null => {
const announced = extractTerminalPreviewUrl(text);
if (announced) return announced;
if (requireAnnounced) return null;
const cleaned = String(text || '').replace(ANSI_ESCAPE_PATTERN, '');
const candidates: URL[] = [];
for (const raw of cleaned.match(ANY_URL_PATTERN) ?? []) {
try {
const parsed = new URL(trimUrlTrailingPunctuation(raw));
if (parsed.port) candidates.push(parsed);
} catch {
// Not a URL after trimming; nothing to score.
}
}
if (candidates.length === 0) return null;
const score = (parsed: URL): number => {
const host = parsed.hostname.toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
// Only the host is scored. Path depth used to count against a candidate,
// which is backwards: a printed path is information the server gave us.
return (isLoopback ? 50 : 0) - (parsed.search || parsed.hash ? 10 : 0);
};
let best = candidates[0];
for (const candidate of candidates.slice(1)) {
if (score(candidate) > score(best)) best = candidate;
}
return normalizeLoopbackUrl(best.toString());
};
+7
View File
@@ -194,6 +194,13 @@ const TOOL_METADATA: Record<string, ToolMetadata> = {
inputFields: []
},
openchamber_web: {
displayName: 'OpenChamber Web',
category: 'system',
outputLanguage: 'json',
inputFields: []
},
plan_enter: {
displayName: 'Plan Mode',
category: 'ai',
@@ -0,0 +1,62 @@
import { create } from 'zustand';
import { invokeDesktopCommand } from '@/lib/desktopNative';
/**
* Page icons for browser tabs, kept per origin.
*
* Per origin rather than per tab or per page: a site uses one icon everywhere,
* so the second tab on the same host already has its icon, and navigating
* within a site does not make the tab flicker back to a placeholder.
*
* Held in memory only. The icons are data URLs, and filling persisted storage
* with them to save one small request per host is a poor trade.
*/
const MAX_ORIGINS = 60;
type FaviconState = {
byOrigin: Record<string, string>;
/** Resolves and stores the icon a page reported. Failure is silent by design. */
resolve: (pageUrl: string, iconUrl: string) => void;
};
const originOf = (value: string): string => {
try {
return new URL(value).origin;
} catch {
return '';
}
};
/** Requests in flight, so a page reporting its icon twice fetches once. */
const pending = new Set<string>();
export const useBrowserFaviconStore = create<FaviconState>()((set, get) => ({
byOrigin: {},
resolve: (pageUrl, iconUrl) => {
const origin = originOf(pageUrl);
if (!origin || !iconUrl) return;
if (get().byOrigin[origin] || pending.has(origin)) return;
pending.add(origin);
void invokeDesktopCommand<{ dataUrl?: string }>('desktop_browser_fetch_favicon', { url: iconUrl })
.then((result) => {
const dataUrl = typeof result?.dataUrl === 'string' ? result.dataUrl : '';
if (!dataUrl) return;
set((state) => {
const byOrigin = { ...state.byOrigin, [origin]: dataUrl };
const origins = Object.keys(byOrigin);
if (origins.length <= MAX_ORIGINS) return { byOrigin };
// Oldest first: insertion order is close enough to least-recently-seen
// for a cache whose only cost is one request.
for (const stale of origins.slice(0, origins.length - MAX_ORIGINS)) delete byOrigin[stale];
return { byOrigin };
});
})
// A missing or unreachable icon is not a problem worth reporting: the tab
// simply keeps the placeholder it already has.
.catch(() => undefined)
.finally(() => { pending.delete(origin); });
},
}));
@@ -0,0 +1,102 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { normalizePath } from '@/lib/pathNormalization';
import { getRuntimeKey } from '@/lib/runtime-switch';
import {
recordVisit,
forgetVisit,
type BrowserHistoryEntry,
} from '@/lib/browser/history';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
/**
* Addresses visited in the browser panel, kept per project.
*
* Scoped by runtime as well as directory: the same path on a remote instance is
* a different machine with different servers on it, and offering one's
* addresses while connected to the other would be a suggestion that cannot work.
*/
const MAX_PROJECTS = 20;
type BrowserHistoryState = {
byProject: Record<string, BrowserHistoryEntry[]>;
recordVisit: (directory: string, visit: { url: string; title?: string }) => void;
forget: (directory: string, url: string) => void;
clear: (directory: string) => void;
};
const projectKey = (directory: string): string => {
const normalized = normalizePath((directory || '').trim());
return normalized ? JSON.stringify([getRuntimeKey(), normalized]) : '';
};
/** Keeps the projects visited most recently; the rest are not worth carrying. */
const evictOldestProjects = (
byProject: Record<string, BrowserHistoryEntry[]>,
): Record<string, BrowserHistoryEntry[]> => {
const keys = Object.keys(byProject);
if (keys.length <= MAX_PROJECTS) return byProject;
const ranked = keys
.map((key) => [key, byProject[key]?.[0]?.lastVisitedAt ?? 0] as const)
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_PROJECTS);
return Object.fromEntries(ranked.map(([key]) => [key, byProject[key] ?? []]));
};
export const useBrowserHistoryStore = create<BrowserHistoryState>()(
persist(
(set) => ({
byProject: {},
recordVisit: (directory, visit) => {
const key = projectKey(directory);
if (!key) return;
set((state) => {
const current = state.byProject[key] ?? [];
const next = recordVisit(current, { ...visit, at: Date.now() });
if (next === current) return state;
return { byProject: evictOldestProjects({ ...state.byProject, [key]: next }) };
});
},
forget: (directory, url) => {
const key = projectKey(directory);
if (!key) return;
set((state) => {
const current = state.byProject[key];
if (!current) return state;
return { byProject: { ...state.byProject, [key]: forgetVisit(current, url) } };
});
},
clear: (directory) => {
const key = projectKey(directory);
if (!key) return;
set((state) => {
if (!(key in state.byProject)) return state;
const byProject = { ...state.byProject };
delete byProject[key];
return { byProject };
});
},
}),
{
name: 'openchamber-browser-history',
version: 1,
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ byProject: state.byProject }),
},
),
);
/** Reads one project's history. Returns a stable empty list when there is none. */
const EMPTY: readonly BrowserHistoryEntry[] = [];
export const selectBrowserHistory = (directory: string) => (
(state: BrowserHistoryState): readonly BrowserHistoryEntry[] => {
const key = projectKey(directory);
return (key ? state.byProject[key] : undefined) ?? EMPTY;
}
);
@@ -63,7 +63,6 @@ describe('useUIStore openContextSurface', () => {
});
test('does nothing for content-driven modes without existing content', () => {
useUIStore.getState().openContextSurface(directory, 'preview');
useUIStore.getState().openContextSurface(directory, 'chat');
expect(useUIStore.getState().contextPanelByDirectory[directory]).toBe(undefined);
@@ -175,3 +174,32 @@ describe('useUIStore contextRailOrder', () => {
expect(ids).toHaveLength(CONTEXT_SURFACES.length);
});
});
describe('context panel tab limits', () => {
test('a surface filling up never evicts another surface tab', () => {
const directory = '/repo';
useUIStore.getState().openContextDiff(directory, 'src/app.ts');
for (let index = 0; index < 20; index += 1) {
useUIStore.getState().openContextPreview(directory, `http://localhost:${3000 + index}/`);
}
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
// The diff tab is not on screen while browsing, so losing it would be a
// disappearance the user never saw happen.
expect(tabs.some((tab) => tab.mode === 'diff')).toBe(true);
expect(tabs.filter((tab) => tab.mode === 'browser').length).toBeLessThan(20);
});
test('keeps the tab that was just opened', () => {
const directory = '/repo';
for (let index = 0; index < 20; index += 1) {
useUIStore.getState().openContextPreview(directory, `http://localhost:${3000 + index}/`);
}
const state = useUIStore.getState().contextPanelByDirectory[directory];
const tabs = state?.tabs ?? [];
expect(tabs.some((tab) => tab.id === state?.activeTabId)).toBe(true);
expect(tabs.some((tab) => tab.targetPath === 'http://localhost:3019/')).toBe(true);
});
});
+134 -37
View File
@@ -11,10 +11,11 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
import type { TerminalShell } from '@/lib/api/types';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -119,10 +120,13 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
const CONTEXT_PANEL_DEFAULT_WIDTH = 380;
const CONTEXT_PANEL_MIN_WIDTH = 380;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
/** Per surface, not per panel: see clampContextPanelTabs. */
const CONTEXT_PANEL_MAX_TABS = 12;
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
const LEFT_SIDEBAR_MIN_WIDTH = 280;
const activeMainTabByRuntime = new Map<string, MainTab>();
/** Separates browser tabs opened in the same millisecond. */
let browserTabSequence = 0;
const runtimeMemoryKey = (value?: string | null): string => {
const key = (value ?? getRuntimeKey()).trim();
@@ -196,7 +200,7 @@ const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath
return targetPath || mode;
}
if (mode === 'preview') {
if (mode === 'browser') {
return targetPath || mode;
}
@@ -247,26 +251,43 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
};
};
const clampContextPanelTabs = (tabs: ContextPanelTab[], maxTabs: number, activeTabId: string | null): ContextPanelTab[] => {
if (tabs.length <= maxTabs) {
return tabs;
/**
* Keeps each surface's tab count in hand.
*
* The limit is per mode because the strip is per mode: a user looking at diffs
* only ever sees diff tabs, so evicting one to make room for a browser tab
* takes away something they cannot see being taken. Modes compete for screen
* space separately, so they get separate budgets.
*/
const clampContextPanelTabs = (
tabs: ContextPanelTab[],
maxTabsPerMode: number,
activeTabId: string | null,
): ContextPanelTab[] => {
const counts = new Map<ContextPanelMode, number>();
for (const tab of tabs) counts.set(tab.mode, (counts.get(tab.mode) ?? 0) + 1);
const over = [...counts.entries()].filter(([, count]) => count > maxTabsPerMode);
if (over.length === 0) return tabs;
const removeSet = new Set<string>();
for (const [mode, count] of over) {
const modeTabs = tabs.filter((tab) => tab.mode === mode);
const removable = [...modeTabs]
.sort((a, b) => a.touchedAt - b.touchedAt)
.filter((tab) => tab.id !== activeTabId);
// Never drop the tab being opened or looked at; if that leaves the mode one
// over its budget, one extra tab beats losing the one in use.
for (const tab of removable.slice(0, count - maxTabsPerMode)) removeSet.add(tab.id);
}
const tabsByTouch = [...tabs].sort((a, b) => a.touchedAt - b.touchedAt);
const removable = tabsByTouch.filter((tab) => tab.id !== activeTabId);
const removeCount = tabs.length - maxTabs;
if (removeCount <= 0 || removable.length === 0) {
return tabs.slice(-maxTabs);
}
const removeSet = new Set(removable.slice(0, removeCount).map((tab) => tab.id));
return tabs.filter((tab) => !removeSet.has(tab.id));
return removeSet.size === 0 ? tabs : tabs.filter((tab) => !removeSet.has(tab.id));
};
const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
if (!Array.isArray(tabs)) {
return [];
}
const dropBrowserTabs = isVSCodeRuntime();
const result: ContextPanelTab[] = [];
const seen = new Set<string>();
@@ -288,7 +309,16 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
touchedAt?: unknown;
};
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
// Legacy 'preview' tabs are converted to 'browser' by the v14 migration;
// anything still carrying an unknown mode here is discarded rather than
// resurrected into a tab the panel cannot render.
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
continue;
}
// State is shared with the desktop and web surfaces, which do have a
// browser; inside VS Code such a tab would have no surface to belong to.
if (dropBrowserTabs && candidate.mode === 'browser') {
continue;
}
@@ -523,7 +553,7 @@ const sanitizeContextPanelByDirectory = (
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
if (
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'preview' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal')
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal')
&& typeof value === 'number'
&& Number.isFinite(value)
) {
@@ -718,6 +748,7 @@ interface UIStore {
persistChatDraft: boolean;
showOpenCodeUpdateNotifications: boolean;
agentControlToolEnabled: boolean;
agentWebToolEnabled: boolean;
inputSpellcheckEnabled: boolean;
wideChatLayoutEnabled: boolean;
codeBlockLineWrap: boolean;
@@ -758,6 +789,7 @@ interface UIStore {
openContextPlan: (directory: string) => void;
openContextPreview: (directory: string, url: string) => void;
openContextBrowser: (directory: string, url?: string) => void;
openNewContextBrowserTab: (directory: string) => void;
setContextPanelTabTargetPath: (directory: string, tabID: string, targetPath: string) => void;
setActiveContextPanelTab: (directory: string, tabID: string) => void;
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
@@ -890,6 +922,7 @@ interface UIStore {
setPersistChatDraft: (value: boolean) => void;
setShowOpenCodeUpdateNotifications: (value: boolean) => void;
setAgentControlToolEnabled: (value: boolean) => void;
setAgentWebToolEnabled: (value: boolean) => void;
setInputSpellcheckEnabled: (value: boolean) => void;
setWideChatLayoutEnabled: (value: boolean) => void;
setCodeBlockLineWrap: (value: boolean) => void;
@@ -1048,6 +1081,7 @@ export const useUIStore = create<UIStore>()(
persistChatDraft: true,
showOpenCodeUpdateNotifications: !isWindowsArm64(),
agentControlToolEnabled: true,
agentWebToolEnabled: true,
inputSpellcheckEnabled: false,
wideChatLayoutEnabled: false,
codeBlockLineWrap: true,
@@ -1166,10 +1200,10 @@ export const useUIStore = create<UIStore>()(
return;
}
// Content-driven modes need a payload (a preview URL or session);
// the rail renders them disabled until content exists. 'file' opens
// an empty editor whose embedded tree picks the first file.
if (mode === 'preview' || mode === 'chat') {
// Content-driven modes need a payload (a session to split); the rail
// renders them disabled until content exists. 'file' opens an empty
// editor whose embedded tree picks the first file.
if (mode === 'chat') {
return;
}
@@ -1262,36 +1296,41 @@ export const useUIStore = create<UIStore>()(
openContextPreview: (directory, url) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedUrl = (url || '').trim();
if (!normalizedDirectory || !normalizedUrl) {
if (!normalizedDirectory || !normalizedUrl || isVSCodeRuntime()) {
return;
}
let label: string | null = null;
try {
const parsed = new URL(normalizedUrl);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
label = parsed.host || parsed.hostname || 'Preview';
}
} catch {
// ignore invalid URL
}
// No stored label: a browser tab is named after wherever it has
// navigated to, which the panel derives from targetPath.
get().openContextPanelTab(normalizedDirectory, {
mode: 'preview',
mode: 'browser',
targetPath: normalizedUrl,
dedupeKey: normalizedUrl,
label,
label: null,
});
},
// Always a new tab, never the existing one: the whole point of asking
// for one is to keep what is already open.
openNewContextBrowserTab: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory || isVSCodeRuntime()) return;
browserTabSequence += 1;
get().openContextPanelTab(normalizedDirectory, {
mode: 'browser',
targetPath: '',
dedupeKey: `browser:new:${Date.now()}-${browserTabSequence}`,
label: null,
});
},
openContextBrowser: (directory, url = '') => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) return;
if (!normalizedDirectory || isVSCodeRuntime()) return;
const targetUrl = typeof url === 'string' && url.trim().length > 0 ? url.trim() : '';
get().openContextPanelTab(normalizedDirectory, {
mode: 'browser',
targetPath: targetUrl,
dedupeKey: 'desktop-browser',
label: 'Browser',
dedupeKey: targetUrl || 'browser',
label: null,
});
},
@@ -2264,6 +2303,9 @@ export const useUIStore = create<UIStore>()(
setAgentControlToolEnabled: (value) => {
set({ agentControlToolEnabled: value });
},
setAgentWebToolEnabled: (value) => {
set({ agentWebToolEnabled: value });
},
setInputSpellcheckEnabled: (value) => {
set({ inputSpellcheckEnabled: value });
},
@@ -2368,13 +2410,67 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createDeferredSafeJSONStorage(),
version: 13,
version: 14,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
}
const state = persistedState as Record<string, unknown>;
// v13 -> v14: the separate 'preview' surface merged into 'browser'.
// Stored preview tabs keep their URL and become browser tabs; their
// id encodes the mode, so it is rebuilt rather than left dangling.
// Persisted widths recorded under 'preview' carry over only when the
// user has not already sized the browser surface.
if (version < 14) {
const byDirectory = state.contextPanelByDirectory;
if (byDirectory && typeof byDirectory === 'object') {
for (const directoryState of Object.values(byDirectory as Record<string, unknown>)) {
if (!directoryState || typeof directoryState !== 'object') continue;
const entry = directoryState as Record<string, unknown>;
const widths = entry.widthByMode;
if (widths && typeof widths === 'object') {
const widthRecord = widths as Record<string, unknown>;
if (widthRecord.preview !== undefined) {
if (widthRecord.browser === undefined) widthRecord.browser = widthRecord.preview;
delete widthRecord.preview;
}
}
if (!Array.isArray(entry.tabs)) continue;
const seenIds = new Set<string>();
const migrated: Array<Record<string, unknown>> = [];
for (const rawTab of entry.tabs as Array<unknown>) {
if (!rawTab || typeof rawTab !== 'object') continue;
const tab = rawTab as Record<string, unknown>;
if (tab.mode !== 'preview') {
if (typeof tab.id === 'string') seenIds.add(tab.id);
migrated.push(tab);
continue;
}
const targetPath = typeof tab.targetPath === 'string' ? tab.targetPath : '';
const dedupeKey = typeof tab.dedupeKey === 'string' && tab.dedupeKey.trim()
? tab.dedupeKey.trim()
: (targetPath || 'browser');
const id = dedupeKey === 'browser' ? 'browser' : `browser:${dedupeKey}`;
// A converted tab can collide with a browser tab on the same
// URL; keep the existing one rather than producing duplicates.
if (seenIds.has(id)) continue;
seenIds.add(id);
migrated.push({ ...tab, mode: 'browser', id, dedupeKey });
}
entry.tabs = migrated;
if (typeof entry.activeTabId === 'string' && entry.activeTabId.startsWith('preview')) {
const nextActive = migrated.find((tab) => typeof tab.id === 'string');
entry.activeTabId = nextActive && typeof nextActive.id === 'string' ? nextActive.id : null;
}
}
}
}
// v12 -> v13: promote FilesView localStorage autosave toggle into the store.
if (version < 13) {
if (typeof state.autoSaveEnabled !== 'boolean') {
@@ -2592,6 +2688,7 @@ export const useUIStore = create<UIStore>()(
persistChatDraft: state.persistChatDraft,
showOpenCodeUpdateNotifications: state.showOpenCodeUpdateNotifications,
agentControlToolEnabled: state.agentControlToolEnabled,
agentWebToolEnabled: state.agentWebToolEnabled,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
wideChatLayoutEnabled: state.wideChatLayoutEnabled,
codeBlockLineWrap: state.codeBlockLineWrap,
+9
View File
@@ -14,11 +14,20 @@ declare global {
loadURL(url: string): void;
goBack(): void;
goForward(): void;
canGoBack(): boolean;
canGoForward(): boolean;
reload(): void;
reloadIgnoringCache(): void;
getZoomLevel(): number;
setZoomLevel(level: number): void;
stop(): void;
getURL(): string;
getTitle(): string;
isLoading(): boolean;
getWebContentsId(): number;
openDevTools(): void;
closeDevTools(): void;
isDevToolsOpened(): boolean;
executeJavaScript(code: string, userGesture?: boolean): Promise<unknown>;
}
-22
View File
@@ -1,22 +0,0 @@
declare module '@zumer/snapdom' {
export type SnapdomOptions = {
backgroundColor?: string;
cache?: 'disabled' | boolean | string;
dpr?: number;
embedFonts?: boolean;
fast?: boolean;
height?: number;
outerShadows?: boolean;
outerTransforms?: boolean;
placeholders?: boolean;
plugins?: unknown[];
quality?: number;
width?: number;
};
export type SnapdomCapture = {
toCanvas(): Promise<HTMLCanvasElement>;
};
export function snapdom(element: Element, options?: SnapdomOptions): Promise<SnapdomCapture>;
}