Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ import {
writeProjectSkill
} from "./services/project-skills.js"
import type { ProjectSkillScope } from "./services/project-skills.js"
import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
import { readProjectBrowserSession, startProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js"
import {
readPanelCloudflareTunnel,
Expand Down Expand Up @@ -1369,6 +1369,15 @@ export const makeRouter = () => {
const browser = yield* _(readProjectBrowserSession(projectId, resolveRequestOrigin(request)))
return yield* _(jsonResponse({ browser }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/projects/:projectId/browser/start",
Effect.gen(function*(_) {
const { projectId } = yield* _(projectParams)
const request = yield* _(HttpServerRequest.HttpServerRequest)
const browser = yield* _(startProjectBrowserSession(projectId, resolveRequestOrigin(request)))
return yield* _(jsonResponse({ browser }, 200))
}).pipe(Effect.catchAll(errorResponse))
)
)

Expand Down
39 changes: 39 additions & 0 deletions packages/api/src/services/project-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,33 @@ const inspectBrowserContainerState = (
Effect.catchAll(() => Effect.succeed(missingBrowserContainerState))
)

const startBrowserContainer = (
cwd: string,
projectContainerName: string
) =>
dockerCapture(
cwd,
[
"exec",
projectContainerName,
"docker-git-browser-connection",
"start",
"--project",
projectContainerName,
"--network",
`container:${projectContainerName}`
],
"docker exec docker-git-browser-connection start"
).pipe(
Effect.asVoid,
Effect.mapError(() =>
new ApiConflictError({
message:
`Failed to start browser runtime for ${projectContainerName}. Make sure the project is running and Playwright MCP is enabled.`
})
)
)

const parseContainerNetworkEntries = (output: string): ReadonlyArray<ContainerNetworkEntry> =>
output
.trim()
Expand Down Expand Up @@ -404,6 +431,18 @@ export const readProjectBrowserSession = (
return browserSessionFromState(projectId, containerName, state, externalOrigin)
})

export const startProjectBrowserSession = (
projectId: string,
externalOrigin: string
): Effect.Effect<ProjectBrowserSession, BrowserApiError | PlatformError, ListProjectsContext> =>
Effect.gen(function*(_) {
const project = yield* _(getProjectItemById(projectId))
const containerName = browserContainerName(project.containerName)
yield* _(startBrowserContainer(project.projectDir, project.containerName))
const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName))
return browserSessionFromState(projectId, containerName, state, externalOrigin)
})

