Oo computer browser: live Chromium, cowork, AgentCursor (#179)

* feat(web): Oo computer browser panel with cowork and local preview

Wire the in-app browser panel to the active Oo's live Chromium via the
computer screen stream, page-browser navigation, and interactive cowork
input with AgentCursor. User browser actions become system meta lines in
thread context; localhost preview remains as Lokale Vorschau.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(i18n): sync Lingui catalogs for Oo browser panel

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix the Oo browser's lint and E2E failures

The shared-browser overlay is a focusable input surface, so it gets the
application role and a translated label. The local preview focuses the
address field again when no page is open, which the split out of
InAppBrowser had dropped. The Oo browser spec opens the panel by its exact
name, because the signed-up user's name also contains "Browser", and reads
the user action from the RPC body's json envelope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-09 10:10:25 +02:00 committed by GitHub
parent 81468063ff
commit f07d17e927
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2007 additions and 369 deletions

View File

@ -45,10 +45,12 @@ import {
clearInactiveUserComputerControl,
computerSupportsUpdate,
computerUpdateView,
createBrowserProvider,
createVoiceProvider,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
engageBrowserCowork,
enqueueTakeoverContinuation,
expireComputerControl,
filterTeamWorkspaceEntries,
@ -73,6 +75,7 @@ import {
queueComputerUpdate,
RECOMMENDED_MODEL,
readProjectWorkspaceFile,
recordBrowserUserAction,
releaseComputerExecutionLease,
replaceComputer,
resolveAutoReviewChecker,
@ -2682,6 +2685,77 @@ export function createRouter(deps: RouterDeps) {
}
return { ok: true as const };
}),
browserChromeState: authed.computer.browserChromeState.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "state", context);
}),
browserNavigate: authed.computer.browserNavigate.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const result = await browser.navigate(
toComputerRef(bot.computer),
{ url: input.url },
computerContext(context.actor, bot.id, "browser.navigate"),
);
if (result.fallback) {
throw new ORPCError("CONFLICT", { message: result.error ?? "Browser unavailable" });
}
await noteBrowserNavigate(deps, context.actor, bot, input.url);
return readBrowserChrome(deps, bot, "state", context);
}),
browserBack: authed.computer.browserBack.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "back", context);
}),
browserForward: authed.computer.browserForward.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "forward", context);
}),
browserReload: authed.computer.browserReload.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "reload", context);
}),
browserCoworkEngage: authed.computer.browserCoworkEngage.handler(
async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer) throw new IsolationError();
const engaged = await engageBrowserCowork(
deps,
context.actor,
bot,
computerContext(context.actor, bot.id, "browser.cowork"),
);
return engaged;
},
),
browserUserAction: authed.computer.browserUserAction.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
const threadId = bot.thread?.id;
if (!threadId) throw new IsolationError();
await recordBrowserUserAction(deps, context.actor, {
botId: bot.id,
threadId,
kind: input.kind,
detail: input.detail,
passwordField: input.passwordField,
});
return { ok: true as const };
}),
},
memory: {
list: authed.memory.list.handler(async ({ context, input }) => {
@ -5842,6 +5916,55 @@ async function persistModelCredential(
return modelCredentialDto(cred, input.plaintext);
}
async function readBrowserChrome(
deps: RouterDeps,
bot: {
id: string;
computer: { homeKey: string; kind: string; providerRef: string | null } | null;
},
command: "state" | "back" | "forward" | "reload",
context: { actor: Actor },
) {
if (!bot.computer?.providerRef) {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const chrome = browser as InstanceType<typeof import("@rakazo/adapters").ComputerBrowserProvider>;
const result = await chrome.chrome(
toComputerRef(bot.computer),
command,
computerContext(context.actor, bot.id, `browser.${command}`),
);
if (!result.ok) {
throw new ORPCError("CONFLICT", {
message: "error" in result && result.error ? result.error : "Browser unavailable",
});
}
return {
url: result.url,
title: result.title,
loading: result.loading,
canGoBack: result.canGoBack,
canGoForward: result.canGoForward,
};
}
async function noteBrowserNavigate(
deps: RouterDeps,
actor: Actor,
bot: { id: string; thread: { id: string } | null },
url: string,
) {
const threadId = bot.thread?.id;
if (!threadId) return;
await recordBrowserUserAction(deps, actor, {
botId: bot.id,
threadId,
kind: "navigate",
detail: url,
});
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw signal.reason ?? new Error("Request cancelled");
}

View File

@ -17,6 +17,7 @@ test("the in-app browser previews a page and follows it back to the browser", as
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
await browser.getByTestId("in-app-browser-mode-local").click();
const address = browser.getByRole("textbox", { name: "Address" });
await expect(address).toBeFocused();
@ -42,6 +43,7 @@ test("the in-app browser previews a page and follows it back to the browser", as
await expect(browser).toHaveCount(0);
// The last address comes back when the browser reopens.
await page.getByRole("button", { name: "Browser" }).click();
await page.getByTestId("in-app-browser-mode-local").click();
await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute(
"src",
"https://preview.example/app",
@ -113,6 +115,7 @@ test("on desktop the page is a native view glued to the panel", async ({ page })
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
await browser.getByTestId("in-app-browser-mode-local").click();
await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173");
await browser.getByRole("textbox", { name: "Address" }).press("Enter");
await expect.poll(calls).toContainEqual(["navigate", "http://localhost:5173/"]);

View File

@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("Oo browser shows live screen, cursor, cowork click, and user context", async ({
page,
}, testInfo) => {
await signup(page, `oo-browser-${Date.now()}@rakazo.test`, "password12", "Browser Cowork");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Chrome</title><body style="margin:0;background:#111;height:100vh"></body>`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
let coworkInput = 0;
await page.route("**/rpc/computer/browserCoworkEngage", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
leaseId: "lease-test",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
interrupted: true,
},
}),
}),
);
await page.route("**/rpc/computer/input", (route) => {
coworkInput += 1;
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { ok: true } }),
});
});
await page.route("**/rpc/computer/browserChromeState", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
},
}),
}),
);
let userActionDetail = "";
await page.route("**/rpc/computer/browserUserAction", (route) => {
const body = route.request().postDataJSON() as { json?: { detail?: string } };
userActionDetail = body.json?.detail ?? "";
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { ok: true } }),
});
});
await page.getByRole("button", { name: "Browser", exact: true }).click();
const browser = page.getByTestId("in-app-browser");
await expect(browser.getByTestId("oo-computer-browser-screen")).toHaveAttribute(
"src",
SCREEN_URL,
);
await rpc(page, "threads/send", { botId, text: "use your screen and click center" });
const cursor = browser.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "1", { timeout: 15_000 });
await browser.getByTestId("browser-cowork-overlay").click({ position: { x: 120, y: 80 } });
await expect.poll(() => coworkInput).toBeGreaterThan(0);
await expect.poll(() => userActionDetail).toContain("(");
await captureScreenshot(page, testInfo, "oo-computer-browser");
});

View File

@ -0,0 +1,181 @@
import { useLingui } from "@lingui/react/macro";
import type { ComputerStatus } from "@rakazo/contracts";
import { DEFAULT_COMPUTER_SCREEN, mapTeachPointer } from "@rakazo/core";
import { useEffect, useRef } from "react";
import { rpc } from "../../lib/rpc";
/** Forwards pointer, scroll, and key input to the Oo's live computer after cowork engage. */
export function BrowserCoworkOverlay({
botId,
computer,
enabled,
onEngaged,
onUserAction,
}: {
botId: string;
computer: ComputerStatus | null;
enabled: boolean;
onEngaged: () => void;
onUserAction: (detail: { kind: "click" | "scroll" | "type"; detail: string }) => void;
}) {
const { t } = useLingui();
const rootRef = useRef<HTMLDivElement>(null);
const engagedRef = useRef(false);
const inputChainRef = useRef(Promise.resolve());
const width = computer?.screenWidth ?? DEFAULT_COMPUTER_SCREEN.width;
const height = computer?.screenHeight ?? DEFAULT_COMPUTER_SCREEN.height;
useEffect(() => {
engagedRef.current = false;
}, [botId]);
useEffect(() => {
if (!enabled) return;
const overlay = rootRef.current;
if (!overlay) return;
function enqueueInput(task: () => Promise<void>) {
inputChainRef.current = inputChainRef.current.then(task).catch(() => undefined);
}
async function ensureEngaged() {
if (engagedRef.current) return;
await rpc.computer.browserCoworkEngage({ botId });
engagedRef.current = true;
onEngaged();
}
function pointerAt(event: PointerEvent) {
const box = overlay!.getBoundingClientRect();
return mapTeachPointer(event.clientX, event.clientY, box, {
width,
height,
});
}
async function sendPointer(
type: "move" | "down" | "up" | "click",
x: number,
y: number,
button: "left" | "right" = "left",
) {
await rpc.computer.input({
botId,
kind: "pointer",
payload: { x, y, button, type },
});
}
async function sendKey(key: string) {
await rpc.computer.input({ botId, kind: "key", payload: { key } });
}
async function sendScroll(direction: "up" | "down", amount: number) {
await rpc.computer.input({
botId,
kind: "scroll",
payload: { direction, amount },
});
}
function buttonFor(event: PointerEvent): "left" | "right" {
return event.button === 2 ? "right" : "left";
}
function onPointerDown(event: PointerEvent) {
event.preventDefault();
overlay!.setPointerCapture(event.pointerId);
const { x, y } = pointerAt(event);
enqueueInput(async () => {
await ensureEngaged();
await sendPointer("down", x, y, buttonFor(event));
onUserAction({ kind: "click", detail: `(${x}, ${y})` });
});
}
let pendingMove: { x: number; y: number; button: "left" | "right" } | null = null;
let moveInFlight = false;
function pumpMove() {
if (moveInFlight || !pendingMove) return;
const move = pendingMove;
pendingMove = null;
moveInFlight = true;
enqueueInput(async () => {
try {
await ensureEngaged();
await sendPointer("move", move.x, move.y, move.button);
} finally {
moveInFlight = false;
pumpMove();
}
});
}
function onPointerMove(event: PointerEvent) {
if (!event.buttons) return;
event.preventDefault();
pendingMove = { ...pointerAt(event), button: buttonFor(event) };
pumpMove();
}
function onPointerUp(event: PointerEvent) {
event.preventDefault();
const { x, y } = pointerAt(event);
enqueueInput(async () => {
await ensureEngaged();
await sendPointer("up", x, y, buttonFor(event));
await sendPointer("click", x, y, buttonFor(event));
});
}
function onWheel(event: WheelEvent) {
event.preventDefault();
const direction = event.deltaY < 0 ? "up" : "down";
const amount = Math.min(12, Math.max(1, Math.round(Math.abs(event.deltaY) / 40)));
enqueueInput(async () => {
await ensureEngaged();
await sendScroll(direction, amount);
onUserAction({ kind: "scroll", detail: direction });
});
}
function onKeyDown(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
event.preventDefault();
enqueueInput(async () => {
await ensureEngaged();
await sendKey(event.key.length === 1 ? event.key : event.code);
onUserAction({
kind: "type",
detail: event.key.length === 1 ? event.key : event.code,
});
});
}
overlay.addEventListener("pointerdown", onPointerDown);
overlay.addEventListener("pointermove", onPointerMove);
overlay.addEventListener("pointerup", onPointerUp);
overlay.addEventListener("wheel", onWheel, { passive: false });
overlay.addEventListener("keydown", onKeyDown);
overlay.tabIndex = 0;
return () => {
overlay.removeEventListener("pointerdown", onPointerDown);
overlay.removeEventListener("pointermove", onPointerMove);
overlay.removeEventListener("pointerup", onPointerUp);
overlay.removeEventListener("wheel", onWheel);
overlay.removeEventListener("keydown", onKeyDown);
};
}, [botId, enabled, height, onEngaged, onUserAction, width]);
if (!enabled) return null;
return (
<div
ref={rootRef}
data-testid="browser-cowork-overlay"
className="absolute inset-0 touch-none outline-none"
role="application"
aria-label={t`Shared browser`}
/>
);
}

View File

@ -1,214 +1,63 @@
import { useLingui } from "@lingui/react/macro";
import type {
RakazoDesktopBrowserPreview,
RakazoDesktopBrowserPreviewState,
} from "@rakazo/contracts";
import { Button, cn, Input } from "@rakazo/ui-web";
import { ArrowLeft, ArrowRight, Code, ExternalLink, RotateCw, X } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { browserFrameSandbox, normalizeBrowserUrl } from "../../lib/browser-url";
import { desktopBridge } from "../../lib/desktop";
import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, ComputerStatus } from "@rakazo/contracts";
import { Button, cn } from "@rakazo/ui-web";
import { X } from "lucide-react";
import { useState } from "react";
import { LocalBrowserPreview } from "./LocalBrowserPreview";
import { OoComputerBrowser } from "./OoComputerBrowser";
const LAST_URL_KEY = "rk:in-app-browser:url";
export type InAppBrowserMode = "oo" | "local";
function readLastUrl(): string {
try {
return window.localStorage.getItem(LAST_URL_KEY) ?? "";
} catch {
return "";
}
}
function rememberUrl(url: string) {
try {
window.localStorage.setItem(LAST_URL_KEY, url);
} catch {
/* A per-viewer convenience only. */
}
}
const EMPTY_STATE: RakazoDesktopBrowserPreviewState = {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null,
};
/** DOM layers the native desktop view would otherwise paint over. */
const FLOATING_UI = '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]';
/**
* Address bar plus page. On desktop the page is a native Chromium view laid over the reserved
* area, so dev servers keep hot reload, DevTools and full speed; in a browser it is an iframe.
*/
export function InAppBrowser({ onClose }: { onClose: () => void }) {
export function InAppBrowser({
onClose,
active,
computer,
embeddedScreenUrl,
computerScreenError,
sandboxProvider,
booting,
onBoot,
onRefreshScreen,
}: {
onClose: () => void;
active: Bot | null;
computer: ComputerStatus | null;
embeddedScreenUrl: string | null;
computerScreenError: React.ReactNode;
sandboxProvider: string | undefined;
booting: boolean;
onBoot: () => Promise<void>;
onRefreshScreen: () => Promise<unknown>;
}) {
const { t } = useLingui();
const native = desktopBridge()?.browserPreview;
const [state, setState] = useState<RakazoDesktopBrowserPreviewState>(() => ({
...EMPTY_STATE,
url: native ? "" : readLastUrl(),
}));
const [draft, setDraft] = useState(state.url);
const [invalid, setInvalid] = useState(false);
const [frameKey, setFrameKey] = useState(0);
const editing = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!native) return;
let live = true;
const apply = (next: RakazoDesktopBrowserPreviewState) => {
if (!live) return;
setState(next);
if (!editing.current) setDraft(next.url);
};
const unsubscribe = native.onState(apply);
void native
.state()
.then((current) => {
apply(current);
const last = readLastUrl();
if (!current.url && last) void native.navigate(last).catch(() => undefined);
})
.catch(() => undefined);
return () => {
live = false;
unsubscribe();
};
}, [native]);
useEffect(() => {
if (!state.url) inputRef.current?.focus();
}, [state.url]);
const go = useCallback(
(input: string) => {
const url = normalizeBrowserUrl(input);
setInvalid(!url);
if (!url) return;
editing.current = false;
setDraft(url);
rememberUrl(url);
if (native) {
void native.navigate(url).catch(() => setInvalid(true));
} else {
setState((current) => ({ ...current, url, loading: true, error: null }));
setFrameKey((key) => key + 1);
}
},
[native],
);
const reload = useCallback(() => {
if (native) {
void (state.loading ? native.stop() : native.reload()).catch(() => undefined);
return;
}
if (!state.url) return;
setState((current) => ({ ...current, loading: true }));
setFrameKey((key) => key + 1);
}, [native, state.loading, state.url]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
go(draft);
inputRef.current?.blur();
};
const [mode, setMode] = useState<InAppBrowserMode>("oo");
return (
<div className="flex h-full min-h-0 flex-col" data-testid="in-app-browser">
<form onSubmit={onSubmit} className="relative flex items-center gap-0.5 border-b px-2 py-1.5">
{native ? (
<>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Back`}
disabled={!state.canGoBack}
onClick={() => void native.back().catch(() => undefined)}
>
<ArrowLeft strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Forward`}
disabled={!state.canGoForward}
onClick={() => void native.forward().catch(() => undefined)}
>
<ArrowRight strokeWidth={1.7} className="size-icon-md" />
</Button>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={state.loading ? t`Stop` : t`Reload`}
disabled={!state.url}
onClick={reload}
>
{state.loading && native ? (
<X strokeWidth={1.8} className="size-icon-md" />
) : (
<RotateCw strokeWidth={1.7} className="size-icon-md" />
)}
</Button>
<Input
ref={inputRef}
value={draft}
aria-label={t`Address`}
aria-invalid={invalid || undefined}
inputMode="url"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
placeholder="localhost:5173"
onFocus={(event) => {
editing.current = true;
event.currentTarget.select();
}}
onBlur={() => {
editing.current = false;
}}
onChange={(event) => {
setDraft(event.target.value);
setInvalid(false);
}}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
setDraft(state.url);
setInvalid(false);
event.currentTarget.blur();
}}
className="mx-1 h-control-sm flex-1 rounded-full bg-muted/60 px-3 text-ui-body-sm"
/>
{native ? (
<div className="flex items-center justify-between gap-2 border-b px-2 py-1">
<div className="flex min-w-0 flex-1 gap-1 overflow-x-auto">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Developer tools`}
disabled={!state.url}
onClick={() => void native.openDevTools().catch(() => undefined)}
size="sm"
variant={mode === "oo" ? "secondary" : "ghost"}
className="shrink-0 rounded-full"
disabled={!active}
data-testid="in-app-browser-mode-oo"
onClick={() => setMode("oo")}
>
<Code strokeWidth={1.7} className="size-icon-md" />
{active ? <Trans>{active.name}'s browser</Trans> : <Trans>Oo browser</Trans>}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Open in browser`}
disabled={!state.url}
onClick={() => window.open(state.url, "_blank", "noopener,noreferrer")}
>
<ExternalLink strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
size="sm"
variant={mode === "local" ? "secondary" : "ghost"}
className="shrink-0 rounded-full"
data-testid="in-app-browser-mode-local"
onClick={() => setMode("local")}
>
<Trans>Local preview</Trans>
</Button>
</div>
<Button
type="button"
variant="ghost"
@ -218,100 +67,27 @@ export function InAppBrowser({ onClose }: { onClose: () => void }) {
>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
<div
aria-hidden
className={cn(
"absolute inset-x-0 -bottom-px h-0.5 overflow-hidden transition-opacity duration-300",
state.loading ? "opacity-100" : "opacity-0",
)}
>
<div className="rk-browser-progress h-full w-1/3 rounded-full bg-primary/70" />
</div>
</form>
<div className="relative min-h-0 flex-1 bg-background">
{native ? (
<NativeViewport browser={native} visible={Boolean(state.url) && !state.error} />
) : state.url ? (
<iframe
key={frameKey}
title={state.title || t`Page`}
src={state.url}
sandbox={browserFrameSandbox(state.url, window.location.origin)}
allow="clipboard-read; clipboard-write; fullscreen"
onLoad={() => setState((current) => ({ ...current, loading: false }))}
className="h-full w-full border-0 bg-background"
data-testid="in-app-browser-frame"
</div>
<div className={cn("flex min-h-0 flex-1 flex-col", mode === "local" ? "" : "")}>
{mode === "local" ? (
<LocalBrowserPreview />
) : active ? (
<OoComputerBrowser
active={active}
computer={computer}
embeddedScreenUrl={embeddedScreenUrl}
computerScreenError={computerScreenError}
sandboxProvider={sandboxProvider}
booting={booting}
onBoot={onBoot}
onRefreshScreen={onRefreshScreen}
/>
) : null}
{state.error ? (
<div
role="alert"
className="absolute inset-0 grid place-items-center px-6 text-center text-ui-body-sm text-muted-foreground"
>
{state.error}
) : (
<div className="grid flex-1 place-items-center px-6 text-center text-ui-body-sm text-muted-foreground">
<Trans>Select an Oo to open its browser.</Trans>
</div>
) : null}
)}
</div>
</div>
);
}
/**
* Reserves the page area and keeps the native view glued to it while the panel resizes,
* hiding it whenever app UI (dialogs, menus) floats where the view would paint over it.
*/
function NativeViewport({
browser,
visible,
}: {
browser: RakazoDesktopBrowserPreview;
visible: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
let last = "";
let frame = 0;
const sync = () => {
frame = 0;
const rect = element.getBoundingClientRect();
const covered = Array.from(document.querySelectorAll(FLOATING_UI)).some(
(node) => !element.contains(node),
);
const show = visible && rect.width >= 1 && rect.height >= 1 && !covered;
const bounds = {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
const key = show ? JSON.stringify(bounds) : "hidden";
if (key === last) return;
last = key;
void (show ? browser.show(bounds) : browser.hide()).catch(() => undefined);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(sync);
};
const resize = new ResizeObserver(schedule);
resize.observe(element);
const floating = new MutationObserver(schedule);
floating.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", schedule);
// Sibling layout (sidebars, transitions) can move the area without resizing it.
const settle = setInterval(schedule, 250);
schedule();
return () => {
cancelAnimationFrame(frame);
resize.disconnect();
floating.disconnect();
window.removeEventListener("resize", schedule);
clearInterval(settle);
void browser.hide().catch(() => undefined);
};
}, [browser, visible]);
return <div ref={ref} className="absolute inset-0" data-testid="in-app-browser-viewport" />;
}

View File

@ -0,0 +1,280 @@
import { useLingui } from "@lingui/react/macro";
import type {
RakazoDesktopBrowserPreview,
RakazoDesktopBrowserPreviewState,
} from "@rakazo/contracts";
import { Button, cn, Input } from "@rakazo/ui-web";
import { ArrowLeft, ArrowRight, Code, ExternalLink, RotateCw } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { browserFrameSandbox, normalizeBrowserUrl } from "../../lib/browser-url";
import { desktopBridge } from "../../lib/desktop";
const LAST_URL_KEY = "rk:in-app-browser:local:url";
function readLastUrl(): string {
try {
return window.localStorage.getItem(LAST_URL_KEY) ?? "";
} catch {
return "";
}
}
function rememberUrl(url: string) {
try {
window.localStorage.setItem(LAST_URL_KEY, url);
} catch {
/* A per-viewer convenience only. */
}
}
const EMPTY_STATE: RakazoDesktopBrowserPreviewState = {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null,
};
const FLOATING_UI = '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]';
/** Localhost dev preview (iframe or desktop native view), not the Oo's computer browser. */
export function LocalBrowserPreview() {
const { t } = useLingui();
const native = desktopBridge()?.browserPreview;
const [state, setState] = useState<RakazoDesktopBrowserPreviewState>(() => ({
...EMPTY_STATE,
url: native ? "" : readLastUrl(),
}));
const [draft, setDraft] = useState(state.url);
const [invalid, setInvalid] = useState(false);
const [frameKey, setFrameKey] = useState(0);
const editing = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!native) return;
let live = true;
const apply = (next: RakazoDesktopBrowserPreviewState) => {
if (!live) return;
setState(next);
if (!editing.current) setDraft(next.url);
};
const unsubscribe = native.onState(apply);
void native
.state()
.then((current) => {
apply(current);
const last = readLastUrl();
if (!current.url && last) void native.navigate(last).catch(() => undefined);
})
.catch(() => undefined);
return () => {
live = false;
unsubscribe();
};
}, [native]);
useEffect(() => {
if (!state.url) inputRef.current?.focus();
}, [state.url]);
const go = useCallback(
(input: string) => {
const url = normalizeBrowserUrl(input);
setInvalid(!url);
if (!url) return;
editing.current = false;
setDraft(url);
rememberUrl(url);
if (native) {
void native.navigate(url).catch(() => setInvalid(true));
} else {
setState((current) => ({ ...current, url, loading: true, error: null }));
setFrameKey((key) => key + 1);
}
},
[native],
);
const reload = useCallback(() => {
if (native) {
void (state.loading ? native.stop() : native.reload()).catch(() => undefined);
return;
}
if (!state.url) return;
setState((current) => ({ ...current, loading: true }));
setFrameKey((key) => key + 1);
}, [native, state.loading, state.url]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
go(draft);
inputRef.current?.blur();
};
return (
<>
<form onSubmit={onSubmit} className="relative flex items-center gap-0.5 border-b px-2 py-1.5">
{native ? (
<>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Back`}
disabled={!state.canGoBack}
onClick={() => void native.back().catch(() => undefined)}
>
<ArrowLeft strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Forward`}
disabled={!state.canGoForward}
onClick={() => void native.forward().catch(() => undefined)}
>
<ArrowRight strokeWidth={1.7} className="size-icon-md" />
</Button>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={state.loading ? t`Stop` : t`Reload`}
disabled={!state.url}
onClick={reload}
>
<RotateCw strokeWidth={1.7} className="size-icon-md" />
</Button>
<Input
ref={inputRef}
value={draft}
aria-label={t`Address`}
aria-invalid={invalid || undefined}
inputMode="url"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
placeholder="localhost:5173"
onFocus={(event) => {
editing.current = true;
event.currentTarget.select();
}}
onBlur={() => {
editing.current = false;
}}
onChange={(event) => {
setDraft(event.target.value);
setInvalid(false);
}}
className="mx-1 h-control-sm flex-1 rounded-full bg-muted/60 px-3 text-ui-body-sm"
/>
{native ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Developer tools`}
disabled={!state.url}
onClick={() => void native.openDevTools().catch(() => undefined)}
>
<Code strokeWidth={1.7} className="size-icon-md" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Open in browser`}
disabled={!state.url}
onClick={() => window.open(state.url, "_blank", "noopener,noreferrer")}
>
<ExternalLink strokeWidth={1.7} className="size-icon-md" />
</Button>
</form>
<div className="relative min-h-0 flex-1 bg-background">
{native ? (
<NativeViewport browser={native} visible={Boolean(state.url) && !state.error} />
) : state.url ? (
<iframe
key={frameKey}
title={state.title || t`Page`}
src={state.url}
sandbox={browserFrameSandbox(state.url, window.location.origin)}
allow="clipboard-read; clipboard-write; fullscreen"
onLoad={() => setState((current) => ({ ...current, loading: false }))}
className="h-full w-full border-0 bg-background"
data-testid="in-app-browser-frame"
/>
) : null}
{state.error ? (
<div
role="alert"
className="absolute inset-0 grid place-items-center px-6 text-center text-ui-body-sm text-muted-foreground"
>
{state.error}
</div>
) : null}
</div>
</>
);
}
function NativeViewport({
browser,
visible,
}: {
browser: RakazoDesktopBrowserPreview;
visible: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
let last = "";
let frame = 0;
const sync = () => {
frame = 0;
const rect = element.getBoundingClientRect();
const covered = Array.from(document.querySelectorAll(FLOATING_UI)).some(
(node) => !element.contains(node),
);
const show = visible && rect.width >= 1 && rect.height >= 1 && !covered;
const bounds = {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
const key = show ? JSON.stringify(bounds) : "hidden";
if (key === last) return;
last = key;
void (show ? browser.show(bounds) : browser.hide()).catch(() => undefined);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(sync);
};
const resize = new ResizeObserver(schedule);
resize.observe(element);
const floating = new MutationObserver(schedule);
floating.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", schedule);
const settle = setInterval(schedule, 250);
schedule();
return () => {
cancelAnimationFrame(frame);
resize.disconnect();
floating.disconnect();
window.removeEventListener("resize", schedule);
clearInterval(settle);
void browser.hide().catch(() => undefined);
};
}, [browser, visible]);
return <div ref={ref} className="absolute inset-0" data-testid="in-app-browser-viewport" />;
}

View File

@ -0,0 +1,258 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, ComputerStatus } from "@rakazo/contracts";
import { Button, cn, Input } from "@rakazo/ui-web";
import { ArrowLeft, ArrowRight, ExternalLink, Loader2, RotateCw } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { normalizeBrowserUrl } from "../../lib/browser-url";
import { rpc } from "../../lib/rpc";
import { screenIframeSandbox } from "../../pages/shell/computer-screen";
import { ComputersUnavailableHint, computersAreUnavailable } from "../ComputersUnavailableHint";
import { AgentCursor } from "../computer/AgentCursor";
import { BrowserCoworkOverlay } from "./BrowserCoworkOverlay";
type ChromeState = {
url: string;
title: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
};
const EMPTY_CHROME: ChromeState = {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
};
export function OoComputerBrowser({
active,
computer,
embeddedScreenUrl,
computerScreenError,
sandboxProvider,
booting,
onBoot,
onRefreshScreen,
}: {
active: Bot;
computer: ComputerStatus | null;
embeddedScreenUrl: string | null;
computerScreenError: React.ReactNode;
sandboxProvider: string | undefined;
booting: boolean;
onBoot: () => Promise<void>;
onRefreshScreen: () => Promise<unknown>;
}) {
const { t } = useLingui();
const [chrome, setChrome] = useState<ChromeState>(EMPTY_CHROME);
const [draft, setDraft] = useState("");
const [invalid, setInvalid] = useState(false);
const [busy, setBusy] = useState(false);
const editing = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const engagedScreen = useRef(false);
const refreshChrome = useCallback(async () => {
if (computer?.state !== "running") return;
try {
const next = await rpc.computer.browserChromeState({ botId: active.id });
setChrome(next);
if (!editing.current) setDraft(next.url);
} catch {
/* Address bar may lag the live tab while the computer boots. */
}
}, [active.id, computer?.state]);
useEffect(() => {
if (computer?.state !== "running") return;
void refreshChrome();
const timer = window.setInterval(() => void refreshChrome(), 4000);
return () => window.clearInterval(timer);
}, [computer?.state, refreshChrome]);
useEffect(() => {
if (computer?.state === "running" && embeddedScreenUrl) return;
void onBoot().catch(() => undefined);
}, [active.id, computer?.state, embeddedScreenUrl, onBoot]);
const runNav = useCallback(
async (task: () => Promise<ChromeState>) => {
setBusy(true);
try {
const next = await task();
setChrome(next);
if (!editing.current) setDraft(next.url);
await onRefreshScreen();
} finally {
setBusy(false);
}
},
[onRefreshScreen],
);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const url = normalizeBrowserUrl(draft);
setInvalid(!url);
if (!url) return;
editing.current = false;
void runNav(async () => {
const next = await rpc.computer.browserNavigate({ botId: active.id, url });
await rpc.computer
.browserUserAction({
botId: active.id,
kind: "navigate",
detail: url,
})
.catch(() => undefined);
return next;
});
inputRef.current?.blur();
};
const reportUserAction = useCallback(
(detail: { kind: "click" | "scroll" | "type"; detail: string }) => {
void rpc.computer
.browserUserAction({
botId: active.id,
kind: detail.kind,
detail: detail.detail,
passwordField: detail.kind === "type" && detail.detail.length > 1 ? false : undefined,
})
.catch(() => undefined);
},
[active.id],
);
const showScreen = computer?.state === "running" && embeddedScreenUrl && !computerScreenError;
const unavailable = computersAreUnavailable(sandboxProvider);
return (
<>
<form onSubmit={onSubmit} className="relative flex items-center gap-0.5 border-b px-2 py-1.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Back`}
disabled={!chrome.canGoBack || busy}
onClick={() => void runNav(() => rpc.computer.browserBack({ botId: active.id }))}
>
<ArrowLeft strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Forward`}
disabled={!chrome.canGoForward || busy}
onClick={() => void runNav(() => rpc.computer.browserForward({ botId: active.id }))}
>
<ArrowRight strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Reload`}
disabled={busy || computer?.state !== "running"}
onClick={() => void runNav(() => rpc.computer.browserReload({ botId: active.id }))}
>
{busy || chrome.loading ? (
<Loader2 strokeWidth={1.7} className="size-icon-md animate-spin" />
) : (
<RotateCw strokeWidth={1.7} className="size-icon-md" />
)}
</Button>
<Input
ref={inputRef}
value={draft}
aria-label={t`Address`}
aria-invalid={invalid || undefined}
inputMode="url"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
placeholder="https://"
onFocus={(event) => {
editing.current = true;
event.currentTarget.select();
}}
onBlur={() => {
editing.current = false;
}}
onChange={(event) => {
setDraft(event.target.value);
setInvalid(false);
}}
className="mx-1 h-control-sm min-w-0 flex-1 rounded-full bg-muted/60 px-3 text-ui-body-sm"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Open in browser`}
disabled={!chrome.url}
onClick={() => window.open(chrome.url, "_blank", "noopener,noreferrer")}
>
<ExternalLink strokeWidth={1.7} className="size-icon-md" />
</Button>
<div
aria-hidden
className={cn(
"absolute inset-x-0 -bottom-px h-0.5 overflow-hidden transition-opacity duration-300",
chrome.loading || booting ? "opacity-100" : "opacity-0",
)}
>
<div className="rk-browser-progress h-full w-1/3 rounded-full bg-primary/70" />
</div>
</form>
<div
className="relative min-h-0 flex-1 bg-background"
data-testid="oo-computer-browser-viewport"
>
{unavailable ? (
<div className="grid h-full place-items-center px-6 text-center text-ui-body-sm text-muted-foreground">
<ComputersUnavailableHint />
</div>
) : showScreen ? (
<>
<iframe
title={t`${active.name} browser`}
src={embeddedScreenUrl}
sandbox={screenIframeSandbox(embeddedScreenUrl)}
className="h-full w-full border-0 bg-black"
allow="clipboard-read; clipboard-write; fullscreen"
data-testid="oo-computer-browser-screen"
/>
<AgentCursor botId={active.id} color={active.color} name={active.name} />
<BrowserCoworkOverlay
botId={active.id}
computer={computer}
enabled
onEngaged={() => {
engagedScreen.current = true;
void onRefreshScreen();
}}
onUserAction={reportUserAction}
/>
</>
) : (
<div className="grid h-full place-items-center px-6 text-center text-ui-body-sm text-muted-foreground">
{computerScreenError ??
(booting || computer?.state === "booting" ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="size-icon-md animate-spin" aria-hidden />
<Trans>Starting computer</Trans>
</span>
) : (
<Trans>{active.name}'s browser appears here when the computer is running.</Trans>
))}
</div>
)}
</div>
</>
);
}

View File

@ -63,6 +63,11 @@ msgstr "{0} (über {1})"
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr "Browser von {0}"
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr "{0}, Gruppenunterhaltung"
msgid "{0}, main conversation"
msgstr "{0}, Hauptunterhaltung"
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr "Browser von {0}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr "{0}s Browser erscheint hier, wenn der Computer läuft."
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr "Hinzugefügt"
msgid "Adding…"
msgstr "Wird hinzugefügt…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr "Adresse"
@ -788,7 +804,8 @@ msgstr "Autopilot gestoppt"
msgid "Available after the routine is saved"
msgstr "Verfügbar, sobald die Routine gespeichert ist"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr "Desktop-App"
msgid "Desktop update"
msgstr "Desktop-Update"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr "Entwicklertools"
@ -2500,7 +2517,8 @@ msgstr "Alles über dich vergessen?"
msgid "Forgot password?"
msgstr "Passwort vergessen?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr "Vorwärts"
@ -3013,6 +3031,10 @@ msgstr "Lokal"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "Mit lokalem Zugriff können Bots ohne Rückfrage Befehle ausführen. Vermeide dies auf gemeinsam genutzten oder öffentlichen Servern."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr "Lokale Vorschau"
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr "Einstellungen des lokalen Servers"
@ -3640,6 +3662,10 @@ msgstr "Nur leere Spaces können gelöscht werden. Lösche zuerst die Bots und G
msgid "Only file and folder names are read."
msgstr "Gelesen werden nur Datei- und Ordnernamen."
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr "Oo-Browser"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr "Name des Oo"
@ -3662,7 +3688,8 @@ msgstr "Chat öffnen"
msgid "Open conversation"
msgstr "Unterhaltung öffnen"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr "Im Browser öffnen"
@ -3732,7 +3759,7 @@ msgstr "Anderes Modell…"
msgid "Other providers"
msgstr "Andere Anbieter"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr "Seite"
@ -4073,7 +4100,8 @@ msgstr "Computer freigeben"
msgid "Release interrupted computer?"
msgstr "Unterbrochenen Computer freigeben?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr "Neu laden"
@ -4420,6 +4448,10 @@ msgstr "Suche…"
msgid "Select {0}"
msgstr "{0} auswählen"
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr "Wähle einen Oo, um seinen Browser zu öffnen."
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr "Erzähl, was dir vorschwebt — oder nenn mir eine Sache, die ich verfei
msgid "Shared"
msgstr "Geteilt"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr "Geteilter Browser"
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr "Gemeinsamer Chat mit 2–6 Bots. Alle antworten in derselben Unterhaltung."
@ -4741,6 +4777,10 @@ msgstr "Sprachunterhaltung starten"
msgid "Start Voice First"
msgstr "Voice First starten"
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr "Computer startet"
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr "Schritt {position} von {total}"
msgid "Steps"
msgstr "Schritte"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr "{0} (over {1})"
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr "{0} browser"
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr "{0}, group conversation"
msgid "{0}, main conversation"
msgstr "{0}, main conversation"
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr "{0}'s browser"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr "{0}'s browser appears here when the computer is running."
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr "Added"
msgid "Adding…"
msgstr "Adding…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr "Address"
@ -788,7 +804,8 @@ msgstr "Autopilot stopped"
msgid "Available after the routine is saved"
msgstr "Available after the routine is saved"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr "Desktop app"
msgid "Desktop update"
msgstr "Desktop update"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr "Developer tools"
@ -2500,7 +2517,8 @@ msgstr "Forget everything about you?"
msgid "Forgot password?"
msgstr "Forgot password?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr "Forward"
@ -3013,6 +3031,10 @@ msgstr "Local"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "Local access lets bots run commands without asking. Avoid it on shared or public servers."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr "Local preview"
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr "Local Server Settings"
@ -3640,6 +3662,10 @@ msgstr "Only empty spaces can be deleted. Delete its bots and groups first."
msgid "Only file and folder names are read."
msgstr "Only file and folder names are read."
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr "Oo browser"
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr "Oo name"
@ -3662,7 +3688,8 @@ msgstr "Open a chat"
msgid "Open conversation"
msgstr "Open conversation"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr "Open in browser"
@ -3732,7 +3759,7 @@ msgstr "Other model…"
msgid "Other providers"
msgstr "Other providers"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr "Page"
@ -4073,7 +4100,8 @@ msgstr "Release computer"
msgid "Release interrupted computer?"
msgstr "Release interrupted computer?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr "Reload"
@ -4420,6 +4448,10 @@ msgstr "Searching…"
msgid "Select {0}"
msgstr "Select {0}"
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr "Select an Oo to open its browser."
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr "Share what you're imagining — or tell me one thing to refine."
msgid "Shared"
msgstr "Shared"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr "Shared browser"
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr "Shared chat with 2-6 bots. They all reply in the same thread."
@ -4741,6 +4777,10 @@ msgstr "Start voice conversation"
msgid "Start Voice First"
msgstr "Start Voice First"
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr "Starting computer"
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr "Step {position} of {total}"
msgid "Steps"
msgstr "Steps"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr ""
msgid "Adding…"
msgstr "Agregando…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr "Disponible después de guardar la rutina"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "¿Olvidaste la contraseña?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "Local"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "El acceso local permite que los bots ejecuten comandos sin preguntar. Evítalo en servidores compartidos o públicos."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr ""
@ -3640,6 +3662,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "Otro modelo…"
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr ""
msgid "Release interrupted computer?"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "Buscando…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "Compartido"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr ""
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "Pasos"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr ""
msgid "Adding…"
msgstr "जोड़ा जा रहा है…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "पासवर्ड भूल गए?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "लोकल"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "स्थानीय पहुँच मिलने पर बॉट्स बिना पूछे कमांड चला सकते हैं। साझा या सार्वजनिक सर्वर पर इससे बचें।"
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr ""
@ -3640,6 +3662,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "अन्य मॉडल…"
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr ""
msgid "Release interrupted computer?"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "खोजा जा रहा है…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "साझा"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr ""
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "चरण"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr ""
msgid "Adding…"
msgstr "추가 중…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr "루틴을 저장한 후 사용할 수 있습니다"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "비밀번호를 잊으셨나요?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "로컬"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "로컬 접근을 허용하면 Bot이 묻지 않고 명령을 실행할 수 있습니다. 공유 서버나 공개 서버에서는 허용하지 마세요."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr ""
@ -3640,6 +3662,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "다른 모델 직접 입력…"
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr ""
msgid "Release interrupted computer?"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "검색 중…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "함께 사용"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr ""
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "실행 단계"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr ""
msgid "Adding…"
msgstr "Adicionando"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "Esqueceu a senha?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "Local"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "O acesso local permite que os bots executem comandos sem perguntar. Evite usá-lo em servidores compartilhados ou públicos."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr ""
@ -3640,6 +3662,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "Outro modelo..."
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr ""
msgid "Release interrupted computer?"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "Pesquisando…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "Compartilhado"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr ""
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "Passos"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr "Добавлен"
msgid "Adding…"
msgstr "Добавление…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr "Доступно после сохранения задачи"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr "Настольное приложение"
msgid "Desktop update"
msgstr "Обновление настольного приложения"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "Забыли пароль?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "Местный"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "Локальный доступ позволяет ботам выполнять команды без запроса разрешения. Не используйте его на общих или публичных серверах."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr "Настройки локального сервера"
@ -3640,6 +3662,10 @@ msgstr "Удалить можно только пустые пространст
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "Другая модель…"
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr "Освободить компьютер"
msgid "Release interrupted computer?"
msgstr "Освободить прерванный компьютер?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "Поиск…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "Общий"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr "Общий чат с 2-6 ботами. Все отвечают в одной ветке."
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "Шаги"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr ""
msgid "Adding…"
msgstr "Ekleniyor…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "Parolanızı mı unuttunuz?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "Yerel"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "Yerel erişim, botların sormadan komut çalıştırmasına izin verir. Paylaşılan veya herkese açık sunucularda bunu kullanma."
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr ""
@ -3640,6 +3662,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "Başka model…"
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr ""
msgid "Release interrupted computer?"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "Aranıyor…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "Ortak"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr ""
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "Adımlar"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -63,6 +63,11 @@ msgstr ""
msgid "{0} {unitLabel}"
msgstr "{0} {unitLabel}"
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0} browser"
msgstr ""
#. placeholder {0}: block.name
#: src/pages/shell/message-cards.tsx
msgid "{0} connection"
@ -145,6 +150,16 @@ msgstr ""
msgid "{0}, main conversation"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/InAppBrowser.tsx
msgid "{0}'s browser"
msgstr ""
#. placeholder {0}: active.name
#: src/components/browser/OoComputerBrowser.tsx
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
@ -489,7 +504,8 @@ msgstr ""
msgid "Adding…"
msgstr "正在添加…"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Address"
msgstr ""
@ -788,7 +804,8 @@ msgstr ""
msgid "Available after the routine is saved"
msgstr "保存例行任务后可用"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
#: src/components/CookingMode.tsx
#: src/components/onboarding/OnboardingNameStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
@ -2061,7 +2078,7 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Developer tools"
msgstr ""
@ -2500,7 +2517,8 @@ msgstr ""
msgid "Forgot password?"
msgstr "忘记密码?"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Forward"
msgstr ""
@ -3013,6 +3031,10 @@ msgstr "本地"
msgid "Local access lets bots run commands without asking. Avoid it on shared or public servers."
msgstr "本地访问权限允许 Bot 无需询问就执行命令。请勿在共享或公开服务器上启用。"
#: src/components/browser/InAppBrowser.tsx
msgid "Local preview"
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Local Server Settings"
msgstr ""
@ -3640,6 +3662,10 @@ msgstr ""
msgid "Only file and folder names are read."
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Oo browser"
msgstr ""
#: src/components/onboarding/OnboardingNameStep.tsx
msgid "Oo name"
msgstr ""
@ -3662,7 +3688,8 @@ msgstr ""
msgid "Open conversation"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in browser"
msgstr ""
@ -3732,7 +3759,7 @@ msgstr "其他模型…"
msgid "Other providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
msgid "Page"
msgstr ""
@ -4073,7 +4100,8 @@ msgstr ""
msgid "Release interrupted computer?"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Reload"
msgstr ""
@ -4420,6 +4448,10 @@ msgstr "正在搜索…"
msgid "Select {0}"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Select an Oo to open its browser."
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
@ -4519,6 +4551,10 @@ msgstr ""
msgid "Shared"
msgstr "共享"
#: src/components/browser/BrowserCoworkOverlay.tsx
msgid "Shared browser"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Shared chat with 2-6 bots. They all reply in the same thread."
msgstr ""
@ -4741,6 +4777,10 @@ msgstr ""
msgid "Start Voice First"
msgstr ""
#: src/components/browser/OoComputerBrowser.tsx
msgid "Starting computer"
msgstr ""
#: src/components/teach/TeachComputerOverlay.tsx
#: src/pages/ModelSettingsOverlay.tsx
msgid "Starting…"
@ -4763,7 +4803,7 @@ msgstr ""
msgid "Steps"
msgstr "步骤"
#: src/components/browser/InAppBrowser.tsx
#: src/components/browser/LocalBrowserPreview.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx

View File

@ -599,7 +599,7 @@ export function ShellPage() {
const manuallyUnread = useRef(new Set<string>());
const readVisibleGroups = useRef(new Set<string>());
const computerVisible = useRef(false);
computerVisible.current = panel === "computer" || computerOpen;
computerVisible.current = panel === "computer" || panel === "browser" || computerOpen;
const autoSpoken = useRef<string | null>(null);
const autoSpokenBotId = useRef<string | null>(null);
@ -3551,7 +3551,25 @@ export function ShellPage() {
/>
) : null
) : panel === "browser" ? (
<InAppBrowser onClose={() => setPanel(null)} />
<InAppBrowser
onClose={() => setPanel(null)}
active={active ?? null}
computer={computer}
embeddedScreenUrl={embeddedScreenUrl}
computerScreenError={computerScreenError}
sandboxProvider={bootstrapMe?.sandboxProvider}
booting={booting}
onBoot={async () => {
if (!active) return;
await bootComputer({ takeControl: false, overlay: true, force: true });
await refreshComputerScreen(active.id);
}}
onRefreshScreen={async () => {
if (!active) return;
await refreshComputerScreen(active.id);
await refreshThread(active.id).catch(() => undefined);
}}
/>
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
{panel !== "routine" &&

View File

@ -579,6 +579,65 @@ def snapshot(client: CdpClient, session_id: str) -> dict[str, Any]:
return {"ok": True, **data}
def browser_state(client: CdpClient, session_id: str) -> dict[str, Any]:
ensure_helpers(client, session_id)
history = client.call("Page.getNavigationHistory", session_id=session_id)
entries = history.get("entries") if isinstance(history, dict) else None
current = history.get("currentIndex") if isinstance(history, dict) else None
can_back = isinstance(current, int) and current > 0
can_forward = (
isinstance(current, int)
and isinstance(entries, list)
and current >= 0
and current < len(entries) - 1
)
url = eval_json(client, session_id, "location.href")
title = eval_json(client, session_id, "(document.title || '').slice(0, 200)")
ready = eval_json(client, session_id, "document.readyState")
return {
"ok": True,
"url": url if isinstance(url, str) else "",
"title": title if isinstance(title, str) else "",
"loading": ready not in ("interactive", "complete"),
"canGoBack": can_back,
"canGoForward": can_forward,
}
def history_step(client: CdpClient, session_id: str, delta: int) -> dict[str, Any]:
ensure_helpers(client, session_id)
history = client.call("Page.getNavigationHistory", session_id=session_id)
current = history.get("currentIndex") if isinstance(history, dict) else None
entries = history.get("entries") if isinstance(history, dict) else None
if not isinstance(current, int) or not isinstance(entries, list):
raise RuntimeError("Navigation history unavailable")
target = current + delta
if target < 0 or target >= len(entries):
raise RuntimeError("Cannot navigate in that direction")
entry_id = entries[target].get("id")
if not isinstance(entry_id, int):
raise RuntimeError("Navigation entry missing")
client.call("Page.navigateToHistoryEntry", {"entryId": entry_id}, session_id=session_id)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
ready = eval_json(client, session_id, "document.readyState")
if ready in ("interactive", "complete"):
return browser_state(client, session_id)
time.sleep(0.1)
raise RuntimeError("History navigation did not settle")
def reload_page(client: CdpClient, session_id: str) -> dict[str, Any]:
client.call("Page.reload", session_id=session_id)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
ready = eval_json(client, session_id, "document.readyState")
if ready in ("interactive", "complete"):
return browser_state(client, session_id)
time.sleep(0.1)
raise RuntimeError("Reload did not settle")
def act(client: CdpClient, session_id: str, actions: list[dict[str, Any]]) -> dict[str, Any]:
if not actions or len(actions) > 24:
raise RuntimeError("Expected 1 to 24 actions")
@ -651,6 +710,14 @@ def run(argv: list[str]) -> int:
session_id = attach_page(client)
if command == "navigate":
result = navigate(client, session_id, str(args.get("url") or ""))
elif command == "state":
result = browser_state(client, session_id)
elif command == "back":
result = history_step(client, session_id, -1)
elif command == "forward":
result = history_step(client, session_id, 1)
elif command == "reload":
result = reload_page(client, session_id)
elif command == "snapshot":
result = snapshot(client, session_id)
elif command == "act":

View File

@ -360,6 +360,10 @@ app.post("/computers/:id/browser", async (c) => {
}),
}),
z.object({ command: z.literal("snapshot") }),
z.object({ command: z.literal("state") }),
z.object({ command: z.literal("back") }),
z.object({ command: z.literal("forward") }),
z.object({ command: z.literal("reload") }),
z.object({
command: z.literal("act"),
actions: z

View File

@ -838,9 +838,29 @@ export interface BrowserActResult {
export type PageBrowserCommand =
| { command: "navigate"; url: string }
| { command: "snapshot" }
| { command: "act"; actions: BrowserActStep[] };
| { command: "act"; actions: BrowserActStep[] }
| { command: "state" }
| { command: "back" }
| { command: "forward" }
| { command: "reload" };
export type PageBrowserResult = Partial<BrowserSnapshotResult & BrowserActResult> & { ok: boolean };
export type PageBrowserChromeState = {
ok: true;
url: string;
title: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
};
export type PageBrowserResult = Partial<BrowserSnapshotResult & BrowserActResult> & {
ok: boolean;
loading?: boolean;
canGoBack?: boolean;
canGoForward?: boolean;
error?: string;
fallback?: "computer_act";
};
/** Vendor-neutral status for a remote cloud coding agent. */
export type CloudAgentStatus = "running" | "finished" | "failed" | "cancelled";

View File

@ -0,0 +1,225 @@
import { randomUUID } from "node:crypto";
import type { AdapterContext, JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
import type { BrowserUserActionKind } from "@rakazo/core";
import { ACTIVE_RUN_STATUSES, browserUserContextLine } from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { createThreadMessage, pauseRunForTakeover } from "@rakazo/db";
import {
expireComputerControl,
hasActiveComputerControl,
scheduleComputerControlExpiry,
takeoverLeaseMs,
} from "./computer-control.js";
import { toComputerRef } from "./computer-support.js";
export type BrowserCoworkDeps = {
prisma: PrismaClient;
sandbox: SandboxProvider;
jobs: JobPublisher;
events: ThreadEvents;
};
function executionBlocksUserTakeover(input: {
hasLease: boolean;
leaseExpiresAt: Date | null | undefined;
runStatus: string | null | undefined;
now?: number;
takeoverRequested?: boolean;
}): boolean {
if (!input.hasLease) return false;
if (input.runStatus === "waiting_takeover" || input.takeoverRequested) return false;
const now = input.now ?? Date.now();
const leaseActive = Boolean(input.leaseExpiresAt && input.leaseExpiresAt.getTime() > now);
const runActive = Boolean(
input.runStatus && (ACTIVE_RUN_STATUSES as readonly string[]).includes(input.runStatus),
);
return leaseActive || runActive;
}
export async function interruptRunningComputerRunForCowork(
deps: BrowserCoworkDeps,
input: {
spaceId: string;
threadId: string;
botId: string;
computerId: string;
},
): Promise<boolean> {
const lease = await deps.prisma.computerExecutionLease.findUnique({
where: { computerId_botId: { computerId: input.computerId, botId: input.botId } },
});
if (!lease) return false;
const run = await deps.prisma.run.findUnique({
where: { id: lease.runId },
select: {
id: true,
status: true,
leaseOwner: true,
leaseFence: true,
attemptId: true,
},
});
if (!run || run.status !== "running" || !run.leaseOwner || run.leaseFence == null) return false;
return pauseRunForTakeover(deps.prisma, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
computerId: input.computerId,
runId: run.id,
attemptId: run.attemptId,
leaseOwner: run.leaseOwner,
leaseFence: run.leaseFence,
reason: "browser_cowork",
});
}
export async function engageBrowserCowork(
deps: BrowserCoworkDeps,
actor: { spaceId: string; userId: string },
bot: {
id: string;
thread: { id: string } | null;
computer: {
id: string;
homeKey: string;
kind: string;
state: string;
providerRef: string | null;
controlHolder: string;
controlBotId: string | null;
controlLeaseId: string | null;
controlLeaseExpiresAt: Date | null;
controlRunId: string | null;
} | null;
},
screenContext: AdapterContext,
): Promise<{ leaseId: string; expiresAt: string; interrupted: boolean }> {
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new Error("computer must be running");
}
const threadId = bot.thread?.id;
if (!threadId) throw new Error("bot thread missing");
let interrupted = false;
if (
hasActiveComputerControl(bot.computer) &&
bot.computer.controlBotId === bot.id &&
bot.computer.controlLeaseId
) {
await scheduleComputerControlExpiry(
deps.jobs,
bot.computer.id,
bot.computer.controlLeaseId,
bot.computer.controlLeaseExpiresAt!,
);
return {
leaseId: bot.computer.controlLeaseId,
expiresAt: bot.computer.controlLeaseExpiresAt!.toISOString(),
interrupted: false,
};
}
const executionLease = await deps.prisma.computerExecutionLease.findUnique({
where: { computerId_botId: { computerId: bot.computer.id, botId: bot.id } },
});
const executionRun = executionLease
? await deps.prisma.run.findUnique({
where: { id: executionLease.runId },
select: { botId: true, status: true },
})
: null;
const waitingForTakeover =
executionRun?.botId === bot.id &&
(executionRun.status === "waiting_takeover" ||
bot.computer.controlRunId === executionLease?.runId);
if (
executionBlocksUserTakeover({
hasLease: Boolean(executionLease),
leaseExpiresAt: executionLease?.expiresAt,
runStatus: executionRun?.status,
takeoverRequested: waitingForTakeover,
})
) {
interrupted = await interruptRunningComputerRunForCowork(deps, {
spaceId: actor.spaceId,
threadId,
botId: bot.id,
computerId: bot.computer.id,
});
}
if (bot.computer.controlLeaseId) {
await expireComputerControl(deps, bot.computer.id, bot.computer.controlLeaseId);
}
const leaseId = randomUUID();
const expiresAt = new Date(Date.now() + takeoverLeaseMs());
const granted = await deps.prisma.computer.updateMany({
where: {
id: bot.computer.id,
controlHolder: { not: "user" },
},
data: {
controlHolder: "user",
controlBotId: bot.id,
controlLeaseId: leaseId,
controlLeaseExpiresAt: expiresAt,
controlRunId: bot.computer.controlRunId,
},
});
if (granted.count !== 1) {
const current = await deps.prisma.computer.findUnique({ where: { id: bot.computer.id } });
if (
current?.controlHolder === "user" &&
current.controlBotId === bot.id &&
current.controlLeaseId
) {
return {
leaseId: current.controlLeaseId,
expiresAt: current.controlLeaseExpiresAt!.toISOString(),
interrupted,
};
}
throw new Error("Could not take browser control");
}
await scheduleComputerControlExpiry(deps.jobs, bot.computer.id, leaseId, expiresAt);
await deps.sandbox.setScreenControl?.(toComputerRef(bot.computer), true, screenContext, leaseId);
await deps.events.append({
spaceId: actor.spaceId,
threadId,
botId: bot.id,
type: "computer.takeover.granted",
payload: { leaseId, takeoverRequested: Boolean(bot.computer.controlRunId) },
});
return { leaseId, expiresAt: expiresAt.toISOString(), interrupted };
}
export async function recordBrowserUserAction(
deps: Pick<BrowserCoworkDeps, "prisma" | "events">,
actor: { spaceId: string },
input: {
botId: string;
threadId: string;
kind: BrowserUserActionKind;
detail: string;
passwordField?: boolean;
},
): Promise<void> {
const text = browserUserContextLine(input.kind, input.detail, input.passwordField);
const blocks = [{ kind: "meta" as const, text }];
const message = await createThreadMessage(deps.prisma, {
threadId: input.threadId,
role: "system",
blocks,
botId: input.botId,
});
await deps.events.append({
spaceId: actor.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "system", blocks },
});
await deps.events.notify(input.threadId, message.seq);
}

View File

@ -14,6 +14,7 @@ import type {
SandboxProvider,
} from "@rakazo/adapter-kit";
import { FakeBrowserProvider, type FakeBrowserProviderOptions } from "./fake-browser.js";
import { pageBrowserSessionKey } from "./page-browser-session.js";
const DETACHED_MESSAGE =
"Page browser is not attached to this computer's Chrome. Use computer_act on the desktop browser instead.";
@ -119,6 +120,53 @@ export class ComputerBrowserProvider implements BrowserProvider {
};
}
async chrome(
computer: ComputerRef,
command: "state" | "back" | "forward" | "reload",
context: AdapterContext,
) {
if (this.canUseInProcess(computer)) {
const session = this.fake.sessions.get(pageBrowserSessionKey(computer, context));
if (command === "state") return session.chromeState();
if (command === "back") {
const moved = session.back();
if (!moved)
return { ok: false as const, error: "cannot go back", fallback: "computer_act" as const };
const state = session.chromeState();
return { ...state };
}
if (command === "forward") {
const moved = session.forward();
if (!moved)
return {
ok: false as const,
error: "cannot go forward",
fallback: "computer_act" as const,
};
const state = session.chromeState();
return { ...state };
}
session.reload();
return session.chromeState();
}
const live = await this.runLive(computer, { command }, context);
if (live?.ok !== true) {
return {
ok: false as const,
error: live?.error || DETACHED_MESSAGE,
fallback: "computer_act" as const,
};
}
return {
ok: true as const,
url: typeof live.url === "string" ? live.url : "",
title: typeof live.title === "string" ? live.title : "",
loading: Boolean(live.loading),
canGoBack: Boolean(live.canGoBack),
canGoForward: Boolean(live.canGoForward),
};
}
async act(
computer: ComputerRef,
request: BrowserActRequest,
@ -177,7 +225,13 @@ export class ComputerBrowserProvider implements BrowserProvider {
uncertain: false,
};
if (result?.ok === true) {
if (typeof result.url !== "string" || typeof result.title !== "string") {
if (request.command === "state" || request.command === "reload") {
if (typeof result.url !== "string" || typeof result.title !== "string") {
throw new Error("Incomplete page browser response");
}
} else if (request.command === "back" || request.command === "forward") {
/* History commands may return navigation metadata only. */
} else if (typeof result.url !== "string" || typeof result.title !== "string") {
throw new Error("Incomplete page browser response");
}
if (

View File

@ -9,6 +9,7 @@ export * from "./background-job-handlers.js";
export * from "./bot-messages.js";
export * from "./box-emulator.js";
export * from "./box-sandbox.js";
export * from "./browser-cowork.js";
export * from "./browser-emulator.js";
export * from "./browser-provider-factory.js";
export * from "./browser-tools.js";

View File

@ -32,12 +32,15 @@ export class PageBrowserSession {
private dom: JSDOM;
private refs = new Map<string, DomElement>();
private nextRef = 1;
private history: string[] = [];
private historyIndex = -1;
constructor(
url = "about:blank",
html = "<!doctype html><html><head><title></title></head><body></body></html>",
) {
this.dom = new JSDOM(html, { url, contentType: "text/html", pretendToBeVisual: true });
this.pushHistory(this.url);
}
close(): void {
@ -57,9 +60,55 @@ export class PageBrowserSession {
this.dom.window.close();
this.dom = new JSDOM(html, { url, contentType: "text/html", pretendToBeVisual: true });
this.refs.clear();
this.pushHistory(this.url);
return { url: this.url, title: this.title };
}
chromeState(): {
ok: true;
url: string;
title: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
} {
return {
ok: true,
url: this.url,
title: this.title,
loading: false,
canGoBack: this.historyIndex > 0,
canGoForward: this.historyIndex >= 0 && this.historyIndex < this.history.length - 1,
};
}
back(): BrowserNavigateResult | null {
if (this.historyIndex <= 0) return null;
this.historyIndex -= 1;
const url = this.history[this.historyIndex]!;
this.dom.window.history.back();
return { url: this.url, title: this.title };
}
forward(): BrowserNavigateResult | null {
if (this.historyIndex < 0 || this.historyIndex >= this.history.length - 1) return null;
this.historyIndex += 1;
this.dom.window.history.forward();
return { url: this.url, title: this.title };
}
reload(): BrowserNavigateResult {
this.dom.window.location.reload();
return { url: this.url, title: this.title };
}
private pushHistory(url: string) {
if (this.historyIndex >= 0 && this.history[this.historyIndex] === url) return;
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(url);
this.historyIndex = this.history.length - 1;
}
snapshot(): BrowserSnapshotResult {
const { elements, tree } = this.buildSnapshot();
return {

View File

@ -509,6 +509,68 @@ export const appContract = {
.output(z.object({ path: z.string(), content: z.string() })),
screenUrl: oc.input(botId).output(z.object({ url: z.string().nullable() })),
heartbeat: oc.input(botId).output(z.object({ ok: z.literal(true) })),
browserChromeState: oc.input(botId).output(
z.object({
url: z.string(),
title: z.string(),
loading: z.boolean(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
}),
),
browserNavigate: oc.input(z.object({ botId: Id, url: z.string().min(1).max(8192) })).output(
z.object({
url: z.string(),
title: z.string(),
loading: z.boolean(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
}),
),
browserBack: oc.input(botId).output(
z.object({
url: z.string(),
title: z.string(),
loading: z.boolean(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
}),
),
browserForward: oc.input(botId).output(
z.object({
url: z.string(),
title: z.string(),
loading: z.boolean(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
}),
),
browserReload: oc.input(botId).output(
z.object({
url: z.string(),
title: z.string(),
loading: z.boolean(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
}),
),
browserCoworkEngage: oc.input(botId).output(
z.object({
leaseId: Id,
expiresAt: z.string(),
interrupted: z.boolean(),
}),
),
browserUserAction: oc
.input(
z.object({
botId: Id,
kind: z.enum(["navigate", "click", "type", "scroll", "page_change"]),
detail: z.string().max(2000),
passwordField: z.boolean().optional(),
}),
)
.output(z.object({ ok: z.literal(true) })),
},
memory: {
list: oc

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { browserUserContextLine, maskBrowserUserDetail } from "./browser-cowork.js";
describe("browser cowork context", () => {
it("masks password-like labels and fields", () => {
expect(maskBrowserUserDetail("secret-token-value", true)).toBe("••••••");
expect(maskBrowserUserDetail("Password field", false)).toBe("••••••");
});
it("formats user navigation lines", () => {
expect(browserUserContextLine("navigate", "https://example.com")).toBe(
"User navigated to https://example.com",
);
expect(browserUserContextLine("type", "hello", true)).toBe("User typed in a password field");
});
});

View File

@ -0,0 +1,35 @@
/** User-visible browser cowork lines for the Oo's thread context (German product copy is applied in UI). */
export type BrowserUserActionKind = "navigate" | "click" | "type" | "scroll" | "page_change";
export function maskBrowserUserDetail(detail: string, passwordField = false): string {
if (passwordField) return "••••••";
const trimmed = detail.trim();
if (!trimmed) return trimmed;
if (/password|passwort|pin|secret|token|api[_-]?key/i.test(trimmed)) return "••••••";
return trimmed.slice(0, 500);
}
export function browserUserContextLine(
kind: BrowserUserActionKind,
detail: string,
passwordField = false,
): string {
const safe = maskBrowserUserDetail(detail, passwordField);
switch (kind) {
case "navigate":
return `User navigated to ${safe}`;
case "click":
return `User clicked ${safe}`;
case "type":
return passwordField ? "User typed in a password field" : `User typed ${safe}`;
case "scroll":
return `User scrolled ${safe}`;
case "page_change":
return `User page changed to ${safe}`;
default: {
const _exhaustive: never = kind;
return _exhaustive;
}
}
}

View File

@ -8,6 +8,7 @@ export * from "./autopilot.js";
export * from "./bot-messages.js";
export * from "./bot-reply-sanitize.js";
export * from "./bot-sections.js";
export * from "./browser-cowork.js";
export * from "./cards.js";
export * from "./cloud-agent.js";
export * from "./coach.js";

View File

@ -145,6 +145,16 @@ describeWithDatabase("API authorization and resource isolation", () => {
["computer/files", { botId: "missing-bot", path: "/" }],
["computer/readFile", { botId: "missing-bot", path: "MEMORY.md" }],
["computer/screenUrl", { botId: "missing-bot" }],
["computer/browserChromeState", { botId: "missing-bot" }],
["computer/browserNavigate", { botId: "missing-bot", url: "https://example.com" }],
["computer/browserBack", { botId: "missing-bot" }],
["computer/browserForward", { botId: "missing-bot" }],
["computer/browserReload", { botId: "missing-bot" }],
["computer/browserCoworkEngage", { botId: "missing-bot" }],
[
"computer/browserUserAction",
{ botId: "missing-bot", kind: "navigate", detail: "https://example.com" },
],
["computer/heartbeat", { botId: "missing-bot" }],
["memory/list", {}],
["memory/update", { documentId: "missing-memory", content: "Nope" }],
@ -378,6 +388,13 @@ describeWithDatabase("API authorization and resource isolation", () => {
["computer/files", { botId: ownerBot.id, path: "/" }],
["computer/readFile", { botId: ownerBot.id, path: "PRIVATE.md" }],
["computer/screenUrl", { botId: ownerBot.id }],
["computer/browserChromeState", { botId: ownerBot.id }],
["computer/browserNavigate", { botId: ownerBot.id, url: "https://example.com" }],
["computer/browserBack", { botId: ownerBot.id }],
["computer/browserForward", { botId: ownerBot.id }],
["computer/browserReload", { botId: ownerBot.id }],
["computer/browserCoworkEngage", { botId: ownerBot.id }],
["computer/browserUserAction", { botId: ownerBot.id, kind: "click", detail: "Submit" }],
["computer/heartbeat", { botId: ownerBot.id }],
["routines/list", { botId: ownerBot.id }],
["routines/create", routineInput(ownerBot.id)],