From 1d2b6cd5600b944a34ce7aa907c63c8207c07e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 22:36:12 +0200 Subject: [PATCH 1/6] feat(server): each thread's provider processes get its agent-browser session On a Lazurio Remote Environment the agents of every thread work in the Environment browser, one shared Chromium, through the agent-browser CLI, each thread in a window of its own (root decision 0191, plan DEV-6646). The CLI takes its session from AGENT_BROWSER_SESSION, so every provider process T3 starts for a thread now carries the thread's name: `t3-` and the thread id with every character outside [A-Za-z0-9_-] replaced by `-`, at most 64 characters (agent-browser's grammar and its dashboard's limit). ProviderService records the name with the thread's provider session, whose environment every adapter already spreads into the processes it starts for the thread (the path the agent-device CLI takes). So the Codex app-server, Claude Code and the other adapters get it from one seam: a single call in ProviderService.ts; the rest lives in apps/server/src/lazurio/. The name wins over one T3 itself inherited. It only names the session: nothing is granted, and it is set whether or not T3's own agent browser access is on. Tests cover the name for UUIDs, dots, colons, slashes, non-ASCII and long ids, ProviderService recording it before the adapter starts, and the environment the Codex app-server and Claude Code start with. CI runs them (`vp test run src/lazurio` in apps/server) and the release contract test keeps the seam. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/lazurio-fork-ci.yml | 4 + .../src/lazurio/agentBrowserSession.test.ts | 296 ++++++++++++++++++ .../server/src/lazurio/agentBrowserSession.ts | 42 +++ .../src/provider/Layers/ProviderService.ts | 12 +- scripts/lazurio-release-contract.test.mjs | 24 ++ 5 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/lazurio/agentBrowserSession.test.ts create mode 100644 apps/server/src/lazurio/agentBrowserSession.ts diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 41324dafc533..53cee7dcc801 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -119,6 +119,10 @@ jobs: src/environment/ServerEnvironmentLabel.test.ts pnpm exec vp test run src/server.test.ts + - name: Test the Lazurio server overlay + working-directory: apps/server + run: pnpm exec vp test run src/lazurio + - name: Test the Lazurio web overlay working-directory: apps/web run: pnpm exec vp test run src/lazurio diff --git a/apps/server/src/lazurio/agentBrowserSession.test.ts b/apps/server/src/lazurio/agentBrowserSession.test.ts new file mode 100644 index 000000000000..076dccb63c8a --- /dev/null +++ b/apps/server/src/lazurio/agentBrowserSession.test.ts @@ -0,0 +1,296 @@ +import type { Options as ClaudeQueryOptions } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + ClaudeSettings, + CodexSettings, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderSession, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as CodexErrors from "effect-codex-app-server/errors"; + +import * as ServerConfig from "../config.ts"; +import * as McpProviderSession from "../mcp/McpProviderSession.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import type { ProviderAdapterError } from "../provider/Errors.ts"; +import { makeClaudeAdapter } from "../provider/Layers/ClaudeAdapter.ts"; +import { makeCodexAdapter } from "../provider/Layers/CodexAdapter.ts"; +import type { CodexSessionRuntimeOptions } from "../provider/Layers/CodexSessionRuntime.ts"; +import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import type { ProviderAdapterShape } from "../provider/Services/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../provider/Services/ProviderAdapterRegistry.ts"; +import * as ProviderService from "../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; +import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { + AGENT_BROWSER_SESSION_ENV, + agentBrowserSessionName, + withAgentBrowserSession, +} from "./agentBrowserSession.ts"; + +const codexSettings = Schema.decodeSync(CodexSettings)({}); +const claudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const threadId = ThreadId.make("4a1f9c2e-7b3d-4e5f-8a6b-9c0d1e2f3a4b"); +const sessionName = "t3-4a1f9c2e-7b3d-4e5f-8a6b-9c0d1e2f3a4b"; + +/** The per-thread config ProviderService records when the MCP credential is issued. */ +const providerSession = ( + instanceId: ProviderInstanceId, + extra: Partial = {}, +): McpProviderSession.McpProviderSessionConfig => ({ + environmentId: EnvironmentId.make("environment-browser-test"), + threadId, + providerSessionId: "provider-session-browser-test", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:1/mcp", + authorizationHeader: "Bearer fixture", + capabilities: new Set(["pull-requests"]), + ...extra, +}); + +/** Records the session for the length of `effect`, as ProviderService does around a session. */ +const withRecordedSession = ( + config: McpProviderSession.McpProviderSessionConfig, + effect: Effect.Effect, +) => + Effect.sync(() => McpProviderSession.setMcpProviderSession(config)).pipe( + Effect.andThen(effect), + Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(config.threadId))), + ); + +const testBaseDir = { prefix: "lazurio-agent-browser-" } as const; + +describe("agent-browser session name", () => { + it("is t3- and the thread id, sanitized to agent-browser's grammar, at most 64 long", () => { + const cases: ReadonlyArray = [ + [threadId, sessionName], + ["thread.with:colons/and/slashes", "t3-thread-with-colons-and-slashes"], + ["under_score and space", "t3-under_score-and-space"], + ["vlákno-č", "t3-vl-kno--"], + ["emoji-\u{1F642}", "t3-emoji---"], + ["x".repeat(100), `t3-${"x".repeat(61)}`], + ["", "t3-"], + ]; + for (const [id, expected] of cases) { + const name = agentBrowserSessionName(id); + assert.deepStrictEqual([id, name], [id, expected]); + assert.match(name, /^[A-Za-z0-9_-]{1,64}$/); + } + }); + + it("joins the thread's provider-session environment and keeps the device variables", () => { + const device = { + PATH: "/t3/device/bin", + PATH_SEPARATOR: ":", + AGENT_DEVICE_DAEMON_BASE_URL: "http://127.0.0.1:9000", + }; + const recorded = providerSession(ProviderInstanceId.make("codex"), { + agentDeviceEnvironment: device, + }); + const withSession = withAgentBrowserSession(recorded); + assert.deepStrictEqual(withSession.agentDeviceEnvironment, { + ...device, + [AGENT_BROWSER_SESSION_ENV]: sessionName, + }); + assert.deepStrictEqual(recorded.agentDeviceEnvironment, device); + // What every adapter spreads into a provider process: the thread's name wins over an + // inherited one, and without device access nothing else is added. + assert.deepStrictEqual( + McpProviderSession.withAgentDeviceEnvironment( + { PATH: "/usr/bin", AGENT_BROWSER_SESSION: "inherited" }, + withAgentBrowserSession(providerSession(ProviderInstanceId.make("codex"))), + ), + { PATH: "/usr/bin", AGENT_BROWSER_SESSION: sessionName }, + ); + }); +}); + +describe("ProviderService", () => { + it.effect("records the thread's session before the adapter starts it", () => + Effect.gen(function* () { + const codexInstanceId = ProviderInstanceId.make("codex"); + const environments: Array = []; + // Starts the provider the way every adapter does: the recorded provider session's + // environment over the instance's own. + const adapter: ProviderAdapterShape = { + provider: ProviderDriverKind.make("codex"), + capabilities: { sessionModelSwitch: "in-session" }, + startSession: (input) => + Effect.sync(() => { + environments.push( + McpProviderSession.withAgentDeviceEnvironment( + { PATH: "/usr/bin" }, + McpProviderSession.readMcpProviderSession(input.threadId), + ), + ); + const now = "2026-01-01T00:00:00.000Z"; + return { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + resumeCursor: { opaque: "resume" }, + createdAt: now, + updatedAt: now, + } satisfies ProviderSession; + }), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.void, + respondToRequest: () => Effect.void, + respondToUserInput: () => Effect.void, + stopSession: () => Effect.void, + listSessions: () => Effect.succeed([]), + hasSession: () => Effect.succeed(false), + readThread: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + stopAll: () => Effect.void, + streamEvents: Stream.empty, + }; + const providerLayer = makeProviderServiceLive({ + issueMcpCredential: (request) => + Effect.succeed({ + config: providerSession(request.providerInstanceId, { + threadId: request.threadId, + capabilities: request.capabilities, + }), + }), + }).pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: adapter }), + ), + ), + Layer.provide( + ProviderSessionDirectoryLive.pipe( + Layer.provide( + ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory)), + ), + ), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(ServerConfig.layerTest(process.cwd(), testBaseDir)), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + assert.deepStrictEqual(environments, [ + { PATH: "/usr/bin", AGENT_BROWSER_SESSION: sessionName }, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("provider processes", () => { + it.effect("the Codex app-server of the thread starts with its session", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const started: Array = []; + const adapter = yield* makeCodexAdapter(codexSettings, { + // The spawn boundary: what the app-server would be started with. Refusing to start keeps + // the test free of a running app-server. + makeRuntime: (options) => { + started.push(options); + return Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: `${options.binaryPath} app-server`, + cause: new Error("not started in this test"), + }), + ); + }, + }); + + yield* withRecordedSession( + withAgentBrowserSession(providerSession(instanceId)), + adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.exit), + ); + + assert.strictEqual(started.length, 1); + assert.strictEqual(started[0]?.environment?.[AGENT_BROWSER_SESSION_ENV], sessionName); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), testBaseDir), + ServerSettings.layerTest(), + Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), + getProvider: () => Effect.die("unused"), + getBinding: () => Effect.succeedNone, + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + }), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + + it.effect("the Claude Code process of the thread starts with its session", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const started: Array = []; + const adapter = yield* makeClaudeAdapter(claudeSettings, { + // The spawn boundary: the options the Claude Agent SDK would start Claude Code with. + createQuery: (input) => { + started.push(input.options); + throw new Error("not started in this test"); + }, + }); + + yield* withRecordedSession( + withAgentBrowserSession(providerSession(instanceId)), + adapter + .startSession({ + provider: ProviderDriverKind.make("claudeAgent"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.exit), + ); + + assert.strictEqual(started.length, 1); + assert.strictEqual(started[0]?.env?.[AGENT_BROWSER_SESSION_ENV], sessionName); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), testBaseDir), + ServerSettings.layerTest(), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); +}); diff --git a/apps/server/src/lazurio/agentBrowserSession.ts b/apps/server/src/lazurio/agentBrowserSession.ts new file mode 100644 index 000000000000..4003fea40b9b --- /dev/null +++ b/apps/server/src/lazurio/agentBrowserSession.ts @@ -0,0 +1,42 @@ +/** + * Lazurio overlay (root decision 0191, plan DEV-6646): the agent-browser session of a thread. + * + * On a Lazurio Environment the agents of every thread work in the Environment browser, one + * Chromium on the Environment, through the agent-browser CLI, each thread in a window of its + * own. The CLI takes its session from AGENT_BROWSER_SESSION, so every provider process T3 starts + * for a thread carries the thread's name. The web client asks the Environment for the view of + * the same session, so `apps/web/src/lazurio/agentBrowserSession.ts` holds a copy of + * `agentBrowserSessionName`: sharing it through packages/shared would take an entry in that + * package's export map, which upstream rewrites every few days. The release contract test keeps + * the two copies equal. + */ + +export const AGENT_BROWSER_SESSION_ENV = "AGENT_BROWSER_SESSION"; + +/** agent-browser accepts [A-Za-z0-9_-] in a session name; its dashboard takes 64 characters. */ +const SESSION_NAME_LIMIT = 64; + +/** `t3-` and the thread id with every character outside [A-Za-z0-9_-] as `-`, at most 64 long. */ +export function agentBrowserSessionName(threadId: string): string { + return `t3-${threadId.replace(/[^A-Za-z0-9_-]/g, "-")}`.slice(0, SESSION_NAME_LIMIT); +} + +/** + * The provider-session config ProviderService records for a thread, with the thread's session + * added to the environment every adapter spreads into the processes it starts for that thread + * (`withAgentDeviceEnvironment`). The thread's own name wins over one T3 itself inherited. + */ +export function withAgentBrowserSession< + Config extends { + readonly threadId: string; + readonly agentDeviceEnvironment?: Readonly>; + }, +>(config: Config): Config { + return { + ...config, + agentDeviceEnvironment: { + ...config.agentDeviceEnvironment, + [AGENT_BROWSER_SESSION_ENV]: agentBrowserSessionName(config.threadId), + }, + }; +} diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a90a77ab86f4..1060760d348c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -84,6 +84,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; +import { withAgentBrowserSession } from "../../lazurio/agentBrowserSession.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); @@ -975,10 +976,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? yield* agentDeviceEnvironment : undefined; yield* Effect.sync(() => - McpProviderSession.setMcpProviderSession({ - ...credential.config, - ...(deviceEnvironment ? { agentDeviceEnvironment: deviceEnvironment } : {}), - }), + McpProviderSession.setMcpProviderSession( + // Lazurio overlay (root decision 0191): the thread's agent-browser session. + withAgentBrowserSession({ + ...credential.config, + ...(deviceEnvironment ? { agentDeviceEnvironment: deviceEnvironment } : {}), + }), + ), ); } return credential; diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index eb6261f3e80c..ef111a6e6c88 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -389,3 +389,27 @@ NodeTest.test("the Lazurio shell takes T3's sidebar colours through the colour r } NodeAssert.deepEqual(users, ["components/AppSidebarLayout.tsx"]); }); + +// Root decision 0191 (plan DEV-6646): the agents of every thread drive a window of their own in +// the Environment browser through the agent-browser CLI, which takes the session from +// AGENT_BROWSER_SESSION. Every adapter spreads the provider session ProviderService records for a +// thread into the processes it starts for that thread, so the thread's session joins it there. +NodeTest.test( + "every provider process of a thread gets the thread's agent-browser session", + async () => { + const [providerService, session] = await Promise.all([ + read("apps/server/src/provider/Layers/ProviderService.ts"), + import("../apps/server/src/lazurio/agentBrowserSession.ts"), + ]); + NodeAssert.equal(session.AGENT_BROWSER_SESSION_ENV, "AGENT_BROWSER_SESSION"); + NodeAssert.match( + providerService, + /McpProviderSession\.setMcpProviderSession\(\n(?:\s*\/\/[^\n]*\n)?\s*withAgentBrowserSession\(\{\n\s*\.\.\.credential\.config,/, + ); + NodeAssert.equal(providerService.match(/withAgentBrowserSession\(/g)?.length, 1); + NodeAssert.match( + ci, + /working-directory: apps\/server\n\s+run: pnpm exec vp test run src\/lazurio\n/, + ); + }, +); From bae3e5682c0e5f0b7a403998e5d13abe1cb9354b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 22:36:51 +0200 Subject: [PATCH 2/6] feat(web): the right panel's Browser shows the Environment browser The web client has no browser of its own: the right panel's Browser is disabled with "Only available in the desktop app" wherever Electron's desktopBridge.preview is missing. On a Lazurio Remote Environment one shared Chromium runs on the Environment, each thread's agents drive a window of their own in it, and a person watches and takes over through a view behind the Environment's gateway (root decision 0191, plan DEV-6646). Without the desktop preview, and only for threads of the environment that serves the page, the right panel now asks this origin for GET /.lazurio/browser.json?session= (same-origin credentials, redirects refused, no cache, 10 s timeout). The Environment's Launchpad answers there behind the same sign-in. A 200 {"available": true, "view": , "session": | null} enables Browser; anything else (available false, a 404 from an older Platform, a network error, T3's own index.html, a view that is not https: or is on this page's origin, another session) keeps today's disabled state and text. Opening it adds a surface kind of its own, environment-browser: preview surfaces are dropped by reconcileBrowserSurfaces when the server has no tab for them. The surface asks for the view each time it opens and on Reload, because the view URL carries a short-lived access token in its fragment; the URL lives only in component state, and the persisted surface is {id, kind}. The view is framed with allow="clipboard-read; clipboard-write; fullscreen" and sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"; the header has Reload and "Open in new tab", since the gateway's sign-in page cannot render in a frame once the sign-in has expired. Desktop profiles are offered only where the desktop preview exists. The desktop app is unchanged: with desktopBridge.preview nothing is asked and Browser is the desktop preview. Upstream files change only at seams (the surface kind in rightPanelStore.ts, its tab in RightPanelTabs.tsx, enabling and rendering it in ChatView.tsx); the rest is in apps/web/src/lazurio/, which holds a copy of the session name function. The allowlist lists every file; the release contract test keeps the seams, the same-origin fetch, the frame attributes, that the URL is never stored, and that the server's and the web's names are one function. Refs #40, #28. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/lazurio-fork-ci.yml | 11 ++ apps/web/src/components/ChatView.tsx | 19 +- apps/web/src/components/RightPanelTabs.tsx | 15 +- .../src/lazurio/LazurioEnvironmentBrowser.tsx | 151 ++++++++++++++++ apps/web/src/lazurio/agentBrowserSession.ts | 16 ++ .../src/lazurio/environmentBrowser.test.ts | 171 ++++++++++++++++++ apps/web/src/lazurio/environmentBrowser.ts | 70 +++++++ apps/web/src/rightPanelStore.ts | 8 +- scripts/lazurio-release-contract.test.mjs | 112 ++++++++++++ 9 files changed, 566 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx create mode 100644 apps/web/src/lazurio/agentBrowserSession.ts create mode 100644 apps/web/src/lazurio/environmentBrowser.test.ts create mode 100644 apps/web/src/lazurio/environmentBrowser.ts diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 53cee7dcc801..5cc80ccbb2c3 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -77,6 +77,17 @@ jobs: # index.html also maps T3's sidebar tokens to the shell's colour roles. apps/web/index.html apps/web/src/components/AppSidebarLayout.tsx + # The Environment browser (root decision 0191, plan DEV-6646): the web client has no + # browser, so on a Lazurio Environment the right panel's Browser frames the view the + # Environment names at /.lazurio/browser.json. A surface kind of its own, its tab, no + # desktop profile choice without the desktop preview, and the seam in ChatView.tsx + # (listed above) that enables Browser and renders the surface. + apps/web/src/components/RightPanelTabs.tsx + apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx + apps/web/src/lazurio/agentBrowserSession.ts + apps/web/src/lazurio/environmentBrowser.test.ts + apps/web/src/lazurio/environmentBrowser.ts + apps/web/src/rightPanelStore.ts ) unexpected="$(git diff --name-only "$UPSTREAM_SHA..HEAD" | grep -E '^(apps/(web|mobile|desktop)|packages)/' | diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8057c83eabdf..f27f2cd83a9a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -196,6 +196,10 @@ import { } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; +import { + LazurioEnvironmentBrowser, + useLazurioEnvironmentBrowser, +} from "../lazurio/LazurioEnvironmentBrowser"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -2055,6 +2059,7 @@ export default function ChatView(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; + const environmentBrowser = useLazurioEnvironmentBrowser(activeThreadRef, rightPanelOpen); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const activeTerminalDrawerPresence = usePanelPresence( @@ -9636,6 +9641,8 @@ export default function ChatView(props: ChatViewProps) { environmentId={activeThreadRef?.environmentId ?? null} threadId={activeThreadRef?.threadId ?? null} /> + ) : renderedRightPanelSurface?.kind === "environment-browser" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( createBrowserSurface()} + onAddBrowser={ + environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() + } onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} @@ -10293,7 +10302,7 @@ export default function ChatView(props: ChatViewProps) { onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} onAddDevice={addDeviceSurface} - browserAvailable={isPreviewSupportedInRuntime()} + browserAvailable={isPreviewSupportedInRuntime() || environmentBrowser.available} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} @@ -10341,7 +10350,9 @@ export default function ChatView(props: ChatViewProps) { onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + onAddBrowser={ + environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() + } onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} @@ -10350,7 +10361,7 @@ export default function ChatView(props: ChatViewProps) { onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} onAddDevice={addDeviceSurface} - browserAvailable={isPreviewSupportedInRuntime()} + browserAvailable={isPreviewSupportedInRuntime() || environmentBrowser.available} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 001342e48262..e1c6a95f77ae 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -632,6 +632,8 @@ function surfaceTitle( return "Agents"; case "device": return surface.title ?? surface.target?.name ?? "Device"; + case "environment-browser": + return "Browser"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -715,6 +717,8 @@ function SurfaceIcon({ return ; case "agents": return ; + case "environment-browser": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -826,7 +830,10 @@ function PullRequestSurfaceIcon({ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; - const browserProfiles = useBrowserDefaults().profiles; + const browserDefaults = useBrowserDefaults(); + // Profiles belong to the desktop preview. Without it, an available Browser is the Lazurio + // Environment browser (apps/web/src/lazurio), which has none to choose from. + const browserProfiles = previewBridge ? browserDefaults.profiles : []; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); @@ -1285,7 +1292,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // while hover or arrow reveals the profiles. The choice // lives at open time because a tab's profile is fixed then — // Electron only honours a partition before attach. - if (action.label === "Browser" && action.available) { + if ( + action.label === "Browser" && + action.available && + browserProfiles.length > 0 + ) { return ( + fetchEnvironmentBrowser(agentBrowserSessionName(threadId), window.location.origin, (url, init) => + window.fetch(url, init), + ); + +/** + * Lazurio overlay (root decision 0191, plan DEV-6646): whether the right panel's Browser opens + * the Environment browser for this thread, and how. The desktop app keeps its own browser, and + * only threads of this page's own environment have their window in its browser, so anywhere + * else this is never available and the Browser stays upstream's. The Environment is asked again + * whenever the panel opens or the thread changes; until it answers, the Browser stays disabled. + */ +export function useLazurioEnvironmentBrowser( + threadRef: ScopedThreadRef | null, + panelOpen: boolean, +): { readonly available: boolean; readonly open: () => void } { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const threadId = + threadRef !== null && + threadRef.environmentId === primaryEnvironmentId && + !isPreviewSupportedInRuntime() + ? threadRef.threadId + : null; + const [answer, setAnswer] = useState<{ + readonly threadId: string; + readonly available: boolean; + } | null>(null); + useEffect(() => { + if (threadId === null || !panelOpen) return; + let current = true; + void askEnvironment(threadId).then((view) => { + if (current) setAnswer({ threadId, available: view !== null }); + }); + return () => { + current = false; + }; + }, [panelOpen, threadId]); + const open = useCallback(() => { + if (threadRef !== null) useRightPanelStore.getState().open(threadRef, "environment-browser"); + }, [threadRef]); + return { + available: threadId !== null && answer?.threadId === threadId && answer.available, + open, + }; +} + +/** + * The Environment browser in the right panel: the view of this thread's window in a frame. The + * view is asked for each time the surface opens, and again on Reload, because its URL carries a + * short-lived access token; it lives only in this component's state. The gateway's sign-in page + * cannot render in a frame once the sign-in has expired, so the view also opens in a new tab. + */ +export function LazurioEnvironmentBrowser(props: { readonly threadId: string }) { + const [request, setRequest] = useState(0); + const [answer, setAnswer] = useState<{ + readonly threadId: string; + readonly request: number; + readonly view: EnvironmentBrowserView | null; + } | null>(null); + useEffect(() => { + let current = true; + void askEnvironment(props.threadId).then((view) => { + if (current) setAnswer({ threadId: props.threadId, request, view }); + }); + return () => { + current = false; + }; + }, [props.threadId, request]); + // Undefined while the Environment is being asked. + const view = + answer?.threadId === props.threadId && answer.request === request ? answer.view : undefined; + + return ( +
+
+ + + Environment browser + + + setRequest((value) => value + 1)} + /> + } + > + + + Reload + + {view ? ( + + ) : null} +
+ {view ? ( +