const copyProxyRequestHeaders = (
request: HttpServerRequest.HttpServerRequest,
target: ProjectBrowserProxyPath,
Expand Down
118 changes: 118 additions & 0 deletions packages/api/tests/project-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from "@effect/vitest"
import { NodeContext } from "@effect/platform-node"
import { Effect } from "effect"
import path from "node:path"
import { beforeEach, vi } from "vitest"

import type { ProjectItem } from "@effect-template/lib"
import { CommandFailedError } from "@effect-template/lib/shell/errors"

import { ApiConflictError } from "../src/api/errors.js"
import { startProjectBrowserSession } from "../src/services/project-browser.js"

const getProjectItemByIdMock = vi.hoisted(() => vi.fn())
const runCommandCaptureMock = vi.hoisted(() => vi.fn())

vi.mock("@effect-template/lib/shell/command-runner", () => ({
runCommandCapture: runCommandCaptureMock
}))

vi.mock("../src/services/projects.js", () => ({
getProjectItemById: getProjectItemByIdMock
}))

const projectId = "/home/dev/.docker-git/projects/repo-issue-353"
const projectDir = "/home/dev/.docker-git/projects/repo-issue-353"
const projectContainerName = "dg-docker-git-issue-353"
const browserContainerName = `${projectContainerName}-browser`

const projectItem: ProjectItem = {
authorizedKeysExists: true,
authorizedKeysPath: path.join(projectDir, "authorized_keys"),
codexAuthPath: path.join(projectDir, ".orch", "auth", "codex"),
codexHome: "/home/dev/.codex",
containerName: projectContainerName,
displayName: "ProverCoderAI/docker-git",
envGlobalPath: path.join(projectDir, ".orch", "env", "global.env"),
envProjectPath: path.join(projectDir, ".orch", "env", "project.env"),
gpu: "none",
lastKnownStatus: "running",
lastStartAction: "up",
lastStartedAtEpochMs: 1_778_000_000_000,
lastStartedAtIso: "2026-05-29T18:00:00.000Z",
projectDir,
repoRef: "issue-353",
repoUrl: "https://github.com/ProverCoderAI/docker-git.git",
serviceName: "app",
sshCommand: "ssh -p 2222 dev@localhost",
sshKeyPath: null,
sshPort: 2222,
sshUser: "dev",
targetDir: "/home/dev/app"
}

describe("project browser", () => {
beforeEach(() => {
getProjectItemByIdMock.mockReset()
runCommandCaptureMock.mockReset()
getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem))
runCommandCaptureMock.mockImplementation((command: { readonly args: ReadonlyArray<string> }) =>
command.args[0] === "inspect"
? Effect.succeed("browser-container-id\ttrue\trunning")
: Effect.succeed("Browser started")
)
})

it.effect("starts or reuses the Rust browser sidecar from the project container", () =>
Effect.gen(function*(_) {
const browser = yield* _(startProjectBrowserSession(projectId, "http://127.0.0.1:3334"))

expect(browser).toMatchObject({
containerName: browserContainerName,
projectId,
status: "running"
})
expect(runCommandCaptureMock).toHaveBeenCalledWith(
{
args: [
"exec",
projectContainerName,
"docker-git-browser-connection",
"start",
"--project",
projectContainerName,
"--network",
`container:${projectContainerName}`
],
command: "docker",
cwd: projectDir
},
[0],
expect.any(Function)
)
expect(runCommandCaptureMock).toHaveBeenLastCalledWith(
{
args: ["inspect", "-f", "{{.Id}}\t{{.State.Running}}\t{{.State.Status}}", browserContainerName],
command: "docker",
cwd: projectDir
},
[0],
expect.any(Function)
)
}).pipe(Effect.provide(NodeContext.layer)))

it.effect("returns a conflict when the project container cannot launch the browser helper", () =>
Effect.gen(function*(_) {
runCommandCaptureMock.mockImplementationOnce(() =>
Effect.fail(new CommandFailedError({ command: "docker exec docker-git-browser-connection start", exitCode: 127 }))
)

const result = yield* _(Effect.either(startProjectBrowserSession(projectId, "http://127.0.0.1:3334")))

expect(result._tag).toBe("Left")
if (result._tag === "Left") {
expect(result.left).toBeInstanceOf(ApiConflictError)
expect(result.left.message).toContain("Playwright MCP is enabled")
}
}).pipe(Effect.provide(NodeContext.layer)))
})
21 changes: 16 additions & 5 deletions packages/app/src/web/actions-browser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { type BrowserActionContext, requireSelectedProjectId, withBusy } from "./actions-shared.js"
import { loadProjectBrowser, projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession } from "./api.js"
import { openUrl } from "./open-url.js"
import {
loadProjectBrowser,
projectBrowserCdpUrl,
projectBrowserNoVncUrl,
type ProjectBrowserSession,
startProjectBrowser
} from "./api.js"
import { prepareOpenUrl } from "./open-url.js"

const browserStatusMessage = (browser: ProjectBrowserSession): string =>
browser.status === "running"
Expand Down Expand Up @@ -46,19 +52,24 @@ export const openSelectedProjectBrowser = (context: BrowserActionContext) => {
}

export const openProjectBrowserById = (projectId: string, context: BrowserActionContext) => {
const preparedUrl = prepareOpenUrl()
withBusy({
context,
effect: loadProjectBrowser(projectId),
label: "Opening project browser",
effect: startProjectBrowser(projectId),
label: "Starting project browser",
onFailure: () => {
preparedUrl.close()
},
onSuccess: (browser) => {
context.setProjectBrowser(browser)
if (browser.status !== "running") {
preparedUrl.close()
context.setMessage(`Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.`)
return
}
const noVncUrl = projectBrowserNoVncUrl(browser)
context.setMessage(
openUrl(noVncUrl)
preparedUrl.navigate(noVncUrl)
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
)
Expand Down
9 changes: 6 additions & 3 deletions packages/app/src/web/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,12 @@ export const loadProjectPortForwards = (projectId: string) =>
)

export const loadProjectBrowser = (projectId: string) =>
requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema).pipe(
Effect.map((response) => response.browser)
)
requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema)
.pipe(Effect.map((response) => response.browser))

export const startProjectBrowser = (projectId: string) =>
requestJson("POST", `/projects/${encodeURIComponent(projectId)}/browser/start`, ProjectBrowserResponseSchema)
.pipe(Effect.map((response) => response.browser))

export const createProjectPortForward = (
projectId: string,
Expand Down
11 changes: 3 additions & 8 deletions packages/app/src/web/app-ready-browser-openable.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import type { ProjectBrowserSession } from "./api.js"
import type { BrowserMenuTag } from "./menu.js"

export const browserSidecarUnavailableMessage =
"Browser runtime is not running. Enable Playwright MCP and start the project first."
export const browserSidecarUnavailableMessage = "Select a project before opening the browser."

export const canOpenProjectBrowser = (
projectBrowser: ProjectBrowserSession | null,
_projectBrowser: ProjectBrowserSession | null,
projectId: string | null | undefined
): boolean =>
projectId !== null &&
projectId !== undefined &&
projectBrowser?.projectId === projectId &&
projectBrowser.status === "running"
): boolean => projectId !== null && projectId !== undefined

export const canRunProjectBrowserAction = (
menu: BrowserMenuTag,
Expand Down
17 changes: 12 additions & 5 deletions packages/app/src/web/app-terminal-session-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
applyProject,
type ContainerTaskSnapshot,
createProjectTerminalSession,
loadProjectBrowser,
loadProjectTaskLogs,
loadProjectTasks,
openSkiller,
projectBrowserCdpUrl,
projectBrowserNoVncUrl,
type ProjectBrowserSession,
startProjectBrowser,
stopProjectTask
} from "./api.js"
import { openUrl, prepareOpenUrl } from "./open-url.js"
Expand Down Expand Up @@ -47,25 +47,32 @@ const confirmApplyProject = (label: string): boolean => {
)
}

