diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 41324dafc533..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)/' | @@ -119,6 +130,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..345865b4a30b --- /dev/null +++ b/apps/server/src/lazurio/agentBrowserSession.test.ts @@ -0,0 +1,324 @@ +import { createHash } from "node:crypto"; +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 when the id already fits agent-browser's grammar", () => { + assert.strictEqual(agentBrowserSessionName(threadId), sessionName); + assert.strictEqual(agentBrowserSessionName("under_score-and-dash"), "t3-under_score-and-dash"); + assert.strictEqual(agentBrowserSessionName("x".repeat(61)), `t3-${"x".repeat(61)}`); + }); + + it("keeps a readable start of a sanitized or cut id and never gives two threads one session", () => { + // Imported threads are `import::`, and an instance id + // may take 64 characters: cutting alone would give every such thread the same name. + const longInstance = `codex-${"w".repeat(58)}`; + const ids = [ + "thread.with:colons/and/slashes", + "thread:with:colons:and:slashes", + "thread-with-colons-and-slashes", + "vlákno-č", + "vlákno-ř", + "emoji-\u{1F642}", + "x".repeat(62), + "x".repeat(100), + `import:codex:${threadId}`, + `import:${longInstance}:019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a70`, + `import:${longInstance}:019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a71`, + // Two imported ids whose 32-bit FNV-1a suffixes collided (review of Lazurio/t3code#41). + `import:codex-${"w".repeat(58)}:f64cccd6-59c8-42a7-aa0e-319969aeccc9`, + `import:codex-${"w".repeat(58)}:3c4a8834-35dc-418d-a6dd-d8d1934ab83f`, + ]; + const names = ids.map(agentBrowserSessionName); + for (const name of names) assert.match(name, /^t3-[A-Za-z0-9_-]{1,61}$/); + assert.strictEqual(new Set(names).size, ids.length); + // A readable start, then 32 hex digits of the exact id's SHA-256. + assert.match(names[0] ?? "", /^t3-thread-with-colons-and-slash-[0-9a-f]{32}$/); + assert.strictEqual(names[2], "t3-thread-with-colons-and-slashes"); + assert.match(names[7] ?? "", new RegExp(`^t3-${"x".repeat(28)}-[0-9a-f]{32}$`)); + assert.strictEqual( + names[0], + `t3-thread-with-colons-and-slash-${createHash("sha256") + .update("thread.with:colons/and/slashes", "utf8") + .digest("hex") + .slice(0, 32)}`, + ); + // The same thread always gets the same session. + assert.deepStrictEqual(ids.map(agentBrowserSessionName), names); + }); + + 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..01c8c5d4edde --- /dev/null +++ b/apps/server/src/lazurio/agentBrowserSession.ts @@ -0,0 +1,61 @@ +/** + * 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. + */ + +import { createHash } from "node:crypto"; + +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; + +/** Hex digits of the id's SHA-256 kept in a cut or rewritten name. */ +const DIGEST_LENGTH = 32; + +/** SHA-256 of the UTF-8 bytes, as hex (node:crypto; the web copy computes the same with + * @noble/hashes, and the release contract test compares the two). */ +const sha256Hex = (value: string): string => + createHash("sha256").update(value, "utf8").digest("hex"); + +/** + * `t3-` and the thread id. An id with characters outside [A-Za-z0-9_-], or too long to fit, has + * them replaced by `-`, is cut, and ends in 32 hex digits (128 bits) of the SHA-256 of the exact + * id, so that two threads never share a session, and with it a window: a shorter, non- + * cryptographic suffix let two imported thread ids of the same instance collide. + */ +export function agentBrowserSessionName(threadId: string): string { + const sanitized = threadId.replace(/[^A-Za-z0-9_-]/g, "-"); + const name = `t3-${sanitized}`; + if (sanitized === threadId && name.length <= SESSION_NAME_LIMIT) return name; + const digest = sha256Hex(threadId).slice(0, DIGEST_LENGTH); + return `${name.slice(0, SESSION_NAME_LIMIT - DIGEST_LENGTH - 1)}-${digest}`; +} + +/** + * 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/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 ? ( +