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:
parent
81468063ff
commit
f07d17e927
@ -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");
|
||||
}
|
||||
|
||||
@ -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/"]);
|
||||
|
||||
88
apps/web/e2e/oo-computer-browser.spec.ts
Normal file
88
apps/web/e2e/oo-computer-browser.spec.ts
Normal 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");
|
||||
});
|
||||
181
apps/web/src/components/browser/BrowserCoworkOverlay.tsx
Normal file
181
apps/web/src/components/browser/BrowserCoworkOverlay.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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 ? (
|
||||
<>
|
||||
<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`Back`}
|
||||
disabled={!state.canGoBack}
|
||||
onClick={() => void native.back().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")}
|
||||
>
|
||||
<ArrowLeft strokeWidth={1.7} className="size-icon-md" />
|
||||
{active ? <Trans>{active.name}'s browser</Trans> : <Trans>Oo browser</Trans>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Forward`}
|
||||
disabled={!state.canGoForward}
|
||||
onClick={() => void native.forward().catch(() => undefined)}
|
||||
size="sm"
|
||||
variant={mode === "local" ? "secondary" : "ghost"}
|
||||
className="shrink-0 rounded-full"
|
||||
data-testid="in-app-browser-mode-local"
|
||||
onClick={() => setMode("local")}
|
||||
>
|
||||
<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 ? (
|
||||
<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" />
|
||||
<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 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" />;
|
||||
}
|
||||
|
||||
280
apps/web/src/components/browser/LocalBrowserPreview.tsx
Normal file
280
apps/web/src/components/browser/LocalBrowserPreview.tsx
Normal 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" />;
|
||||
}
|
||||
258
apps/web/src/components/browser/OoComputerBrowser.tsx
Normal file
258
apps/web/src/components/browser/OoComputerBrowser.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" &&
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
225
packages/adapters/src/browser-cowork.ts
Normal file
225
packages/adapters/src/browser-cowork.ts
Normal 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);
|
||||
}
|
||||
@ -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,9 +225,15 @@ export class ComputerBrowserProvider implements BrowserProvider {
|
||||
uncertain: false,
|
||||
};
|
||||
if (result?.ok === true) {
|
||||
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 (
|
||||
request.command === "snapshot" &&
|
||||
(typeof result.tree !== "string" || !Array.isArray(result.elements))
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
16
packages/core/src/browser-cowork.test.ts
Normal file
16
packages/core/src/browser-cowork.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
35
packages/core/src/browser-cowork.ts
Normal file
35
packages/core/src/browser-cowork.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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)],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user