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
@@ -28,7 +28,7 @@ The tool can list projects and model preferences, create and follow up on sessio
## Turn the tool on or off
Open **Settings → General → OpenCode CLI**, change **Agent control tool**, then select **Save + Reload**. The setting applies after the managed OpenCode server restarts.
Open **Settings → General → OpenChamber Tools** and change **Agent control tool**. The setting applies once the managed OpenCode server restarts, which OpenChamber offers as **Apply & Restart**.
The tool is not available when OpenChamber connects to an external OpenCode server through `OPENCODE_HOST` or skip-start, or inside the VS Code extension. Desktop and web installations that use OpenChamber's managed OpenCode server support it automatically.
@@ -37,3 +37,4 @@ The tool is not available when OpenChamber connects to an external OpenCode serv
- [Scheduled Tasks](/scheduled-tasks/)
- [Worktree Sessions](/worktrees/)
- [Session Goals](/session-goals/)
- [Browser Panel](/desktop-browser/) — the OpenChamber Web tool, for looking at and driving a page
@@ -28,7 +28,7 @@ Das Werkzeug kann Projekte und Modelleinstellungen auflisten, Sitzungen erstelle
## Werkzeug ein- oder ausschalten
Öffne **Einstellungen → Allgemein → OpenCode CLI**, ändere **Agent control tool** und wähle dann **Save + Reload**. Die Einstellung gilt, nachdem der verwaltete OpenCode-Server neu gestartet wurde.
Öffne **Einstellungen → Allgemein → OpenChamber-Werkzeuge** und ändere **Agent control tool**. Die Einstellung gilt, sobald der verwaltete OpenCode-Server neu startet — OpenChamber bietet das als **Apply & Restart** an.
Das Werkzeug ist nicht verfügbar, wenn OpenChamber über `OPENCODE_HOST` oder skip-start mit einem externen OpenCode-Server verbunden ist oder innerhalb der VS-Code-Erweiterung läuft. Desktop- und Web-Installationen, die den verwalteten OpenCode-Server von OpenChamber verwenden, unterstützen es automatisch.
@@ -37,3 +37,4 @@ Das Werkzeug ist nicht verfügbar, wenn OpenChamber über `OPENCODE_HOST` oder s
- [Geplante Aufgaben](/scheduled-tasks/)
- [Worktree-Sitzungen](/worktrees/)
- [Sitzungsziele](/session-goals/)
- [Browser-Panel](/desktop-browser/) — das OpenChamber-Web-Werkzeug, um eine Seite anzusehen und zu bedienen
@@ -1,22 +1,53 @@
---
title: Desktop-Browser
description: Durchsuche jede Seite in der Desktop-App mit Inspektion und Konsolenaufzeichnung.
title: Browser-Panel
description: Öffne jede Seite in der App, annotiere sie und lass den Agenten sie bedienen.
---
# Desktop-Browser
# Browser-Panel
Die Desktop-App hat einen eingebauten Browser, damit du jede Seite direkt neben deinem Chat öffnen, auf Elemente zeigen und danach fragen sowie die Konsole der Seite aufzeichnen kannst. Öffne ihn über die Globus-Schaltfläche im App-Kopfbereich.
Das Browser-Panel öffnet jede Seite direkt neben deinem Chat. Öffne es über die Globus-Schaltfläche in der Kopfzeile.
> Der Desktop-Browser ist eine Funktion **nur für den Desktop**. Im Web bietet das [Preview](/preview/)-Panel dieselben Inspektions- und Konsolentools für deinen lokalen Dev-Server.
In der Desktop-App ist es ein echter Browser: Deine Logins bleiben erhalten, Hot Reload funktioniert, und die Entwicklerwerkzeuge sind einen Klick entfernt. In einem Browser-Tab zeigt das Panel eine Seite zwar an, kann aber nicht in sie hineinsehen — die Annotationswerkzeuge unten gibt es nur auf dem Desktop.
## Inspizieren und annotieren
Seiten, die hier geöffnet werden, bekommen keinen Zugriff auf Kamera, Mikrofon oder Standort: solche Anfragen werden abgelehnt.
Aktiviere **inspect** und klicke auf ein beliebiges Element auf der Seite. OpenChamber erstellt dazu eine Notiz — was es ist, welche Stile es hat, wo es sich befindet und einen Screenshot — und hängt sie an deine Chatnachricht an. Das ist der schnellste Weg, dem Agenten zu sagen: „dieses Element, genau hier“.
## Die Werkzeugleiste
## Konsolenaufzeichnung
Die Adressleiste merkt sich Seiten, die du in diesem Projekt geöffnet hast, und schlägt sie beim Tippen vor — passend zu einem Teil der Adresse oder des Seitentitels. Mit den Pfeiltasten gehst du durch die Liste, Enter öffnet den markierten Eintrag, und die Schaltfläche in einer Zeile entfernt ihn.
Der Browser sammelt die Konsolenausgabe der Seite — Fehler, Warnungen und Logs — damit du sie filtern und lesen kannst, ohne die Entwicklertools zu öffnen.
Daneben liegt **Neu laden**, dazu ein **hartes Neuladen**, das den Cache übergeht, wenn eine Änderung partout nicht erscheint, sowie eine Zoomsteuerung, die nur die Seite skaliert.
**Cookies löschen** und **Zwischengespeicherte Daten löschen** gelten allein für dieses Panel. Deine OpenChamber-Sitzung und andere Fenster bleiben unberührt.
## Eine Seite annotieren
Drücke **Annotieren**, und über der Seite erscheint eine Leiste mit drei Werkzeugen:
- **Element** — klicke ein Element an. Ein Klick auf ein anderes verschiebt die Auswahl, ein erneuter Klick auf dasselbe hebt sie auf.
- **Bereich** — ziehe einen Rahmen um einen Ausschnitt, wenn es um mehr als ein Element geht.
- **Zeichnen** — skizziere frei über die Seite.
Schreib dein Anliegen in das Feld neben deiner Markierung und drücke **Anhängen** — oder einfach Enter. Deine Chat-Nachricht bekommt eine Karte mit allem Markierten, deiner Notiz und einem Screenshot der sichtbaren Seite mit deinen Markierungen darauf — du kannst also „dieser Button, etwas runder" sagen, statt zu beschreiben, wo er steht.
Die Seite selbst wird nie verändert — Annotieren markiert nur, was da ist. `Esc` bricht ab und schließt die Leiste.
## Den Agenten steuern lassen
Der Agent kann das Browser-Panel selbst benutzen — eine Seite öffnen, lesen, was darauf steht, klicken, tippen, scrollen und zwischen mobiler, Tablet- und Desktop-Ansicht wechseln — um seine eigene Arbeit zu prüfen, statt dich darum zu bitten. Du siehst es im Panel passieren.
Beliebigen Code kann der Agent auf der Seite nicht ausführen. Der Browser behält deine echten Logins, deshalb bleibt er auf die genannten Aktionen beschränkt.
Er kann außerdem ein Bild dessen, was er sieht, in `.openchamber/screenshots/` in deinem Projekt speichern und es dir in seiner Antwort zeigen. Genau das macht ein Vorher-Nachher möglich, und die Datei bleibt danach liegen, um sie an einen Pull Request zu hängen.
Die Browser-Aktionen sind das **OpenChamber-Web-Werkzeug**, das sich unter **Einstellungen → Allgemein → OpenChamber-Werkzeuge** einzeln ein- und ausschalten lässt.
Dafür braucht es die Desktop-App: eine Seite in einem Browser-Tab lässt sich nicht steuern.
## Entwicklerwerkzeuge
Drücke die Terminal-Schaltfläche in der Leiste, um Chromiums eigene Entwicklerwerkzeuge für die Seite zu öffnen — Konsole, Netzwerk, Elemente, alles Gewohnte.
## Verwandt
- [Preview & Dev Servers](/preview/) — dieselben Tools für deinen lokalen Dev-Server
- [Vorschau & Dev-Server](/preview/) — deine laufende App öffnen, auch auf einem entfernten Rechner
- [Agenten-Steuerungswerkzeug](/agent-control-tool/) — Sitzungen, Worktrees und geplante Aufgaben aus dem Chat
+20 -17
View File
@@ -1,32 +1,35 @@
---
title: Vorschau & Entwicklungsserver
description: Öffne einen laufenden Entwicklungsserver direkt in OpenChamber.
title: Vorschau & Dev-Server
description: Öffne einen laufenden Dev-Server direkt in OpenChamber.
---
# Vorschau & Entwicklungsserver
# Vorschau & Dev-Server
Wenn du einen Entwicklungsserver startest, kann OpenChamber ihn direkt in der App öffnen statt in einem separaten Browser-Tab — so kannst du deine Seite neben dem Chat sehen, ihre Konsole aufzeichnen und Elemente anstupsen, um Fragen dazu zu stellen.
Wenn du einen Dev-Server startest, kann OpenChamber ihn direkt in der App öffnen statt in einem separaten Browser-Tab — so siehst du deine Seite neben dem Chat und kannst auf Elemente zeigen, um danach zu fragen.
## Eine Vorschau öffnen
## Einen Dev-Server öffnen
OpenChamber überwacht die Terminalausgabe auf eine lokale Adresse (die `Local:`-Zeile, die Werkzeuge wie Vite, Next.js oder Astro ausgeben). Sobald es eine findet:
Öffne das Browser-Panel über die Globus-Schaltfläche in der Kopfzeile. Läuft bereits ein Dev-Server, steht er dort in der Liste und ein Klick öffnet ihn — OpenChamber findet ihn daran, was auf deinem Rechner tatsächlich lauscht, also unabhängig davon, wie du ihn gestartet hast.
- erscheint im Terminal eine Schaltfläche **Open preview**
- öffnet eine [Projektaktion](/project-actions/) mit aktiviertem Auto-Open die Vorschau für dich
- kann auch ein lokaler Link in einer Chatnachricht sie öffnen
Ein Dev-Server öffnet sich außerdem automatisch, wenn:
Die Seite lädt im Seitenbereich. Nur lokale Adressen (auf deinem eigenen Rechner) können als Vorschau geöffnet werden.
- du im Terminal bei einer lokalen Adresse auf **Vorschau öffnen** drückst
- eine [Projektaktion](/project-actions/) mit aktiviertem Auto-Öffnen einen startet
- du einem lokalen Link in einer Chat-Nachricht folgst
## Konsole und Inspektion
Du kannst die Adresse jederzeit selbst eintippen. Ein bloßes `localhost:5173` wird als `http://` verstanden, das Schema musst du also nicht mitschreiben.
Im Vorschau-Bereich kannst du:
## Mit einem entfernten OpenChamber arbeiten
- die **Konsole** der Seite beobachten — Fehler, Warnungen und Protokolle, so gefiltert, wie du möchtest
- **inspect** einschalten, auf ein beliebiges Element klicken und eine Notiz dazu senden — Selektor, Stile, Position und ein Screenshot — direkt in den Chat
Läuft OpenChamber auf einem anderen Rechner, liegt sein Dev-Server ebenfalls auf *jenem* Rechner — `localhost` auf deinem Laptop führt ganz woandershin. Die Desktop-App erledigt das für dich: Sie öffnet einen lokalen Port, der die Verbindung zum entfernten Dev-Server durchreicht. Die Seite lädt ganz normal, mit funktionierendem Hot Reload und Entwicklerwerkzeugen. Du tippst weiterhin die Adresse, die du erwartest; die Technik dahinter bleibt dir aus dem Weg.
Das ist der schnellste Weg, dem Agenten "diese Schaltfläche hier" zu sagen, ohne sie beschreiben zu müssen.
Dafür brauchst du die Desktop-App. In einem Browser-Tab lassen sich nur Dev-Server auf deinem eigenen Rechner öffnen.
## Die Seite annotieren
Wie du auf Elemente zeigst, auf der Seite zeichnest und alles in den Chat schickst, steht unter [Browser-Panel](/desktop-browser/).
## Verwandt
- [Projektaktionen](/project-actions/) — einen Server automatisch öffnen, wenn du ihn startest
- [Desktop-Browser](/desktop-browser/) — dieselben Werkzeuge für jede Seite, auf dem Desktop
- [Projektaktionen](/project-actions/) — einen Server beim Start automatisch öffnen
- [Browser-Panel](/desktop-browser/) — Seiten annotieren und den Agenten steuern lassen
+51 -10
View File
@@ -1,22 +1,63 @@
---
title: Desktop Browser
description: Browse any page inside the desktop app, with inspect and console capture.
title: Browser Panel
description: Browse any page inside the app, annotate it, and let the agent drive it.
---
# Desktop Browser
# Browser Panel
The desktop app has a built-in browser so you can open any page right next to your chat, point at elements to ask about them, and capture the page's console. Open it from the globe button in the app header.
The browser panel opens any page right next to your chat. Open it from the globe button in the app header.
> The desktop browser is a **desktop-only** feature. On the web, the [preview](/preview/) panel offers the same inspect-and-console tools for your local dev server.
On the desktop app it is a real browser: your logins persist, hot reload works, and developer tools are one click away. In a web browser tab the panel can still display a page, but it cannot look inside one — the annotation tools below are desktop-only. The VS Code extension has no browser panel at all: VS Code is already an editor with a browser beside it, and everything that makes this panel worth having needs the desktop app.
## Inspect and annotate
Pages opened here cannot use your camera, microphone, or location: those requests are refused.
Turn on **inspect** and click any element on the page. OpenChamber captures a note about it — what it is, its styles, where it sits, and a screenshot — and attaches it to your chat message. It's the quickest way to tell the agent "this element, right here."
## The toolbar
## Console capture
The address bar remembers pages you have opened in this project and offers them as you type, matching part of an address or a page title. Arrow keys move through the list, Enter opens the highlighted entry, and the button on a row removes it.
The browser collects the page's console output — errors, warnings, and logs — so you can filter and read it without opening developer tools.
**Reload** is next to it, along with a **hard reload** that ignores the cache when a change refuses to show up, and zoom controls that scale the page only.
**Clear cookies** and **Clear cached data** apply to this panel alone. Your OpenChamber session and any other window are untouched.
## Annotate a page
Press **Annotate** and a toolbar appears over the page with three tools:
- **Element** — click an element. Clicking another moves the selection; clicking the same one again clears it.
- **Region** — drag a box around an area, when what you mean covers more than one element.
- **Draw** — sketch freehand over the page.
Write what you want in the box that appears beside your mark, and press **Attach** — or just press Enter. Your chat message gets a card with everything you marked, your note, and a screenshot of the visible page with your marks drawn on it — so you can say "this button, a bit rounder" instead of describing where it is.
The page itself is never modified — annotating marks what is there. `Esc` cancels and closes the toolbar.
## Let the agent drive
The agent can use the browser panel itself — opening a page, reading what is on it, clicking, typing, scrolling, and switching between mobile, tablet and desktop layouts — so it can check its own work instead of asking you to. You will see it happening in the panel.
The agent cannot run arbitrary code in the page. The browser keeps your real logins, so it is limited to the specific actions above.
It can also save a picture of what it is looking at into `.openchamber/screenshots/` in your project and show it to you in its reply. That is what makes a before-and-after possible, and the file stays there afterwards to attach to a pull request.
The browser actions are the **OpenChamber Web tool**, which can be turned on and off on its own in **Settings → General → OpenChamber Tools**.
This needs the desktop app: a page shown in a web browser tab cannot be driven.
## Size and appearance
Press the phone button to open the device bar. Pick a preset or type a width and
height, and the page is laid out at that size — scaled down to fit the panel
when it is bigger, but still measuring itself at the size you asked for.
The same bar forces the page to light or dark, so a theme can be checked without
changing anything on your machine. It leaves DevTools alone; a page can only
have one debugger attached, so close DevTools first if it is open.
## Developer tools
Press the terminal button in the toolbar to open Chromium's own developer tools for the page — console, network, elements, everything you would expect.
## Related
- [Preview & Dev Servers](/preview/) — the same tools for your local dev server
- [Preview & Dev Servers](/preview/) — opening your running app, including on a remote machine
- [Agent Control Tool](/agent-control-tool/) — sessions, worktrees and scheduled tasks from chat
@@ -28,7 +28,7 @@ La herramienta puede listar proyectos y preferencias de modelos, crear y continu
## Activar o desactivar la herramienta
Abre **Ajustes → General → OpenCode CLI**, cambia **Herramienta de control para agentes** y selecciona **Save + Reload**. El ajuste se aplica cuando se reinicia el servidor OpenCode gestionado.
Abre **Ajustes → General → Herramientas de OpenChamber** y cambia **Herramienta de control para agentes**. El ajuste se aplica cuando se reinicia el servidor OpenCode gestionado, que OpenChamber ofrece como **Apply & Restart**.
La herramienta no está disponible cuando OpenChamber se conecta a un servidor OpenCode externo mediante `OPENCODE_HOST` o skip-start, ni dentro de la extensión de VS Code. Las instalaciones web y de escritorio que usan el servidor OpenCode gestionado por OpenChamber la admiten automáticamente.
@@ -37,3 +37,4 @@ La herramienta no está disponible cuando OpenChamber se conecta a un servidor O
- [Tareas programadas](/es/scheduled-tasks/)
- [Sesiones de worktree](/es/worktrees/)
- [Objetivos de sesión](/es/session-goals/)
- [Panel del navegador](/es/desktop-browser/) — la herramienta OpenChamber Web, para ver una página y manejarla
@@ -1,22 +1,53 @@
---
title: Navegador de escritorio
description: Navega cualquier página dentro de la app de escritorio, con inspección y captura de consola.
title: Panel del navegador
description: Navega cualquier página dentro de la aplicación, anótala y deja que el agente la maneje.
---
# Navegador de escritorio
# Panel del navegador
La app de escritorio tiene un navegador integrado para que abras cualquier página justo al lado de tu chat, señales elementos para preguntar sobre ellos y captures la consola de la página. Ábrelo desde el botón del globo en el encabezado de la app.
El panel del navegador abre cualquier página justo al lado del chat. Ábrelo con el botón del globo de la cabecera.
> El navegador de escritorio es una función **solo de escritorio**. En la web, el panel de [vista previa](/es/preview/) ofrece las mismas herramientas de inspección y consola para tu servidor de desarrollo local.
En la aplicación de escritorio es un navegador de verdad: tus sesiones se mantienen, la recarga en caliente funciona y las herramientas de desarrollo están a un clic. En una pestaña del navegador el panel puede mostrar una página, pero no mirar dentro de ella: las herramientas de anotación de abajo son solo de escritorio.
## Inspecciona y anota
Las páginas que abras aquí no pueden usar tu cámara, tu micrófono ni tu ubicación: esas peticiones se rechazan.
Activa **inspect** y haz clic en cualquier elemento de la página. OpenChamber captura una nota sobre él —qué es, sus estilos, dónde se sitúa y una captura de pantalla— y la adjunta a tu mensaje del chat. Es la forma más rápida de decirle al agente "este elemento, justo aquí".
## La barra de herramientas
## Captura de consola
La barra de direcciones recuerda las páginas que has abierto en este proyecto y las ofrece mientras escribes, buscando en parte de la dirección o del título de la página. Las flechas recorren la lista, Enter abre la entrada resaltada y el botón de una fila la quita.
El navegador recopila la salida de la consola de la página —errores, advertencias y registros— para que puedas filtrarla y leerla sin abrir las herramientas de desarrollo.
Al lado está **Recargar**, junto con una **recarga forzada** que ignora la caché cuando un cambio se niega a aparecer, y los controles de zoom, que escalan solo la página.
**Borrar cookies** y **Borrar datos en caché** afectan únicamente a este panel. Tu sesión de OpenChamber y cualquier otra ventana quedan intactas.
## Anotar una página
Pulsa **Anotar** y aparecerá una barra sobre la página con tres herramientas:
- **Elemento** — haz clic en un elemento. Hacer clic en otro mueve la selección; volver a hacer clic en el mismo la quita.
- **Región** — arrastra un recuadro alrededor de una zona cuando te refieras a más de un elemento.
- **Dibujar** — traza a mano alzada sobre la página.
Escribe lo que quieres en el cuadro que aparece junto a tu marca y pulsa **Adjuntar**, o simplemente Enter. Tu mensaje recibe una tarjeta con todo lo que has marcado, tu nota y una captura de la página visible con tus marcas dibujadas encima — así puedes decir "este botón, un poco más redondeado" en vez de describir dónde está.
La página en sí nunca se modifica: anotar solo marca lo que ya está ahí. `Esc` cancela y cierra la barra.
## Dejar que el agente maneje
El agente puede usar el panel del navegador por su cuenta — abrir una página, leer lo que hay en ella, hacer clic, escribir, desplazarse y alternar entre diseño móvil, de tableta y de escritorio — para comprobar su propio trabajo en lugar de pedírtelo a ti. Lo verás ocurrir en el panel.
El agente no puede ejecutar código arbitrario en la página. El navegador conserva tus sesiones reales, así que se limita a las acciones anteriores.
También puede guardar una imagen de lo que está viendo en `.openchamber/screenshots/` de tu proyecto y mostrártela en su respuesta. Eso es lo que hace posible un antes y después, y el archivo se queda ahí para adjuntarlo a un pull request.
Las acciones del navegador son la **herramienta OpenChamber Web**, que se activa y desactiva por separado en **Ajustes → General → Herramientas de OpenChamber**.
Esto necesita la aplicación de escritorio: una página mostrada en una pestaña del navegador no se puede controlar.
## Herramientas de desarrollo
Pulsa el botón de terminal de la barra para abrir las herramientas de desarrollo propias de Chromium — consola, red, elementos, todo lo habitual.
## Relacionado
- [Vista previa y servidores de desarrollo](/es/preview/) — las mismas herramientas para tu servidor de desarrollo local
- [Vista previa y servidores de desarrollo](/preview/) — abrir tu aplicación en marcha, también en una máquina remota
- [Herramienta de control para agentes](/es/agent-control-tool/) — sesiones, worktrees y tareas programadas desde el chat
+17 -14
View File
@@ -5,28 +5,31 @@ description: Abre un servidor de desarrollo en marcha dentro de OpenChamber.
# Vista previa y servidores de desarrollo
Cuando inicias un servidor de desarrollo, OpenChamber puede abrirlo dentro de la propia app en lugar de en una pestaña de navegador aparte, para que veas tu sitio junto al chat, captures su consola y señales elementos para preguntar sobre ellos.
Cuando arrancas un servidor de desarrollo, OpenChamber puede abrirlo dentro de la propia aplicación en lugar de en una pestaña aparte — así ves tu sitio junto al chat y puedes señalar elementos para preguntar por ellos.
## Abre una vista previa
## Abrir un servidor de desarrollo
OpenChamber observa la salida de la terminal en busca de una dirección local (la línea `Local:` que imprimen herramientas como Vite, Next.js o Astro). Cuando detecta una:
Abre el panel del navegador con el botón del globo de la cabecera. Si ya hay un servidor en marcha, aparece en la lista y se abre con un clic: OpenChamber lo encuentra mirando qué está escuchando de verdad en tu máquina, así que funciona sin importar cómo lo hayas arrancado.
- en la terminal aparece un botón **Open preview**
- una [acción de proyecto](/es/project-actions/) con la apertura automática activada la abre por ti
- un enlace local en un mensaje del chat también puede abrirla
Un servidor de desarrollo también se abre solo cuando:
El sitio se carga en el panel lateral. Solo se pueden previsualizar direcciones locales (en tu propia máquina).
- pulsas **Abrir vista previa** sobre una dirección local en la terminal
- una [acción de proyecto](/project-actions/) con apertura automática arranca uno
- sigues un enlace local en un mensaje del chat
## Consola e inspección
Siempre puedes escribir la dirección a mano. Un simple `localhost:5173` se entiende como `http://`, así que no hace falta escribir el esquema.
En el panel de vista previa puedes:
## Trabajar con un OpenChamber remoto
- ver la **consola** de la página —errores, advertencias y registros— filtrada como prefieras
- activar **inspect**, hacer clic en cualquier elemento y enviar una nota sobre él —selector, estilos, posición y una captura de pantalla— directamente al chat
Cuando OpenChamber corre en otra máquina, su servidor de desarrollo está en *esa* máquina — `localhost` en tu portátil apunta a otro sitio completamente distinto. La aplicación de escritorio se encarga: abre un puerto local que lleva la conexión hasta el servidor remoto, de modo que la página carga con normalidad, con recarga en caliente y herramientas de desarrollo funcionando. Tú sigues escribiendo la dirección que esperas; la fontanería no te estorba.
Esta es la forma más rápida de decirle al agente "este botón, aquí" sin describirlo.
Esto requiere la aplicación de escritorio. En una pestaña del navegador solo se pueden abrir servidores de tu propia máquina.
## Anotar la página
Consulta [Panel del navegador](/desktop-browser/) para señalar elementos, dibujar sobre la página y enviarlo todo al chat.
## Relacionado
- [Acciones de proyecto](/es/project-actions/) — abre automáticamente un servidor al iniciarlo
- [Navegador de escritorio](/es/desktop-browser/) — las mismas herramientas para cualquier página, en el escritorio
- [Acciones de proyecto](/project-actions/) — abrir un servidor automáticamente al arrancarlo
- [Panel del navegador](/desktop-browser/) — anotar páginas y dejar que el agente las maneje
@@ -28,7 +28,7 @@ Loutil peut répertorier les projets et les préférences de modèles, créer
## Activer ou désactiver loutil
Ouvrez **Paramètres → Général → OpenCode CLI**, modifiez **Outil de contrôle pour les agents**, puis sélectionnez **Save + Reload**. Le réglage sapplique après le redémarrage du serveur OpenCode géré.
Ouvrez **Paramètres → Général → Outils OpenChamber** et modifiez **Outil de contrôle pour les agents**. Le réglage sapplique au redémarrage du serveur OpenCode géré, que OpenChamber propose sous **Apply & Restart**.
Loutil nest pas disponible quand OpenChamber se connecte à un serveur OpenCode externe avec `OPENCODE_HOST` ou skip-start, ni dans lextension VS Code. Les installations desktop et web utilisant le serveur OpenCode géré par OpenChamber le prennent automatiquement en charge.
@@ -37,3 +37,4 @@ Loutil nest pas disponible quand OpenChamber se connecte à un serveur Ope
- [Tâches planifiées](/fr/scheduled-tasks/)
- [Sessions worktree](/fr/worktrees/)
- [Objectifs de session](/fr/session-goals/)
- [Panneau navigateur](/fr/desktop-browser/) — l'outil OpenChamber Web, pour consulter une page et la piloter
@@ -1,22 +1,53 @@
---
title: Navigateur desktop
description: Parcourez nimporte quelle page dans lapplication desktop, avec inspection et capture de console.
title: Panneau navigateur
description: Parcourez n'importe quelle page dans l'application, annotez-la et laissez l'agent la piloter.
---
# Navigateur desktop
# Panneau navigateur
Lapplication desktop possède un navigateur intégré pour ouvrir nimporte quelle page juste à côté de votre chat, pointer des éléments pour poser des questions à leur sujet et capturer la console de la page. Ouvrez-le avec le bouton globe dans len-tête de lapplication.
Le panneau navigateur ouvre n'importe quelle page juste à côté de votre discussion. Ouvrez-le depuis le bouton globe de l'en-tête.
> Le navigateur desktop est une fonctionnalité **desktop uniquement**. Sur le web, le panneau [aperçu](/preview/) offre les mêmes outils dinspection et de console pour votre serveur de dev local.
Dans l'application de bureau, c'est un vrai navigateur : vos connexions persistent, le rechargement à chaud fonctionne et les outils de développement sont à un clic. Dans un onglet de navigateur, le panneau affiche bien une page mais ne peut pas regarder à l'intérieur — les outils d'annotation ci-dessous sont réservés au bureau.
## Inspecter et annoter
Les pages ouvertes ici ne peuvent pas utiliser votre caméra, votre micro ni votre position : ces demandes sont refusées.
Activez **inspect** et cliquez sur nimporte quel élément de la page. OpenChamber capture une note à son sujet — ce que cest, ses styles, sa position et une capture d’écran — puis lattache à votre message de chat. Cest le moyen le plus rapide de dire à lagent « cet élément, juste ici ».
## La barre d'outils
## Capture de console
La barre d'adresse retient les pages que vous avez ouvertes dans ce projet et les propose pendant la saisie, en cherchant dans une partie de l'adresse ou du titre de la page. Les flèches parcourent la liste, Entrée ouvre l'entrée surlignée, et le bouton d'une ligne la retire.
Le navigateur collecte la sortie console de la page — erreurs, avertissements et logs — pour que vous puissiez la filtrer et la lire sans ouvrir les outils développeur.
À côté se trouve **Recharger**, ainsi qu'un **rechargement forcé** qui ignore le cache quand un changement refuse d'apparaître, et des commandes de zoom qui agrandissent la page seule.
## Pages liées
**Effacer les cookies** et **Effacer les données en cache** ne concernent que ce panneau. Votre session OpenChamber et les autres fenêtres n'y touchent pas.
- [Aperçu et serveurs de dev](/preview/) — les mêmes outils pour votre serveur de dev local
## Annoter une page
Appuyez sur **Annoter** : une barre apparaît au-dessus de la page avec trois outils.
- **Élément** — cliquez sur un élément. Cliquer sur un autre déplace la sélection ; recliquer sur le même la retire.
- **Zone** — tracez un cadre autour d'une portion lorsque votre remarque porte sur plusieurs éléments.
- **Dessin** — croquez à main levée par-dessus la page.
Écrivez votre demande dans le champ qui apparaît à côté de votre marque, puis appuyez sur **Joindre** — ou simplement sur Entrée. Votre message reçoit une carte avec tout ce que vous avez marqué, votre note et une capture de la page visible avec vos marques dessinées dessus — vous pouvez donc dire « ce bouton, un peu plus arrondi » au lieu de décrire où il se trouve.
La page elle-même n'est jamais modifiée : annoter ne fait que marquer ce qui s'y trouve. `Échap` annule et ferme la barre.
## Laisser l'agent piloter
L'agent peut se servir lui-même du panneau navigateur — ouvrir une page, lire ce qu'elle contient, cliquer, saisir du texte, faire défiler et basculer entre les mises en page mobile, tablette et bureau — afin de vérifier son propre travail plutôt que de vous le demander. Vous le voyez faire dans le panneau.
L'agent ne peut pas exécuter de code arbitraire dans la page. Le navigateur conserve vos vraies connexions ; il s'en tient donc aux actions ci-dessus.
Il peut aussi enregistrer une image de ce qu'il regarde dans `.openchamber/screenshots/` de votre projet et vous la montrer dans sa réponse. C'est ce qui rend un avant-après possible, et le fichier reste ensuite disponible pour l'attacher à une pull request.
Les actions du navigateur constituent l'**outil OpenChamber Web**, qui s'active et se désactive séparément dans **Réglages → Général → Outils OpenChamber**.
Cela nécessite l'application de bureau : une page affichée dans un onglet de navigateur ne peut pas être pilotée.
## Outils de développement
Appuyez sur le bouton terminal de la barre pour ouvrir les outils de développement de Chromium pour la page — console, réseau, éléments, tout ce à quoi vous vous attendez.
## Voir aussi
- [Aperçu et serveurs de développement](/preview/) — ouvrir votre application en cours, y compris sur une machine distante
- [Outil de contrôle pour les agents](/fr/agent-control-tool/) — sessions, worktrees et tâches planifiées depuis la discussion
+21 -18
View File
@@ -1,32 +1,35 @@
---
title: Aperçu et serveurs de dev
description: Ouvrez un serveur de dev en cours dexécution dans OpenChamber.
title: Aperçu et serveurs de développement
description: Ouvrez un serveur de développement en cours d'exécution dans OpenChamber.
---
# Aperçu et serveurs de dev
# Aperçu et serveurs de développement
Quand vous démarrez un serveur de dev, OpenChamber peut louvrir directement dans lapplication au lieu dun onglet de navigateur séparé — vous voyez ainsi votre site à côté du chat, vous capturez sa console et vous pouvez pointer des éléments pour poser des questions à leur sujet.
Quand vous lancez un serveur de développement, OpenChamber peut l'ouvrir directement dans l'application plutôt que dans un onglet séparé — vous voyez ainsi votre site à côté de la discussion et pouvez désigner des éléments pour poser des questions à leur sujet.
## Ouvrir un aperçu
## Ouvrir un serveur de développement
OpenChamber surveille la sortie du terminal pour détecter une adresse locale (la ligne `Local:` affichée par des outils comme Vite, Next.js ou Astro). Quand il en trouve une :
Ouvrez le panneau navigateur depuis le bouton globe de l'en-tête. Si un serveur tourne déjà, il apparaît dans la liste et un clic suffit à l'ouvrir : OpenChamber le repère à partir de ce qui écoute réellement sur votre machine, quelle que soit la façon dont vous l'avez lancé.
- dans le terminal, un bouton **Ouvrir laperçu** apparaît
- une [action de projet](/project-actions/) avec louverture automatique activée louvre pour vous
- un lien local dans un message de chat peut aussi louvrir
Un serveur s'ouvre également tout seul quand :
Le site se charge dans le panneau latéral. Seules les adresses locales (sur votre propre machine) peuvent être prévisualisées.
- vous appuyez sur **Ouvrir l'aperçu** sur une adresse locale dans le terminal
- une [action de projet](/project-actions/) avec ouverture automatique en démarre un
- vous suivez un lien local dans un message de la discussion
## Console et inspection
Vous pouvez toujours saisir l'adresse vous-même. Un simple `localhost:5173` est compris comme `http://`, inutile donc d'écrire le schéma.
Dans le panneau daperçu, vous pouvez :
## Travailler avec un OpenChamber distant
- regarder la **console** de la page — erreurs, avertissements et logs, filtrés comme vous le voulez
- activer **inspect**, cliquer sur nimporte quel élément et envoyer une note à son sujet — sélecteur, styles, position et capture d’écran — directement dans le chat
Quand OpenChamber tourne sur une autre machine, son serveur de développement s'y trouve aussi — `localhost` sur votre portable désigne tout autre chose. L'application de bureau s'en charge : elle ouvre un port local qui achemine la connexion jusqu'au serveur distant, si bien que la page se charge normalement, avec rechargement à chaud et outils de développement fonctionnels. Vous continuez à saisir l'adresse attendue ; la tuyauterie reste hors de votre chemin.
Cest le moyen le plus rapide de dire à lagent « ce bouton, ici » sans devoir le décrire.
Cela nécessite l'application de bureau. Dans un onglet de navigateur, seuls les serveurs de votre propre machine peuvent être ouverts.
## Pages liées
## Annoter la page
- [Actions de projet](/project-actions/) — ouvrir automatiquement un serveur quand vous le démarrez
- [Navigateur desktop](/desktop-browser/) — les mêmes outils pour nimporte quelle page, sur desktop
Voyez [Panneau navigateur](/desktop-browser/) pour désigner des éléments, dessiner sur la page et envoyer le tout dans la discussion.
## Voir aussi
- [Actions de projet](/project-actions/) — ouvrir automatiquement un serveur à son démarrage
- [Panneau navigateur](/desktop-browser/) — annoter les pages et laisser l'agent les piloter
@@ -28,7 +28,7 @@ description: エージェントがチャットから OpenChamber のセッショ
## ツールを有効または無効にする
**設定 → 一般 → OpenCode CLI** を開き、**エージェント制御ツール**を変更して、**Save + Reload** を選択します。この設定は、管理対象の OpenCode サーバーが再起動した後に反映されます。
**設定 → 一般 → OpenChamber ツール** を開き、**エージェント制御ツール**を変更します。この設定は、管理対象の OpenCode サーバーが再起動すると反映されます。再起動は OpenChamber が **Apply & Restart** として案内します。
OpenChamber が `OPENCODE_HOST` または skip-start で外部 OpenCode サーバーに接続している場合や、VS Code 拡張機能内では、このツールを利用できません。OpenChamber が管理する OpenCode サーバーを使用するデスクトップ版と Web 版では自動的に利用できます。
@@ -37,3 +37,4 @@ OpenChamber が `OPENCODE_HOST` または skip-start で外部 OpenCode サー
- [スケジュールタスク](/ja/scheduled-tasks/)
- [Worktree セッション](/ja/worktrees/)
- [セッションゴール](/ja/session-goals/)
- [ブラウザパネル](/ja/desktop-browser/) — ページを見て操作するための OpenChamber Web ツール
@@ -1,22 +1,53 @@
---
title: デスクトップブラウザ
description: デスクトップアプリ内で任意のページを開き、検査とコンソール取得を使います。
title: ブラウザパネル
description: アプリ内で任意のページを開き、注釈を付け、エージェントに操作させます。
---
# デスクトップブラウザ
# ブラウザパネル
デスクトップアプリには組み込みブラウザがあります。チャットのすぐ横で任意のページを開き、要素を指して質問したり、ページのコンソールを取得したりできます。アプリヘッダーの地球儀ボタンから開きます
ブラウザパネルはチャットのすぐ隣に任意のページを開きます。ヘッダーの地球儀ボタンから開いてください
> デスクトップブラウザは**デスクトップ専用**機能です。Web では、[プレビュー](/preview/) パネルがローカル開発サーバー向けに同じ検査・コンソールツールを提供します。
デスクトップアプリでは本物のブラウザです。ログイン状態は保持され、ホットリロードが動き、開発者ツールはワンクリックで開けます。ブラウザのタブでもページの表示はできますが、中を覗くことはできません。以下の注釈ツールはデスクトップ専用です。
## 検査して注釈を付ける
ここで開いたページは、カメラ・マイク・位置情報を使えません。これらの要求は拒否されます。
**inspect** をオンにして、ページ上の任意の要素をクリックします。OpenChamber はその要素について、何であるか、スタイル、位置、スクリーンショットを含むメモを取得し、チャットメッセージに添付します。エージェントに「この要素、ここ」と伝える最短の方法です。
## ツールバー
## コンソール取得
アドレスバーはこのプロジェクトで開いたページを覚えていて、入力中に候補として出します。アドレスの一部でもページタイトルの一部でも一致します。矢印キーで候補を移動し、Enter で選択中の候補を開き、行のボタンでその候補を消せます。
ブラウザはページのコンソール出力(エラー、警告、ログ)を集めるので、開発者ツールを開かずにフィルターして読めます。
隣には **再読み込み** があり、変更がどうしても反映されないときのためにキャッシュを無視する **強制再読み込み**、そしてページだけを拡大縮小するズーム操作も並びます。
**Cookie を消去** と **キャッシュを消去** はこのパネルにだけ効きます。OpenChamber のセッションや他のウィンドウには影響しません。
## ページに注釈を付ける
**注釈** を押すと、ページの上に3つのツールを備えたバーが表示されます。
- **要素** — 要素をクリックします。別の要素をクリックすると選択が移り、同じ要素をもう一度クリックすると解除されます。
- **範囲** — 複数の要素にまたがる話をしたいときは、領域をドラッグで囲みます。
- **描画** — ページの上にフリーハンドで描きます。
印の隣に現れる入力欄に希望を書き、**添付** を押します。Enter でも送れます。チャットメッセージには、印を付けた内容、あなたのメモ、表示中のページに印を描き込んだスクリーンショットを含むカードが付きます。「このボタン、もう少し角を丸く」と言えば済み、場所を説明する必要はありません。
ページ自体は変更されません。注釈はそこにあるものに印を付けるだけです。`Esc` で取り消してツールバーを閉じます。
## エージェントに操作させる
エージェントはブラウザパネルを自分で使えます。ページを開き、内容を読み、クリックし、文字を入力し、スクロールし、モバイル・タブレット・デスクトップのレイアウトを切り替えて、自分の作業をあなたに頼まず自分で確認します。その様子はパネルで見えます。
エージェントがページ内で任意のコードを実行することはできません。ブラウザは実際のログイン状態を保持しているため、上記の操作に限定されています。
見ている内容をプロジェクト内の `.openchamber/screenshots/` に画像として保存し、返答の中で見せることもできます。ビフォー・アフターができるのはこのためで、ファイルはその後もプルリクエストに添付できる形で残ります。
ブラウザ操作は **OpenChamber Web ツール** で、**設定 → 一般 → OpenChamber ツール** から単独でオン・オフできます。
これにはデスクトップアプリが必要です。ブラウザのタブに表示したページは操作できません。
## 開発者ツール
バーのターミナルボタンを押すと、そのページに対する Chromium 本来の開発者ツールが開きます。コンソール、ネットワーク、要素など、期待どおりのものがすべて使えます。
## 関連
- [プレビューと開発サーバー](/preview/) — ローカル開発サーバー向けの同じツール
- [プレビューと開発サーバー](/preview/) — リモートマシン上のものも含め、実行中のアプリを開く
- [エージェント制御ツール](/ja/agent-control-tool/) — チャットからセッション・worktree・スケジュールタスクを扱う
+18 -15
View File
@@ -1,32 +1,35 @@
---
title: プレビューと開発サーバー
description: 実行中の開発サーバーを OpenChamber で開きます。
description: 実行中の開発サーバーを OpenChamber の中で開きます。
---
# プレビューと開発サーバー
開発サーバーを起動すると、OpenChamber は別のブラウザタブではなくアプリ内で直接開けます。サイトをチャットの横で見ながら、コンソールを取得し、要素を指して質問できます。
開発サーバーを起動すると、OpenChamber は別のブラウザタブではなくアプリ内でそれを開けます。チャットの隣にサイトを表示したまま、要素を指し示して質問できます。
## プレビューを開く
## 開発サーバーを開く
OpenChamber はターミナル出力からローカルアドレスを監視します(Vite、Next.js、Astro などが表示する `Local:` 行)。見つけると次のことができます。
ヘッダーの地球儀ボタンからブラウザパネルを開きます。開発サーバーがすでに動いていれば一覧に表示され、クリックひとつで開けます。OpenChamber はマシン上で実際に待ち受けているものから見つけるので、どうやって起動したかに関係なく機能します。
- ターミナルに **Open preview** ボタンが表示されます
- 自動オープンを有効にした [プロジェクトアクション](/project-actions/) が開きます
- チャットメッセージ内のローカルリンクからも開けます
次の場合にも開発サーバーは自動で開きます
サイトはサイドパネルに読み込まれます。プレビューできるのはローカルアドレス(あなたのマシン上)のみです。
- ターミナルのローカルアドレスで **プレビューを開く** を押したとき
- 自動オープンを有効にした[プロジェクトアクション](/project-actions/)が起動したとき
- チャットメッセージ内のローカルリンクをたどったとき
## コンソールと検査
アドレスはいつでも自分で入力できます。`localhost:5173` のようにスキームを省いた入力は `http://` として扱われます。
プレビューパネルでは次のことができます。
## リモートの OpenChamber を使う場合
- ページの **console** を見る — エラー、警告、ログを好きなようにフィルターできます
- **inspect** をオンにし、任意の要素をクリックして、そのメモ(セレクター、スタイル、位置、スクリーンショット)をそのままチャットへ送る
OpenChamber が別のマシンで動いているとき、開発サーバーも*そちら*のマシンにあります。手元のノートPCの `localhost` はまったく別の場所を指します。デスクトップアプリはこれを引き受けます。ローカルポートを開いてリモートの開発サーバーまで接続を通すので、ページは普通に読み込まれ、ホットリロードも開発者ツールも動きます。入力するアドレスは期待どおりのままで、裏側の仕組みが邪魔をすることはありません。
これは「このボタン、ここ」とエージェントに伝える最速の方法です。
これにはデスクトップアプリが必要です。ブラウザのタブでは、自分のマシン上の開発サーバーだけを開けます。
## ページに注釈を付ける
要素を指し示す、ページに描き込む、それらをまとめてチャットへ送る方法は[ブラウザパネル](/desktop-browser/)を参照してください。
## 関連
- [プロジェクトアクション](/project-actions/) — サーバー起動時に自動で開く
- [デスクトップブラウザ](/desktop-browser/) — デスクトップで任意のページに同じツールを使う
- [プロジェクトアクション](/project-actions/) — 起動時にサーバーを自動で開く
- [ブラウザパネル](/desktop-browser/) — ページへの注釈とエージェントによる操作
@@ -28,7 +28,7 @@ description: 에이전트가 채팅에서 OpenChamber 세션, worktree, 예약
## 도구 켜기 또는 끄기
**설정 → 일반 → OpenCode CLI**를 열고 **에이전트 제어 도구**를 변경한 다음 **Save + Reload**를 선택하세요. 관리형 OpenCode 서버가 다시 시작된 후 설정이 적용됩니다.
**설정 → 일반 → OpenChamber 도구**를 열고 **에이전트 제어 도구**를 변경하세요. 설정은 관리형 OpenCode 서버가 다시 시작되면 적용되며, OpenChamber가 **Apply & Restart** 로 안내합니다.
OpenChamber가 `OPENCODE_HOST` 또는 skip-start를 통해 외부 OpenCode 서버에 연결된 경우와 VS Code 확장에서는 이 도구를 사용할 수 없습니다. OpenChamber의 관리형 OpenCode 서버를 사용하는 데스크톱 및 웹 설치에서는 자동으로 지원됩니다.
@@ -37,3 +37,4 @@ OpenChamber가 `OPENCODE_HOST` 또는 skip-start를 통해 외부 OpenCode 서
- [예약 작업](/ko/scheduled-tasks/)
- [Worktree 세션](/ko/worktrees/)
- [세션 목표](/ko/session-goals/)
- [브라우저 패널](/ko/desktop-browser/) — 페이지를 보고 조작하는 OpenChamber Web 도구
@@ -1,22 +1,53 @@
---
title: 데스크톱 브라우저
description: 검사 및 콘솔 캡처 기능과 함께 데스크톱 앱 안에서 임의의 페이지를 탐색하세요.
title: 브라우저 패널
description: 앱 안에서 아무 페이지나 열고 주석을 달며 에이전트가 조작하게 합니다.
---
# 데스크톱 브라우저
# 브라우저 패널
데스크톱 앱에는 내장 브라우저가 있어 채팅 바로 옆에서 임의의 페이지를 열고, 요소를 가리켜 질문하고, 페이지의 콘솔을 캡처할 수 있습니다. 앱 헤더의 지구본 버튼에서 엽니다.
브라우저 패널은 채팅 바로 옆에 아무 페이지나 엽니다. 앱 헤더의 지구본 버튼으로 여세요.
> 데스크톱 브라우저는 **데스크톱 전용** 기능입니다. 웹에서는 [미리보기](/ko/preview/) 패널이 로컬 개발 서버에 대해 동일한 검사 및 콘솔 도구를 제공합니다.
데스크톱 앱에서는 진짜 브라우저입니다. 로그인 상태가 유지되고 핫 리로드가 동작하며 개발자 도구도 클릭 한 번이면 열립니다. 브라우저 탭에서도 페이지를 보여줄 수는 있지만 내부를 들여다볼 수는 없습니다. 아래 주석 도구는 데스크톱 전용입니다.
## 검사 및 주석
여기서 연 페이지는 카메라, 마이크, 위치를 사용할 수 없습니다. 그런 요청은 거부됩니다.
**inspect**를 켜고 페이지의 임의 요소를 클릭합니다. OpenChamber가 그것이 무엇인지, 스타일, 위치, 스크린샷을 담은 메모를 캡처해 채팅 메시지에 첨부합니다. 에이전트에게 "바로 여기 이 요소"라고 알리는 가장 빠른 방법입니다.
## 도구 모음
## 콘솔 캡처
주소창은 이 프로젝트에서 열었던 페이지를 기억해 두었다가 입력하는 동안 제안합니다. 주소의 일부나 페이지 제목의 일부와 맞춰 봅니다. 화살표 키로 목록을 이동하고, Enter로 선택한 항목을 열고, 행의 버튼으로 목록에서 지웁니다.
브라우저는 페이지의 콘솔 출력(오류, 경고, 로그)을 수집하므로 개발자 도구를 열지 않고도 필터링하여 읽을 수 있습니다.
그 옆에는 **새로 고침**이 있고, 변경이 도무지 반영되지 않을 때 캐시를 무시하는 **강력 새로 고침**, 그리고 페이지만 확대·축소하는 확대 조절이 있습니다.
## 관련 항목
**쿠키 지우기**와 **캐시 데이터 지우기**는 이 패널에만 적용됩니다. OpenChamber 세션이나 다른 창은 그대로입니다.
- [Preview & Dev Servers](/ko/preview/) — 로컬 개발 서버에 대한 동일한 도구
## 페이지에 주석 달기
**주석** 을 누르면 페이지 위에 세 가지 도구가 있는 막대가 나타납니다.
- **요소** — 요소를 클릭합니다. 다른 요소를 클릭하면 선택이 옮겨가고, 같은 요소를 다시 클릭하면 해제됩니다.
- **영역** — 여러 요소에 걸친 이야기를 할 때는 해당 부분을 드래그해 감쌉니다.
- **그리기** — 페이지 위에 자유롭게 스케치합니다.
표시 옆에 나타나는 입력란에 원하는 내용을 적고 **첨부** 를 누르세요. Enter 로도 됩니다. 채팅 메시지에 표시한 모든 것, 남긴 메모, 표시 중인 페이지에 표시를 그려 넣은 스크린샷이 담긴 카드가 붙습니다. 위치를 설명하는 대신 "이 버튼, 조금 더 둥글게"라고 말하면 됩니다.
페이지 자체는 변경되지 않습니다. 주석은 있는 것을 표시할 뿐입니다. `Esc` 로 취소하고 도구 막대를 닫습니다.
## 에이전트에게 조작 맡기기
에이전트는 브라우저 패널을 직접 쓸 수 있습니다. 페이지를 열고, 내용을 읽고, 클릭하고, 입력하고, 스크롤하고, 모바일·태블릿·데스크톱 레이아웃을 바꿔 가며 자기 작업을 여러분에게 부탁하지 않고 스스로 확인합니다. 그 과정은 패널에서 보입니다.
에이전트가 페이지에서 임의의 코드를 실행할 수는 없습니다. 브라우저가 실제 로그인 상태를 유지하므로 위에 적힌 동작으로만 제한됩니다.
보고 있는 화면을 프로젝트의 `.openchamber/screenshots/` 에 이미지로 저장하고 답변에서 보여 줄 수도 있습니다. 전후 비교가 가능한 이유가 이것이며, 파일은 그대로 남아 풀 리퀘스트에 첨부할 수 있습니다.
브라우저 동작은 **OpenChamber Web 도구**이며, **설정 → 일반 → OpenChamber 도구** 에서 따로 켜고 끌 수 있습니다.
이 기능에는 데스크톱 앱이 필요합니다. 브라우저 탭에 표시된 페이지는 조작할 수 없습니다.
## 개발자 도구
막대의 터미널 버튼을 누르면 해당 페이지에 대한 Chromium 자체 개발자 도구가 열립니다. 콘솔, 네트워크, 요소 등 기대하는 모든 기능을 쓸 수 있습니다.
## 관련 문서
- [미리보기와 개발 서버](/preview/) — 원격 컴퓨터의 것을 포함해 실행 중인 앱 열기
- [에이전트 제어 도구](/ko/agent-control-tool/) — 채팅에서 세션, worktree, 예약 작업 다루기
+21 -18
View File
@@ -1,32 +1,35 @@
---
title: 미리보기 개발 서버
description: 실행 중인 개발 서버를 OpenChamber 안에서 여세요.
title: 미리보기 개발 서버
description: 실행 중인 개발 서버를 OpenChamber 안에서 엽니다.
---
# 미리보기 개발 서버
# 미리보기 개발 서버
개발 서버를 시작하면 OpenChamber 별도의 브라우저 탭이 아니라 앱 안에서 바로 열 수 있습니다. 그래서 채팅 옆에 사이트를 보고, 콘솔을 캡처하고, 요소를 가리켜 질문할 수 있습니다.
개발 서버를 띄우면 OpenChamber 별도의 브라우저 탭 대신 앱 안에서 바로 열어 줍니다. 채팅 옆에 사이트를 두고 요소를 가리키며 물어볼 수 있습니다.
## 미리보기 열기
## 개발 서버 열기
OpenChamber는 터미널 출력에서 로컬 주소(Vite, Next.js, Astro 같은 도구가 출력하는 `Local:` 줄)를 감시합니다. 발견하면 다음과 같이 동작합니다.
앱 헤더의 지구본 버튼으로 브라우저 패널을 엽니다. 개발 서버가 이미 실행 중이면 목록에 나타나고 클릭 한 번으로 열립니다. OpenChamber는 컴퓨터에서 실제로 수신 대기 중인 것을 보고 찾아내므로, 어떻게 실행했든 상관없이 동작합니다.
- 터미널에 **Open preview** 버튼이 나타납니다
- auto-open이 켜진 [프로젝트 액션](/ko/project-actions/)이 대신 열어줍니다
- 채팅 메시지의 로컬 링크로도 열 수 있습니다
다음 경우에도 개발 서버가 자동으로 열립니다.
사이트는 사이드 패널에 로드됩니다. 로컬 주소(사용자 자신의 컴퓨터)만 미리볼 수 있습니다.
- 터미널의 로컬 주소에서 **미리보기 열기** 를 누를 때
- 자동 열기를 켠 [프로젝트 작업](/project-actions/)이 서버를 시작할 때
- 채팅 메시지의 로컬 링크를 따라갈 때
## 콘솔과 검사
주소는 언제든 직접 입력할 수 있습니다. `localhost:5173` 처럼 스킴을 생략하면 `http://` 로 처리됩니다.
미리보기 패널에서 다음을 할 수 있습니다.
## 원격 OpenChamber와 함께 쓰기
- 페이지의 **console**(오류, 경고, 로그)을 원하는 대로 필터링하여 확인
- **inspect**를 켜고 임의의 요소를 클릭한 뒤 그에 대한 메모(선택자, 스타일, 위치, 스크린샷)를 바로 채팅으로 전송
OpenChamber가 다른 컴퓨터에서 실행 중이면 개발 서버도 *그* 컴퓨터에 있습니다. 노트북의 `localhost`는 전혀 다른 곳을 가리키죠. 데스크톱 앱이 이를 대신 처리합니다. 로컬 포트를 열어 원격 개발 서버까지 연결을 이어 주므로 페이지가 평소처럼 로드되고 핫 리로드와 개발자 도구도 동작합니다. 여러분은 기대한 주소를 그대로 입력하면 되고, 내부 배관은 눈에 띄지 않습니다.
것은 설명 없이 에이전트에게 "여기 이 버튼"이라고 알리는 가장 빠른 방법입니다.
기능에는 데스크톱 앱이 필요합니다. 브라우저 탭에서는 자신의 컴퓨터에 있는 개발 서버만 열 수 있습니다.
## 관련 항목
## 페이지에 주석 달기
- [Project Actions](/ko/project-actions/) — 서버를 시작할 때 자동으로 열기
- [Desktop Browser](/ko/desktop-browser/) — 데스크톱에서 임의의 페이지에 동일한 도구 사용
요소를 가리키고, 페이지에 그리고, 스타일 변경을 시험해 보고, 이 모두를 채팅으로 보내는 방법은 [브라우저 패널](/desktop-browser/)을 참고하세요.
## 관련 문서
- [프로젝트 작업](/project-actions/) — 서버 시작 시 자동으로 열기
- [브라우저 패널](/desktop-browser/) — 페이지 주석과 에이전트 조작
@@ -28,7 +28,7 @@ Narzędzie może wyświetlać projekty i preferencje modeli, tworzyć i kontynuo
## Włączanie i wyłączanie narzędzia
Otwórz **Ustawienia → Ogólne → OpenCode CLI**, zmień **Narzędzie sterowania dla agentów**, a następnie wybierz **Save + Reload**. Ustawienie zacznie działać po ponownym uruchomieniu zarządzanego serwera OpenCode.
Otwórz **Ustawienia → Ogólne → Narzędzia OpenChamber** i zmień **Narzędzie sterowania dla agentów**. Ustawienie zacznie działać po ponownym uruchomieniu zarządzanego serwera OpenCode, które OpenChamber proponuje jako **Apply & Restart**.
Narzędzie nie jest dostępne, gdy OpenChamber łączy się z zewnętrznym serwerem OpenCode przez `OPENCODE_HOST` lub skip-start, ani w rozszerzeniu VS Code. Instalacje desktopowe i webowe korzystające z serwera OpenCode zarządzanego przez OpenChamber obsługują je automatycznie.
@@ -37,3 +37,4 @@ Narzędzie nie jest dostępne, gdy OpenChamber łączy się z zewnętrznym serwe
- [Zaplanowane zadania](/pl/scheduled-tasks/)
- [Sesje worktree](/pl/worktrees/)
- [Cele sesji](/pl/session-goals/)
- [Panel przeglądarki](/pl/desktop-browser/) — narzędzie OpenChamber Web — oglądanie strony i sterowanie nią
@@ -1,22 +1,53 @@
---
title: Przeglądarka na komputerze
description: Przeglądaj dowolną stronę wewnątrz aplikacji na komputerze, z inspekcją i przechwytywaniem konsoli.
title: Panel przeglądarki
description: Przeglądaj dowolną stronę w aplikacji, dodawaj do niej adnotacje i pozwól agentowi nią sterować.
---
# Przeglądarka na komputerze
# Panel przeglądarki
Aplikacja na komputerze ma wbudowaną przeglądarkę, dzięki czemu możesz otworzyć dowolną stronę tuż obok czatu, wskazywać elementy, aby o nie zapytać, oraz przechwytywać konsolę strony. Otwórz ją z przycisku globusa w nagłówku aplikacji.
Panel przeglądarki otwiera dowolną stronę tuż obok czatu. Otwórz go przyciskiem globusa w nagłówku aplikacji.
> Przeglądarka na komputerze to funkcja **tylko na komputerze**. W wersji webowej panel [podglądu](/pl/preview/) oferuje te same narzędzia inspekcji i konsoli dla Twojego lokalnego serwera deweloperskiego.
W aplikacji desktopowej to prawdziwa przeglądarka: twoje logowania są zachowywane, przeładowanie na gorąco działa, a narzędzia deweloperskie są o jedno kliknięcie. W karcie przeglądarki panel wyświetli stronę, ale nie zajrzy do jej wnętrza — poniższe narzędzia adnotacji są dostępne tylko na desktopie.
## Inspekcja i adnotacje
Strony otwarte tutaj nie mogą użyć twojej kamery, mikrofonu ani lokalizacji: takie prośby są odrzucane.
Włącz **inspect** i kliknij dowolny element na stronie. OpenChamber przechwytuje o nim notatkę — czym jest, jakie ma style, gdzie się znajduje, oraz zrzut ekranu — i dołącza ją do Twojej wiadomości czatu. To najszybszy sposób, by powiedzieć agentowi „ten element, dokładnie tutaj”.
## Pasek narzędzi
## Przechwytywanie konsoli
Pasek adresu pamięta strony otwierane w tym projekcie i podpowiada je podczas pisania, dopasowując fragment adresu albo tytułu strony. Strzałki przechodzą po liście, Enter otwiera podświetloną pozycję, a przycisk w wierszu usuwa ją z listy.
Przeglądarka zbiera wynik konsoli strony — błędy, ostrzeżenia i logi — dzięki czemu możesz go filtrować i czytać bez otwierania narzędzi deweloperskich.
Obok jest **odświeżenie**, a także **twarde odświeżenie**, które pomija pamięć podręczną, gdy zmiana uparcie się nie pokazuje, oraz sterowanie powiększeniem działające tylko na stronę.
**Wyczyść ciasteczka** i **Wyczyść dane w pamięci podręcznej** dotyczą wyłącznie tego panelu. Twoja sesja OpenChamber i pozostałe okna zostają nietknięte.
## Dodawanie adnotacji
Naciśnij **Adnotuj** — nad stroną pojawi się pasek z trzema narzędziami:
- **Element** — kliknij element. Kliknięcie innego przenosi zaznaczenie, ponowne kliknięcie tego samego je zdejmuje.
- **Obszar** — obrysuj fragment ramką, gdy chodzi o więcej niż jeden element.
- **Rysuj** — szkicuj odręcznie po stronie.
Napisz, czego oczekujesz, w polu obok swojego oznaczenia i naciśnij **Dołącz** — albo po prostu Enter. Do wiadomości trafi karta ze wszystkim, co zaznaczyłeś, z twoją notatką i ze zrzutem widocznej strony z naniesionymi oznaczeniami — możesz więc powiedzieć „ten przycisk, trochę bardziej zaokrąglony" zamiast opisywać, gdzie jest.
Sama strona nie jest zmieniana — adnotacja tylko oznacza to, co już tam jest. `Esc` anuluje i zamyka pasek.
## Sterowanie przez agenta
Agent może sam korzystać z panelu przeglądarki — otworzyć stronę, odczytać jej zawartość, klikać, wpisywać tekst, przewijać i przełączać układ mobilny, tabletowy i desktopowy — żeby sprawdzić własną pracę zamiast prosić o to ciebie. Zobaczysz to w panelu.
Agent nie może uruchamiać dowolnego kodu na stronie. Przeglądarka zachowuje twoje prawdziwe logowania, więc ogranicza się do powyższych działań.
Może też zapisać obraz tego, co widzi, w `.openchamber/screenshots/` w twoim projekcie i pokazać go w odpowiedzi. To właśnie umożliwia porównanie przed i po, a plik zostaje, by dołączyć go do pull requesta.
Działania w przeglądarce to **narzędzie OpenChamber Web**, które włącza się i wyłącza osobno w **Ustawienia → Ogólne → Narzędzia OpenChamber**.
Wymaga to aplikacji desktopowej: stroną pokazaną w karcie przeglądarki nie da się sterować.
## Narzędzia deweloperskie
Naciśnij przycisk terminala na pasku, aby otworzyć własne narzędzia deweloperskie Chromium dla strony — konsolę, sieć, elementy, wszystko czego oczekujesz.
## Powiązane
- [Podgląd i serwery deweloperskie](/pl/preview/) — te same narzędzia dla Twojego lokalnego serwera deweloperskiego
- [Podgląd i serwery deweloperskie](/preview/) — otwieranie działającej aplikacji, także na zdalnym komputerze
- [Narzędzie sterowania dla agentów](/pl/agent-control-tool/) — sesje, worktree i zaplanowane zadania prosto z czatu
+17 -14
View File
@@ -5,28 +5,31 @@ description: Otwórz działający serwer deweloperski wewnątrz OpenChamber.
# Podgląd i serwery deweloperskie
Gdy uruchamiasz serwer deweloperski, OpenChamber może otworzyć go bezpośrednio w aplikacji zamiast w osobnej karcie przeglądarki — dzięki czemu widzisz swoją witrynę obok czatu, przechwytujesz jej konsolę i wskazujesz elementy, aby o nie zapytać.
Gdy uruchamiasz serwer deweloperski, OpenChamber może otworzyć go od razu w aplikacji zamiast w osobnej karcie przeglądarki — widzisz swoją stronę obok czatu i możesz wskazywać elementy, żeby o nie zapytać.
## Otwórz podgląd
## Otwieranie serwera deweloperskiego
OpenChamber obserwuje wynik terminala pod kątem adresu lokalnego (wiersz `Local:`, który drukują narzędzia takie jak Vite, Next.js czy Astro). Gdy go wykryje:
Otwórz panel przeglądarki przyciskiem globusa w nagłówku aplikacji. Jeśli serwer już działa, znajdziesz go na liście i otworzysz jednym kliknięciem — OpenChamber rozpoznaje go po tym, co faktycznie nasłuchuje na twoim komputerze, więc działa niezależnie od sposobu uruchomienia.
- w terminalu pojawia się przycisk **Open preview**
- [akcja projektu](/pl/project-actions/) z włączonym auto-open otwiera go za Ciebie
- lokalny link w wiadomości czatu również może go otworzyć
Serwer otwiera się też sam, gdy:
Witryna ładuje się w panelu bocznym. Podglądać można wyłącznie adresy lokalne (na Twojej własnej maszynie).
- naciśniesz **Otwórz podgląd** przy lokalnym adresie w terminalu
- [akcja projektu](/project-actions/) z włączonym automatycznym otwieraniem go uruchomi
- klikniesz lokalny odnośnik w wiadomości na czacie
## Konsola i inspekcja
Adres zawsze możesz wpisać ręcznie. Samo `localhost:5173` jest traktowane jako `http://`, więc schematu nie musisz podawać.
W panelu podglądu możesz:
## Praca ze zdalnym OpenChamber
- obserwować **konsolę** strony — błędy, ostrzeżenia i logi, filtrowane wedle uznania
- włączyć **inspect**, kliknąć dowolny element i wysłać o nim notatkę — selektor, style, pozycję i zrzut ekranu — prosto do czatu
Gdy OpenChamber działa na innym komputerze, jego serwer deweloperski też jest na *tamtym* komputerze — `localhost` na twoim laptopie prowadzi zupełnie gdzie indziej. Aplikacja desktopowa załatwia to za ciebie: otwiera lokalny port, którym prowadzi połączenie do zdalnego serwera, dzięki czemu strona ładuje się normalnie, z działającym przeładowaniem na gorąco i narzędziami deweloperskimi. Nadal wpisujesz adres, którego się spodziewasz; technikalia nie wchodzą ci w drogę.
To najszybszy sposób, by powiedzieć agentowi „ten przycisk, tutaj”, bez opisywania go.
Wymaga to aplikacji desktopowej. W karcie przeglądarki otworzysz tylko serwery na własnym komputerze.
## Adnotacje na stronie
O wskazywaniu elementów, rysowaniu po stronie, przymierzaniu zmian stylów i wysyłaniu tego wszystkiego na czat przeczytasz w [Panelu przeglądarki](/desktop-browser/).
## Powiązane
- [Akcje projektu](/pl/project-actions/) — automatycznie otwórz serwer przy uruchomieniu
- [Przeglądarka na komputerze](/pl/desktop-browser/) — te same narzędzia dla dowolnej strony, na komputerze
- [Akcje projektu](/project-actions/) — automatyczne otwarcie serwera po uruchomieniu
- [Panel przeglądarki](/desktop-browser/) — adnotacje stron i sterowanie przez agenta
+16 -13
View File
@@ -5,28 +5,31 @@ description: Open a running dev server inside OpenChamber.
# Preview & Dev Servers
When you start a dev server, OpenChamber can open it right inside the app instead of a separate browser tab — so you can see your site next to the chat, capture its console, and point at elements to ask about them.
When you start a dev server, OpenChamber can open it right inside the app instead of a separate browser tab — so you can see your site next to the chat and point at elements to ask about them.
## Open a preview
## Open a dev server
OpenChamber watches terminal output for a local address (the `Local:` line that tools like Vite, Next.js, or Astro print). When it spots one:
Open the browser panel from the globe button in the app header. If a dev server is already running, it is listed there and one click opens it — OpenChamber finds it by looking at what is actually listening on your machine, so it works no matter how you started it.
- in the terminal, an **Open preview** button appears
- a [project action](/project-actions/) with auto-open turned on opens it for you
- a local link in a chat message can open it too
A dev server also opens automatically when:
The site loads in the side panel. Only local addresses (on your own machine) can be previewed.
- you press **Open preview** on a local address in the terminal
- a [project action](/project-actions/) with auto-open turned on starts one
- you follow a local link in a chat message
## Console and inspect
You can always type an address yourself. A bare `localhost:5173` is treated as `http://`, so you do not have to type the scheme.
In the preview panel you can:
## Working with a remote OpenChamber
- watch the page's **console** — errors, warnings, and logs, filtered however you like
- turn on **inspect**, click any element, and send a note about it — selector, styles, position, and a screenshot — straight into chat
When OpenChamber runs on another machine, its dev server is on *that* machine — `localhost` on your laptop is somewhere else entirely. The desktop app handles this for you: it opens a local port that carries the connection through to the remote dev server, so the page loads normally, with working hot reload and developer tools. You keep typing the address you expect; the plumbing stays out of your way.
This is the fastest way to tell the agent "this button, here" without describing it.
This needs the desktop app. In a web browser tab, only dev servers on your own machine can be opened.
## Annotate the page
See [Browser Panel](/desktop-browser/) for pointing at elements, drawing on the page, and sending it all to chat.
## Related
- [Project Actions](/project-actions/) — auto-open a server when you start it
- [Desktop Browser](/desktop-browser/) — the same tools for any page, on desktop
- [Browser Panel](/desktop-browser/) — annotating pages and letting the agent drive
@@ -28,7 +28,7 @@ A ferramenta pode listar projetos e preferências de modelos, criar e continuar
## Ativar ou desativar a ferramenta
Abra **Configurações → Geral → OpenCode CLI**, altere **Ferramenta de controle para agentes** e selecione **Save + Reload**. A configuração entra em vigor depois que o servidor OpenCode gerenciado reinicia.
Abra **Configurações → Geral → Ferramentas do OpenChamber** e altere **Ferramenta de controle para agentes**. A configuração entra em vigor quando o servidor OpenCode gerenciado reinicia, o que o OpenChamber oferece como **Apply & Restart**.
A ferramenta não está disponível quando o OpenChamber se conecta a um servidor OpenCode externo por `OPENCODE_HOST` ou skip-start, nem na extensão do VS Code. As instalações desktop e web que usam o servidor OpenCode gerenciado pelo OpenChamber têm suporte automático.
@@ -37,3 +37,4 @@ A ferramenta não está disponível quando o OpenChamber se conecta a um servido
- [Tarefas agendadas](/pt-br/scheduled-tasks/)
- [Sessões de worktree](/pt-br/worktrees/)
- [Objetivos de sessão](/pt-br/session-goals/)
- [Painel do navegador](/pt-br/desktop-browser/) — a ferramenta OpenChamber Web, para ver uma página e conduzi-la
@@ -1,22 +1,53 @@
---
title: Navegador no Desktop
description: Navegue por qualquer página dentro do app de desktop, com inspeção e captura de console.
title: Painel do navegador
description: Navegue em qualquer página dentro do aplicativo, anote-a e deixe o agente conduzi-la.
---
# Navegador no Desktop
# Painel do navegador
O app de desktop tem um navegador integrado para você abrir qualquer página logo ao lado do seu chat, apontar para elementos para perguntar sobre eles e capturar o console da página. Abra-o pelo botão de globo no cabeçalho do app.
O painel do navegador abre qualquer página bem ao lado do seu chat. Abra-o pelo botão do globo no cabeçalho.
> O navegador no desktop é um recurso **apenas para desktop**. Na web, o painel de [preview](/pt-br/preview/) oferece as mesmas ferramentas de inspeção e console para o seu servidor de desenvolvimento local.
No aplicativo desktop é um navegador de verdade: seus logins persistem, a recarga a quente funciona e as ferramentas de desenvolvedor estão a um clique. Em uma aba do navegador o painel exibe a página, mas não consegue olhar dentro dela — as ferramentas de anotação abaixo são exclusivas do desktop.
## Inspecionar e anotar
Páginas abertas aqui não podem usar sua câmera, seu microfone nem sua localização: esses pedidos são recusados.
Ative o **inspect** e clique em qualquer elemento da página. O OpenChamber captura uma nota sobre ele — o que é, seus estilos, onde fica e uma captura de tela — e a anexa à sua mensagem no chat. É a forma mais rápida de dizer ao agente "este elemento, bem aqui".
## A barra de ferramentas
## Captura de console
A barra de endereço lembra as páginas que você abriu neste projeto e as oferece enquanto você digita, procurando por parte do endereço ou do título da página. As setas percorrem a lista, Enter abre o item destacado e o botão da linha o remove.
O navegador coleta a saída do console da página — erros, avisos e logs — para que você possa filtrá-la e lê-la sem abrir as ferramentas de desenvolvedor.
Ao lado fica **Recarregar**, junto de uma **recarga forçada** que ignora o cache quando uma mudança teima em não aparecer, e os controles de zoom, que ampliam apenas a página.
**Limpar cookies** e **Limpar dados em cache** valem só para este painel. Sua sessão do OpenChamber e as outras janelas ficam intactas.
## Anotar uma página
Pressione **Anotar** e uma barra aparece sobre a página com três ferramentas:
- **Elemento** — clique em um elemento. Clicar em outro move a seleção; clicar no mesmo de novo a remove.
- **Região** — arraste um retângulo em volta de uma área quando o assunto envolver mais de um elemento.
- **Desenhar** — rabisque à mão livre sobre a página.
Escreva o que quer no campo que aparece ao lado da sua marcação e pressione **Anexar** — ou apenas Enter. Sua mensagem ganha um cartão com tudo o que você marcou, sua nota e uma captura da página visível com suas marcações desenhadas nela — assim você pode dizer "este botão, um pouco mais arredondado" em vez de descrever onde ele está.
A página em si nunca é modificada — anotar apenas marca o que já está lá. `Esc` cancela e fecha a barra.
## Deixar o agente conduzir
O agente pode usar o painel do navegador sozinho — abrir uma página, ler o que há nela, clicar, digitar, rolar e alternar entre layout móvel, de tablet e de desktop — para conferir o próprio trabalho em vez de pedir isso a você. Você vê acontecendo no painel.
O agente não pode executar código arbitrário na página. O navegador guarda seus logins reais, então ele fica limitado às ações acima.
Ele também pode salvar uma imagem do que está vendo em `.openchamber/screenshots/` no seu projeto e mostrá-la na resposta. É isso que torna possível um antes e depois, e o arquivo continua lá para anexar a um pull request.
As ações do navegador são a **ferramenta OpenChamber Web**, que pode ser ligada e desligada por conta própria em **Configurações → Geral → Ferramentas do OpenChamber**.
Isso exige o aplicativo de desktop: uma página exibida numa aba do navegador não pode ser controlada.
## Ferramentas de desenvolvedor
Pressione o botão de terminal na barra para abrir as ferramentas de desenvolvedor do próprio Chromium para a página — console, rede, elementos, tudo o que você espera.
## Relacionado
- [Preview e Servidores de Desenvolvimento](/pt-br/preview/) — as mesmas ferramentas para o seu servidor de desenvolvimento local
- [Pré-visualização e servidores de desenvolvimento](/preview/) — abrir seu aplicativo em execução, inclusive em uma máquina remota
- [Ferramenta de controle para agentes](/pt-br/agent-control-tool/) — sessões, worktrees e tarefas agendadas pelo chat
+19 -16
View File
@@ -1,32 +1,35 @@
---
title: Preview e Servidores de Desenvolvimento
title: Pré-visualização e servidores de desenvolvimento
description: Abra um servidor de desenvolvimento em execução dentro do OpenChamber.
---
# Preview e Servidores de Desenvolvimento
# Pré-visualização e servidores de desenvolvimento
Quando você inicia um servidor de desenvolvimento, o OpenChamber pode abri-lo logo dentro do app em vez de uma aba separada do navegador — para que você veja seu site ao lado do chat, capture seu console e aponte para elementos para perguntar sobre eles.
Quando você sobe um servidor de desenvolvimento, o OpenChamber pode abri-lo dentro do próprio aplicativo em vez de uma aba separada — assim você vê seu site ao lado do chat e pode apontar para elementos para perguntar sobre eles.
## Abrir um preview
## Abrir um servidor de desenvolvimento
O OpenChamber observa a saída do terminal em busca de um endereço local (a linha `Local:` que ferramentas como Vite, Next.js ou Astro imprimem). Quando ele detecta um:
Abra o painel do navegador pelo botão do globo no cabeçalho. Se já houver um servidor rodando, ele aparece na lista e um clique o abre: o OpenChamber o encontra olhando o que está de fato escutando na sua máquina, então funciona independentemente de como você o iniciou.
- no terminal, aparece um botão **Open preview**
- uma [ação de projeto](/pt-br/project-actions/) com auto-open ativado o abre para você
- um link local em uma mensagem do chat também pode abri-lo
Um servidor também abre sozinho quando:
O site carrega no painel lateral. Apenas endereços locais (na sua própria máquina) podem ter preview.
- você pressiona **Abrir pré-visualização** em um endereço local no terminal
- uma [ação de projeto](/project-actions/) com abertura automática inicia um
- você segue um link local em uma mensagem do chat
## Console e inspeção
Você sempre pode digitar o endereço à mão. Um simples `localhost:5173` é entendido como `http://`, então não precisa escrever o esquema.
No painel de preview você pode:
## Trabalhando com um OpenChamber remoto
- acompanhar o **console** da página — erros, avisos e logs, filtrados como você quiser
- ativar o **inspect**, clicar em qualquer elemento e enviar uma nota sobre ele — seletor, estilos, posição e uma captura de tela — direto no chat
Quando o OpenChamber roda em outra máquina, o servidor de desenvolvimento está *nessa* máquina — `localhost` no seu notebook aponta para outro lugar completamente. O aplicativo desktop resolve isso: ele abre uma porta local que leva a conexão até o servidor remoto, de modo que a página carrega normalmente, com recarga a quente e ferramentas de desenvolvedor funcionando. Você continua digitando o endereço que espera; o encanamento não atrapalha.
Esta é a forma mais rápida de dizer ao agente "este botão, aqui" sem descrevê-lo.
Isso exige o aplicativo desktop. Em uma aba do navegador, só é possível abrir servidores da sua própria máquina.
## Anotar a página
Veja [Painel do navegador](/desktop-browser/) para apontar elementos, desenhar sobre a página e mandar tudo para o chat.
## Relacionado
- [Ações de Projeto](/pt-br/project-actions/) — abra um servidor automaticamente ao iniciá-lo
- [Navegador no Desktop](/pt-br/desktop-browser/) — as mesmas ferramentas para qualquer página, no desktop
- [Ações de projeto](/project-actions/) — abrir um servidor automaticamente ao iniciá-lo
- [Painel do navegador](/desktop-browser/) — anotar páginas e deixar o agente conduzir
@@ -28,7 +28,7 @@ description: Дозвольте агенту керувати сесіями, wo
## Увімкнення та вимкнення
Відкрийте **Налаштування → Загальні → OpenCode CLI**, змініть **Інструмент керування для агентів**, потім виберіть **Save + Reload**. Налаштування застосовується після перезапуску керованого сервера OpenCode.
Відкрийте **Налаштування → Загальні → Інструменти OpenChamber** і змініть **Інструмент керування для агентів**. Налаштування застосовується після перезапуску керованого сервера OpenCode, який OpenChamber запропонує як **Apply & Restart**.
Інструмент недоступний, коли OpenChamber підключається до зовнішнього сервера OpenCode через `OPENCODE_HOST` чи skip-start, а також у розширенні VS Code. Десктопні та вебінсталяції з керованим OpenChamber сервером OpenCode підтримують його автоматично.
@@ -37,3 +37,4 @@ description: Дозвольте агенту керувати сесіями, wo
- [Заплановані задачі](/uk/scheduled-tasks/)
- [Сесії worktree](/uk/worktrees/)
- [Цілі сесії](/uk/session-goals/)
- [Панель браузера](/uk/desktop-browser/) — інструмент OpenChamber Web — дивитися на сторінку й керувати нею
@@ -1,22 +1,64 @@
---
title: Десктопний браузер
description: Переглядайте будь-яку сторінку всередині десктопного застосунку, з інспекцією та перехопленням консолі.
title: Панель браузера
description: Переглядайте будь-яку сторінку в застосунку, анотуйте її та дозвольте агенту нею керувати.
---
# Десктопний браузер
# Панель браузера
Десктопний застосунок має вбудований браузер, тож ви можете відкрити будь-яку сторінку просто поруч із чатом, вказувати на елементи, щоб запитати про них, і перехоплювати консоль сторінки. Відкрийте його з кнопки глобуса в заголовку застосунку.
Панель браузера відкриває будь-яку сторінку просто поруч із чатом. Відкрийте її кнопкою глобуса в заголовку застосунку.
> Десктопний браузер — це функція **лише для десктопа**. У вебі панель [перегляду](/uk/preview/) пропонує ті самі інструменти інспекції та консолі для вашого локального dev-сервера.
У десктопному застосунку це справжній браузер: ваші входи зберігаються, гаряче перезавантаження працює, а інструменти розробника — за один клік. У вкладці веббраузера панель теж покаже сторінку, але не зможе зазирнути всередину — інструменти анотацій нижче доступні лише на десктопі.
## Інспекція та анотування
Сторінки, відкриті тут, не можуть скористатися вашою камерою, мікрофоном чи місцем перебування: такі запити відхиляються.
Увімкніть **inspect** і клікніть будь-який елемент на сторінці. OpenChamber перехоплює нотатку про нього — що це, його стилі, де він розташований, і знімок екрана — і прикріплює її до вашого повідомлення в чаті. Це найшвидший спосіб сказати агентові «ось цей елемент, прямо тут».
## Панель інструментів
## Перехоплення консолі
Адресний рядок памʼятає сторінки, які ви відкривали в цьому проєкті, і пропонує їх під час набору, звіряючись із частиною адреси або назвою сторінки. Стрілки рухають списком, Enter відкриває підсвічений запис, а кнопка в рядку прибирає його зі списку.
Браузер збирає вивід консолі сторінки — помилки, попередження та логи — щоб ви могли фільтрувати й читати його, не відкриваючи інструменти розробника.
Поруч — **перезавантаження**, а також **жорстке перезавантаження**, яке ігнорує кеш, коли зміна вперто не показується, і керування масштабом, що змінює лише сторінку.
## Пов'язане
**Очистити куки** та **Очистити кеш** стосуються тільки цієї панелі. Вашої сесії OpenChamber та інших вікон це не торкається.
- [Перегляд і dev-сервери](/uk/preview/) — ті самі інструменти для вашого локального dev-сервера
## Анотувати сторінку
Натисніть **Анотувати** — над сторінкою зʼявиться панель із трьома інструментами:
- **Елемент** — клікніть на елемент. Клік по іншому переносить вибір, повторний клік по тому самому — знімає його.
- **Область** — обведіть ділянку рамкою, коли йдеться більш ніж про один елемент.
- **Малювання** — малюйте від руки поверх сторінки.
Опишіть бажане в полі, що зʼявляється поруч із позначкою, і натисніть **Додати** — або просто Enter. У повідомленні чату зʼявиться картка з усім, що ви позначили, з вашою нотаткою і зі знімком видимої сторінки, на якому намальовано твої позначки — тож можна сказати «оця кнопка, трохи круглішу», а не описувати, де вона.
Сама сторінка при цьому не змінюється — анотація лише позначає те, що є. `Esc` скасовує й закриває панель.
## Дозволити агенту керувати
Агент може сам користуватися панеллю браузера — відкривати сторінку, читати, що на ній, клікати, вводити текст, прокручувати й перемикатися між мобільним, планшетним і десктопним розкладами — щоб перевіряти власну роботу, а не просити про це вас. Ви бачитимете це в панелі.
Агент не може виконувати довільний код на сторінці. Браузер зберігає ваші справжні входи, тож агент обмежений переліченими діями.
Він також може зберегти знімок того, що бачить, у `.openchamber/screenshots/` вашого проєкту й показати його у відповіді. Саме це робить можливим «до і після», а файл лишається на місці, щоб потім прикріпити його до pull request.
Дії з браузером — це **інструмент OpenChamber Web**, який вмикається й вимикається окремо в **Налаштування → Загальні → Інструменти OpenChamber**.
Для цього потрібен десктопний застосунок: сторінкою у вкладці веббраузера керувати не вийде.
## Розмір і оформлення
Натисніть кнопку телефона, щоб відкрити панель пристроїв. Оберіть пресет або
введіть ширину й висоту — сторінка буде викладена саме в цьому розмірі, а якщо
не вміщається в панель, її буде зменшено візуально. Сама сторінка при цьому
вимірює себе в тому розмірі, який ви задали.
Там же можна змусити сторінку показатися світлою чи темною, не змінюючи нічого
на своїй машині. З DevTools вони не поєднуються: у сторінки може бути лише один
приєднаний зневаджувач, тож DevTools доведеться спершу закрити.
## Інструменти розробника
Натисніть кнопку термінала на панелі, щоб відкрити власні інструменти розробника Chromium для сторінки — консоль, мережу, елементи, усе як зазвичай.
## Повʼязане
- [Перегляд і dev-сервери](/preview/) — як відкрити запущений застосунок, зокрема на віддаленій машині
- [Інструмент керування для агентів](/uk/agent-control-tool/) — сесії, worktree й заплановані задачі просто з чату
+18 -15
View File
@@ -5,28 +5,31 @@ description: Відкрийте запущений dev-сервер усеред
# Перегляд і dev-сервери
Коли ви запускаєте dev-сервер, OpenChamber може відкрити його просто всередині застосунку замість окремої вкладки браузера — щоб ви могли бачити свій сайт поруч із чатом, перехоплювати його консоль і вказувати на елементи, щоб запитати про них.
Коли ви запускаєте dev-сервер, OpenChamber може відкрити його просто всередині застосунку замість окремої вкладки браузера — щоб ви бачили свій сайт поруч із чатом і могли вказувати на елементи, щоб запитати про них.
## Відкриття перегляду
## Відкрити dev-сервер
OpenChamber слідкує за виводом терміналу на предмет локальної адреси (рядок `Local:`, який друкують інструменти на кшталт Vite, Next.js чи Astro). Коли він її помічає:
Відкрийте панель браузера кнопкою глобуса в заголовку застосунку. Якщо dev-сервер уже запущено, він буде у списку — один клік, і він відкриється. OpenChamber знаходить його за тим, що насправді слухає порти на вашій машині, тож це працює незалежно від того, як саме ви його запустили.
- у терміналі з'являється кнопка **Open preview**
- [дія проєкту](/uk/project-actions/) з увімкненим автовідкриттям відкриває її за вас
- локальне посилання в повідомленні чату теж може її відкрити
Dev-сервер також відкривається автоматично, коли:
Сайт завантажується в бічній панелі. Переглянути можна лише локальні адреси (на вашій власній машині).
- ви натискаєте **Відкрити перегляд** на локальній адресі в терміналі
- [дія проєкту](/project-actions/) з увімкненим автовідкриттям запускає його
- ви переходите за локальним посиланням у повідомленні чату
## Консоль та інспекція
Адресу завжди можна ввести вручну. Простий `localhost:5173` трактується як `http://`, тож схему писати не обовʼязково.
У панелі перегляду ви можете:
## Робота з віддаленим OpenChamber
- спостерігати за **консоллю** сторінки — помилки, попередження та логи, відфільтровані як вам зручно
- увімкнути **inspect**, клікнути будь-який елемент і надіслати нотатку про нього — селектор, стилі, позицію та знімок екрана — прямо в чат
Коли OpenChamber працює на іншій машині, його dev-сервер теж на *тій* машині — `localhost` на вашому ноутбуці веде зовсім не туди. Десктопний застосунок робить це за вас: відкриває локальний порт, який проводить зʼєднання до віддаленого dev-сервера, тож сторінка вантажиться як звичайно, з робочим гарячим перезавантаженням і інструментами розробника. Ви й далі вводите очікувану адресу; технічні деталі вам не заважають.
Це найшвидший спосіб сказати агентові «ось ця кнопка, тут», не описуючи її.
Для цього потрібен десктопний застосунок. У вкладці браузера можна відкрити лише dev-сервери на вашій власній машині.
## Пов'язане
## Анотації на сторінці
- [Дії проєкту](/uk/project-actions/) — автоматично відкривайте сервер при запуску
- [Десктопний браузер](/uk/desktop-browser/) — ті самі інструменти для будь-якої сторінки на десктопі
Про те, як вказувати на елементи, малювати на сторінці й надсилати все це в чат, читайте в розділі [Панель браузера](/desktop-browser/).
## Повʼязане
- [Дії проєкту](/project-actions/) — автовідкриття сервера після запуску
- [Панель браузера](/desktop-browser/) — анотації сторінок і керування агентом
@@ -28,7 +28,7 @@ description: 让智能体从聊天中管理 OpenChamber 会话、worktree 和计
## 开启或关闭工具
打开 **设置 → 常规 → OpenCode CLI**,更改 **智能体控制工具**,然后选择 **Save + Reload**。该设置会在托管的 OpenCode 服务器重启后生效。
打开 **设置 → 常规 → OpenChamber 工具**,更改 **智能体控制工具**。该设置会在托管的 OpenCode 服务器重启后生效OpenChamber 会以 **Apply & Restart** 的形式提示重启
当 OpenChamber 通过 `OPENCODE_HOST` 或 skip-start 连接外部 OpenCode 服务器时,或在 VS Code 扩展中,此工具不可用。使用 OpenChamber 托管 OpenCode 服务器的桌面端和 Web 安装会自动支持此工具。
@@ -37,3 +37,4 @@ description: 让智能体从聊天中管理 OpenChamber 会话、worktree 和计
- [计划任务](/zh-cn/scheduled-tasks/)
- [Worktree 会话](/zh-cn/worktrees/)
- [会话目标](/zh-cn/session-goals/)
- [浏览器面板](/zh-cn/desktop-browser/) — 用于查看并操作页面的 OpenChamber Web 工具
@@ -1,22 +1,53 @@
---
title: 桌面浏览器
description: 在桌面应用内浏览任意页面,并提供检查和控制台捕获功能
title: 浏览器面板
description: 在应用内浏览任意页面、为其添加标注,并让智能体操作它
---
# 桌面浏览器
# 浏览器面板
桌面应用内置了浏览器,因此你可以在聊天旁边直接打开任意页面、指向元素来询问相关问题,并捕获页面的控制台。从应用标题栏的地球按钮打开它。
浏览器面板会在聊天旁边打开任意页面。用应用标题栏的地球按钮打开它。
> 桌面浏览器是一项**仅限桌面**的功能。在网页端,[预览](/zh-cn/preview/) 面板为你的本地开发服务器提供相同的检查与控制台工具
在桌面应用中,它是一个真正的浏览器:登录状态会保留,热重载可用,开发者工具一键即达。在浏览器标签页中,面板仍能显示页面,但无法查看其内部——下面的标注工具仅限桌面端
## 检查与标注
在这里打开的页面无法使用你的摄像头、麦克风或位置:这类请求会被拒绝。
开启 **inspect** 并点击页面上的任意元素。OpenChamber 会捕获关于它的说明 — 它是什么、它的样式、它所处的位置,以及一张截图 — 并将其附加到你的聊天消息中。这是告诉智能体“就这个元素,就在这里”的最快方式。
## 工具栏
## 控制台捕获
地址栏会记住你在这个项目里打开过的页面,并在你输入时给出候选,按地址或页面标题的任意片段匹配。方向键在列表中移动,回车打开选中的一项,行上的按钮把它从列表中移除。
浏览器会收集页面的控制台输出 — 错误、警告和日志 — 因此你无需打开开发者工具即可筛选和阅读它
旁边是**重新加载**,还有在改动怎么都不出现时忽略缓存的**强制重新加载**,以及只缩放页面的缩放控件
## 相关内容
**清除 Cookie** 和**清除缓存数据**只作用于这个面板。你的 OpenChamber 会话和其他窗口不受影响。
- [预览与开发服务器](/zh-cn/preview/) — 为你的本地开发服务器提供相同的工具
## 为页面添加标注
按 **标注**,页面上方会出现一个包含三种工具的工具条:
- **元素** — 点击某个元素。点击另一个会移动选择,再次点击同一个则取消。
- **区域** — 当要说的内容不止一个元素时,拖拽出矩形框住那块区域。
- **绘制** — 在页面上自由手绘。
在标记旁出现的输入框里写下你的想法,然后按 **附加**,或直接按回车。你的聊天消息会收到一张卡片,包含你标记的全部内容、你的备注,以及一张画上你的标记的可见页面截图——于是你可以说"这个按钮,再圆一点",而不必描述它在哪里。
页面本身不会被修改——标注只是标记已有的内容。按 `Esc` 取消并关闭工具条。
## 让智能体操作
智能体可以自己使用浏览器面板——打开页面、读取页面内容、点击、输入、滚动,并在移动端、平板和桌面端布局之间切换——从而自行检查工作成果,而不必来问你。你可以在面板中看到这个过程。
智能体无法在页面中执行任意代码。浏览器保留着你真实的登录状态,因此它只能执行上述操作。
它还可以把当前看到的画面保存为图片,放进项目里的 `.openchamber/screenshots/`,并在回复中展示给你。「改动前后」的对比正是靠这一点,而文件会留在那里,方便附到 pull request 上。
浏览器相关的动作属于 **OpenChamber Web 工具**,可在**设置 → 通用 → OpenChamber 工具**中单独开关。
这需要桌面应用:在浏览器标签页中显示的页面无法被操作。
## 开发者工具
按工具条上的终端按钮,即可打开 Chromium 自带的页面开发者工具——控制台、网络、元素,一应俱全。
## 相关
- [预览与开发服务器](/preview/) — 打开正在运行的应用,包括远程机器上的
- [智能体控制工具](/zh-cn/agent-control-tool/) — 在聊天中管理会话、worktree 和计划任务
+19 -16
View File
@@ -1,32 +1,35 @@
---
title: 预览与开发服务器
description: 在 OpenChamber 内打开正在运行的开发服务器。
description: 在 OpenChamber 内打开正在运行的开发服务器。
---
# 预览与开发服务器
当你启动开发服务器OpenChamber 可以直接在应用内打开它,而不是在单独的浏览器标签页中 — 这样你就可以在聊天旁边查看你的站点、捕获它的控制台,并指元素来询问相关问题
启动开发服务器OpenChamber 可以直接在应用内打开它,而不是另开一个浏览器标签页——这样你就在聊天旁边看到自己的站点,并指元素提问
## 打开预览
## 打开开发服务器
OpenChamber 会监视终端输出中的本地地址(像 Vite、Next.js 或 Astro 这类工具打印的 `Local:` 行)。当它发现一个时:
用应用标题栏的地球按钮打开浏览器面板。如果开发服务器已在运行,它会出现在列表里,点一下即可打开:OpenChamber 是根据你机器上实际正在监听的端口找到它的,因此无论你用什么方式启动都能识别。
- 终端中会出现一个 **Open preview** 按钮
- 一个开启了自动打开的 [项目操作](/zh-cn/project-actions/) 会为你打开它
- 聊天消息中的本地链接也可以打开它
以下情况开发服务器也会自动打开:
站点会在侧面板中加载。只有本地地址(在你自己的机器上)才能被预览。
- 你在终端的本地地址上按 **打开预览**
- 开启了自动打开的[项目操作](/project-actions/)启动了一个
- 你点击了聊天消息中的本地链接
## 控制台与检查
你随时可以自己输入地址。直接写 `localhost:5173` 会按 `http://` 处理,不必输入协议。
在预览面板中你可以:
## 配合远程 OpenChamber 使用
- 查看页面的**控制台** — 错误、警告和日志,可按你喜欢的方式筛选
- 开启 **inspect**,点击任意元素,并将关于它的说明 — 选择器、样式、位置和一张截图 — 直接发送到聊天
当 OpenChamber 运行在另一台机器上时,它的开发服务器也在*那台*机器上——你笔记本上的 `localhost` 指向的完全是别处。桌面应用会替你处理:它打开一个本地端口,把连接一路接到远程开发服务器,于是页面照常加载,热重载和开发者工具都能用。你仍然输入你预期的地址,底层的管道不会碍事。
是告诉智能体“就这个按钮,这里”而无需描述它的最快方式
需要桌面应用。在浏览器标签页中,只能打开你自己机器上的开发服务器
## 相关内容
## 为页面添加标注
- [项目操作](/zh-cn/project-actions/) — 启动服务器时自动打开它
- [桌面浏览器](/zh-cn/desktop-browser/) — 在桌面端为任意页面提供相同的工具
指向元素、在页面上绘制并把这些一起发到聊天的方法,见[浏览器面板](/desktop-browser/)。
## 相关
- [项目操作](/project-actions/) — 启动服务器时自动打开
- [浏览器面板](/desktop-browser/) — 标注页面并让智能体操作
+10 -10
View File
@@ -638,18 +638,18 @@
}
},
{
"label": "Desktop Browser",
"label": "Browser Panel",
"link": "/desktop-browser/",
"translations": {
"uk": "Десктопний браузер",
"zh-CN": "桌面浏览器",
"es": "Navegador de escritorio",
"pt-BR": "Navegador desktop",
"ko": "데스크톱 브라우저",
"pl": "Przeglądarka na pulpicie",
"fr": "Navigateur desktop",
"ja": "デスクトップブラウザ",
"de": "Desktop-Browser"
"uk": "Панель браузера",
"zh-CN": "浏览器面板",
"es": "Panel del navegador",
"pt-BR": "Painel do navegador",
"ko": "브라우저 패널",
"pl": "Panel przeglądarki",
"fr": "Panneau navigateur",
"ja": "ブラウザパネル",
"de": "Browser-Panel"
}
},
{
+7
View File
@@ -153,6 +153,13 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
- Tunnel lifecycle integration through the web server runtime.
- Auto-update checks, downloads, and restart/apply flow.
- The browser panel's own session (`persist:openchamber-browser`): its storage is
cleared only through the scoped clear-data command, and camera, microphone,
location, and device-picker requests from pages shown there are denied. Electron
grants permission requests by default when no handler is set, and the panel
loads whatever address the user types. Tab favicons are fetched in this
session too, so icons behind the page's own login resolve and the app's origin
never requests anything from a third-party host.
## IPC Pattern
+219 -4
View File
@@ -1132,6 +1132,76 @@ const injectRuntimeConfigIntoHtml = (html) => {
return `${initScript}${html}`;
};
/**
* The browser panel's own session, kept separate from OpenChamber's.
*
* Every page the user opens in the panel shares this partition, which is what
* lets a dev-server login persist between sessions without touching the app's
* own storage.
*/
const BROWSER_PANEL_PARTITION = 'persist:openchamber-browser';
/**
* Denies device and location access to pages shown in the browser panel.
*
* Electron grants permission requests by default when no handler is set. The
* panel loads whatever address the user types, so that default would hand a
* page the camera, the microphone, or the user's location without anything
* being asked or shown — a browser people would not tolerate.
*
* This denies rather than prompts: a prompt is the right end state, but a
* silent grant is the one outcome that must not stay. Denials are logged so a
* page that legitimately needs something is diagnosable rather than mysterious.
*/
const MAX_FAVICON_BYTES = 512 * 1024;
const FAVICON_MIME_TYPES = new Set([
'image/x-icon',
'image/vnd.microsoft.icon',
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/svg+xml',
]);
/**
* Resolves a web contents id to a browser-panel view, or refuses.
*
* These commands take an id from the renderer, and an id is guessable. Without
* this a compromised renderer could point capture or the debugger at another
* window's contents. Membership of the panel's own session is the proof: only
* views created with that partition have it, and nothing else in the app does.
*/
const resolveBrowserPanelContents = (rawId) => {
const id = Number.isFinite(rawId) ? Math.trunc(rawId) : null;
if (id === null || id < 0) throw new Error('webContentsId is required');
const target = webContents.fromId(id);
if (!target || target.isDestroyed()) throw new Error('WebContents not found');
if (target.session !== session.fromPartition(BROWSER_PANEL_PARTITION)) {
throw new Error('That view is not a browser panel page');
}
return target;
};
const hardenBrowserPanelSession = () => {
const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION);
panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => {
log.info('[electron] browser panel denied a permission request', {
permission,
origin: details?.requestingUrl || '',
});
callback(false);
});
// Asked before some features even request; answering here keeps a page from
// reporting a capability it would then be denied.
panelSession.setPermissionCheckHandler(() => false);
// Serial, HID and USB device pickers.
panelSession.setDevicePermissionHandler(() => false);
};
const registerPackagedUiProtocol = () => {
if (!shouldUsePackagedUi()) return;
protocol.handle(UI_PROTOCOL, async (request) => {
@@ -3649,6 +3719,28 @@ const runSpecChain = (specs, appName) => {
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
};
// The tunnel client lives in the web package (it already has a WebSocket
// client) and is loaded only if the user actually previews a remote dev server.
let devTunnelClientPromise = null;
const getDevTunnelClient = async () => {
if (!devTunnelClientPromise) {
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
.then(({ createDevTunnelClient }) => createDevTunnelClient({ logger: log }))
.catch((error) => {
devTunnelClientPromise = null;
throw error;
});
}
return devTunnelClientPromise;
};
const closeAllDevTunnels = () => {
if (!devTunnelClientPromise) return;
const pending = devTunnelClientPromise;
devTunnelClientPromise = null;
pending.then((client) => client.closeAll()).catch(() => {});
};
const handleInvoke = async (browserWindow, command, args = {}) => {
switch (command) {
case 'desktop_start_window_drag':
@@ -3740,11 +3832,131 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return { supported: true, enabled, active };
}
// Dev-server tunnels: bind a loopback port here and pipe it to a dev server
// on the remote OpenChamber host, so the browser panel loads a real origin
// instead of a rewritten page. Deliberately absent from
// COMMANDS_SAFE_FOR_REMOTE — a remote page must never open local listeners.
case 'desktop_dev_tunnel_open': {
const baseUrl = typeof args.baseUrl === 'string' ? args.baseUrl.trim() : '';
const port = Number.isFinite(args.port) ? Math.trunc(args.port) : 0;
if (!baseUrl) throw new Error('baseUrl is required');
if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required');
const headers = {};
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
for (const [name, value] of Object.entries(requestHeaders)) {
if (typeof value === 'string' && value) headers[name] = value;
}
if (typeof args.clientToken === 'string' && args.clientToken) {
headers.Authorization = `Bearer ${args.clientToken}`;
}
const client = await getDevTunnelClient();
const result = await client.open({ baseUrl, port, headers });
return { localPort: result.localPort, reused: result.reused, url: `http://127.0.0.1:${result.localPort}/` };
}
case 'desktop_dev_tunnel_close': {
const baseUrl = typeof args.baseUrl === 'string' ? args.baseUrl.trim() : '';
const port = Number.isFinite(args.port) ? Math.trunc(args.port) : 0;
if (!baseUrl || !(port > 0)) return { closed: false };
const client = await getDevTunnelClient();
return { closed: client.close({ baseUrl, port }) };
}
/**
* Forces prefers-color-scheme for one previewed page.
*
* nativeTheme.themeSource is app-wide and would drag OpenChamber's own
* appearance along with it, so this goes through the page's own emulation
* instead. The debugger session has to stay attached: emulation is part of
* that session and resets the moment it detaches.
*/
case 'desktop_browser_set_color_scheme': {
const scheme = args.scheme === 'light' || args.scheme === 'dark' ? args.scheme : 'system';
const target = resolveBrowserPanelContents(args.webContentsId);
if (!target.debugger.isAttached()) {
try {
target.debugger.attach('1.3');
} catch {
// DevTools owns the only debugger session a page can have.
throw new Error('Close DevTools for this page before changing its appearance');
}
}
await target.debugger.sendCommand('Emulation.setEmulatedMedia', scheme === 'system'
? { features: [] }
: { features: [{ name: 'prefers-color-scheme', value: scheme }] });
if (scheme === 'system') {
// Nothing left to emulate; give the session back so DevTools can attach.
try { target.debugger.detach(); } catch { /* already gone */ }
}
return { scheme };
}
/**
* Fetches a page's favicon for the tab strip.
*
* Done here, in the panel's own session, rather than by the renderer: the
* icon often sits behind the same login as the page, and letting the app's
* own origin request it would both fail on those and quietly send traffic
* to third-party hosts from OpenChamber itself. The bytes come back as a
* data URL so nothing else has to fetch anything.
*/
case 'desktop_browser_fetch_favicon': {
const target = typeof args.url === 'string' ? args.url.trim() : '';
let parsed;
try {
parsed = new URL(target);
} catch {
throw new Error('A favicon URL is required');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Unsupported favicon URL');
}
const response = await electronNet.fetch(parsed.toString(), {
session: session.fromPartition(BROWSER_PANEL_PARTITION),
});
if (!response.ok) throw new Error(`Favicon request failed (${response.status})`);
const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!FAVICON_MIME_TYPES.has(mime)) throw new Error('Favicon is not an image');
const buffer = Buffer.from(await response.arrayBuffer());
// A tab icon is a few kilobytes; anything of a different order is not one,
// and is not worth holding in memory for every tab.
if (buffer.length === 0 || buffer.length > MAX_FAVICON_BYTES) {
throw new Error('Favicon is not a usable size');
}
return { dataUrl: `data:${mime};base64,${buffer.toString('base64')}` };
}
// Scoped to the browser panel's own partition, so clearing it can never
// touch OpenChamber's session or any other window's storage.
case 'desktop_browser_clear_data': {
// Exact match, not a prefix: a prefix would also accept a partition that
// merely starts with this name, which is not what the comment above
// promises and would quietly stop being true if one were ever added.
const partition = typeof args.partition === 'string' ? args.partition.trim() : '';
if (partition !== BROWSER_PANEL_PARTITION) {
throw new Error('Unsupported browser partition');
}
const storages = [];
if (args.cookies === true) storages.push('cookies');
if (args.cache === true) storages.push('localstorage', 'indexdb', 'websql', 'serviceworkers', 'cachestorage');
if (storages.length === 0) return { cleared: false };
const browserSession = session.fromPartition(partition);
await browserSession.clearStorageData({ storages });
if (args.cache === true) await browserSession.clearCache();
return { cleared: true };
}
case 'desktop_browser_capture_page': {
const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null;
if (wcId === null || wcId < 0) throw new Error('webContentsId is required');
const wc = webContents.fromId(wcId);
if (!wc || wc.isDestroyed()) throw new Error('WebContents not found');
const wc = resolveBrowserPanelContents(args.webContentsId);
const image = await wc.capturePage();
const buffer = image.toJPEG(82);
return {
@@ -5083,6 +5295,8 @@ app.on('window-all-closed', () => {
app.on('before-quit', (event) => {
state.quitRequested = true;
// Loopback listeners would otherwise outlive the window that needed them.
closeAllDevTunnels();
if (state.installingUpdate) {
return;
@@ -5156,6 +5370,7 @@ app.whenReady().then(async () => {
});
nativeTheme.themeSource = readThemeSource();
registerPackagedUiProtocol();
hardenBrowserPanelSession();
setupAutoUpdater();
if (process.platform === 'darwin') {
-1
View File
@@ -50,7 +50,6 @@
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "3.14.5",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -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();
};

Some files were not shown because too many files have changed in this diff Show More