const browserStatusMessage = (browser: ProjectBrowserSession): string => {
const browserStatusMessage = (browser: ProjectBrowserSession, opened: boolean): string => {
if (browser.status !== "running") {
return `Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.`
}
const noVncUrl = projectBrowserNoVncUrl(browser)
return openUrl(noVncUrl)
return opened
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
}

const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): void => {
const preparedUrl = prepareOpenUrl()
void Effect.runPromise(
loadProjectBrowser(projectId).pipe(
startProjectBrowser(projectId).pipe(
Effect.match({
onFailure: (error) => {
preparedUrl.close()
setMessage(`Failed to open browser: ${error}`)
},
onSuccess: (browser) => {
setMessage(browserStatusMessage(browser))
if (browser.status !== "running") {
preparedUrl.close()
setMessage(browserStatusMessage(browser, false))
return
}
setMessage(browserStatusMessage(browser, preparedUrl.navigate(projectBrowserNoVncUrl(browser))))
}
})
)
Expand Down
16 changes: 9 additions & 7 deletions packages/app/src/web/panel-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,27 @@ const BrowserLinks = ({ browser }: { readonly browser: ProjectBrowserSession }):
const BrowserStatusDetails = (
{
browser,
canOpenBrowser
selectedProjectId
}: {
readonly browser: ProjectBrowserSession | null
readonly canOpenBrowser: boolean
readonly selectedProjectId: string | null
}
): JSX.Element => {
if (browser === null) {
if (browser === null || browser.projectId !== selectedProjectId) {
return <Text fg="#8fa6c4" marginTop={1}>Browser status is not loaded.</Text>
}
const browserRunning = browser.status === "running"
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Box alignItems="center" flexWrap="wrap" gap={1} justifyContent="space-between">
<Text fg="#8fa6c4" wrap="truncate">Container: {browser.containerName}</Text>
<Text bold={true} fg={statusColor(browser.status)}>{browser.status}</Text>
</Box>
{canOpenBrowser
{browserRunning
? <BrowserLinks browser={browser} />
: (
<Text fg="#ffb86c" wrap="wrap">
Enable Playwright MCP for this project and start it before opening the browser.
Open browser will start the runtime for this project.
</Text>
)}
</Box>
Expand Down Expand Up @@ -111,7 +112,8 @@ export const BrowserPanel = (
selectedProjectSummary
}: BrowserPanelProps
): JSX.Element => {
const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectSummary?.id ?? null)
const selectedProjectId = selectedProjectSummary?.id ?? null
const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectId)
return (
<Box flexDirection="column">
<Text bold={true} fg="#8be9fd">Browser</Text>
Expand All @@ -121,7 +123,7 @@ export const BrowserPanel = (
<Text fg="#8fa6c4" marginTop={1} wrap="truncate">
Project: {selectedProjectSummary?.displayName ?? "not selected"}
</Text>
<BrowserStatusDetails browser={browser} canOpenBrowser={canOpenBrowser} />
<BrowserStatusDetails browser={browser} selectedProjectId={selectedProjectId} />
<BrowserActions
canOpenBrowser={canOpenBrowser}
onOpenBrowser={onOpenBrowser}
Expand Down
Loading
Loading