Skip to content

Commit a38d819

Browse files
authored
fix(browser): start sidecar from open browser action (#358)
1 parent 361329f commit a38d819

11 files changed

Lines changed: 284 additions & 53 deletions

packages/api/src/http.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ import {
117117
writeProjectSkill
118118
} from "./services/project-skills.js"
119119
import type { ProjectSkillScope } from "./services/project-skills.js"
120-
import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
120+
import { readProjectBrowserSession, startProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
121121
import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js"
122122
import {
123123
readPanelCloudflareTunnel,
@@ -1369,6 +1369,15 @@ export const makeRouter = () => {
13691369
const browser = yield* _(readProjectBrowserSession(projectId, resolveRequestOrigin(request)))
13701370
return yield* _(jsonResponse({ browser }, 200))
13711371
}).pipe(Effect.catchAll(errorResponse))
1372+
),
1373+
HttpRouter.post(
1374+
"/projects/:projectId/browser/start",
1375+
Effect.gen(function*(_) {
1376+
const { projectId } = yield* _(projectParams)
1377+
const request = yield* _(HttpServerRequest.HttpServerRequest)
1378+
const browser = yield* _(startProjectBrowserSession(projectId, resolveRequestOrigin(request)))
1379+
return yield* _(jsonResponse({ browser }, 200))
1380+
}).pipe(Effect.catchAll(errorResponse))
13721381
)
13731382
)
13741383

packages/api/src/services/project-browser.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,33 @@ const inspectBrowserContainerState = (
221221
Effect.catchAll(() => Effect.succeed(missingBrowserContainerState))
222222
)
223223

224+
const startBrowserContainer = (
225+
cwd: string,
226+
projectContainerName: string
227+
) =>
228+
dockerCapture(
229+
cwd,
230+
[
231+
"exec",
232+
projectContainerName,
233+
"docker-git-browser-connection",
234+
"start",
235+
"--project",
236+
projectContainerName,
237+
"--network",
238+
`container:${projectContainerName}`
239+
],
240+
"docker exec docker-git-browser-connection start"
241+
).pipe(
242+
Effect.asVoid,
243+
Effect.mapError(() =>
244+
new ApiConflictError({
245+
message:
246+
`Failed to start browser runtime for ${projectContainerName}. Make sure the project is running and Playwright MCP is enabled.`
247+
})
248+
)
249+
)
250+
224251
const parseContainerNetworkEntries = (output: string): ReadonlyArray<ContainerNetworkEntry> =>
225252
output
226253
.trim()
@@ -404,6 +431,18 @@ export const readProjectBrowserSession = (
404431
return browserSessionFromState(projectId, containerName, state, externalOrigin)
405432
})
406433

434+
export const startProjectBrowserSession = (
435+
projectId: string,
436+
externalOrigin: string
437+
): Effect.Effect<ProjectBrowserSession, BrowserApiError | PlatformError, ListProjectsContext> =>
438+
Effect.gen(function*(_) {
439+
const project = yield* _(getProjectItemById(projectId))
440+
const containerName = browserContainerName(project.containerName)
441+
yield* _(startBrowserContainer(project.projectDir, project.containerName))
442+
const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName))
443+
return browserSessionFromState(projectId, containerName, state, externalOrigin)
444+
})
445+
407446
const copyProxyRequestHeaders = (
408447
request: HttpServerRequest.HttpServerRequest,
409448
target: ProjectBrowserProxyPath,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
import { NodeContext } from "@effect/platform-node"
3+
import { Effect } from "effect"
4+
import path from "node:path"
5+
import { beforeEach, vi } from "vitest"
6+
7+
import type { ProjectItem } from "@effect-template/lib"
8+
import { CommandFailedError } from "@effect-template/lib/shell/errors"
9+
10+
import { ApiConflictError } from "../src/api/errors.js"
11+
import { startProjectBrowserSession } from "../src/services/project-browser.js"
12+
13+
const getProjectItemByIdMock = vi.hoisted(() => vi.fn())
14+
const runCommandCaptureMock = vi.hoisted(() => vi.fn())
15+
16+
vi.mock("@effect-template/lib/shell/command-runner", () => ({
17+
runCommandCapture: runCommandCaptureMock
18+
}))
19+
20+
vi.mock("../src/services/projects.js", () => ({
21+
getProjectItemById: getProjectItemByIdMock
22+
}))
23+
24+
const projectId = "/home/dev/.docker-git/projects/repo-issue-353"
25+
const projectDir = "/home/dev/.docker-git/projects/repo-issue-353"
26+
const projectContainerName = "dg-docker-git-issue-353"
27+
const browserContainerName = `${projectContainerName}-browser`
28+
29+
const projectItem: ProjectItem = {
30+
authorizedKeysExists: true,
31+
authorizedKeysPath: path.join(projectDir, "authorized_keys"),
32+
codexAuthPath: path.join(projectDir, ".orch", "auth", "codex"),
33+
codexHome: "/home/dev/.codex",
34+
containerName: projectContainerName,
35+
displayName: "ProverCoderAI/docker-git",
36+
envGlobalPath: path.join(projectDir, ".orch", "env", "global.env"),
37+
envProjectPath: path.join(projectDir, ".orch", "env", "project.env"),
38+
gpu: "none",
39+
lastKnownStatus: "running",
40+
lastStartAction: "up",
41+
lastStartedAtEpochMs: 1_778_000_000_000,
42+
lastStartedAtIso: "2026-05-29T18:00:00.000Z",
43+
projectDir,
44+
repoRef: "issue-353",
45+
repoUrl: "https://github.com/ProverCoderAI/docker-git.git",
46+
serviceName: "app",
47+
sshCommand: "ssh -p 2222 dev@localhost",
48+
sshKeyPath: null,
49+
sshPort: 2222,
50+
sshUser: "dev",
51+
targetDir: "/home/dev/app"
52+
}
53+
54+
describe("project browser", () => {
55+
beforeEach(() => {
56+
getProjectItemByIdMock.mockReset()
57+
runCommandCaptureMock.mockReset()
58+
getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem))
59+
runCommandCaptureMock.mockImplementation((command: { readonly args: ReadonlyArray<string> }) =>
60+
command.args[0] === "inspect"
61+
? Effect.succeed("browser-container-id\ttrue\trunning")
62+
: Effect.succeed("Browser started")
63+
)
64+
})
65+
66+
it.effect("starts or reuses the Rust browser sidecar from the project container", () =>
67+
Effect.gen(function*(_) {
68+
const browser = yield* _(startProjectBrowserSession(projectId, "http://127.0.0.1:3334"))
69+
70+
expect(browser).toMatchObject({
71+
containerName: browserContainerName,
72+
projectId,
73+
status: "running"
74+
})
75+
expect(runCommandCaptureMock).toHaveBeenCalledWith(
76+
{
77+
args: [
78+
"exec",
79+
projectContainerName,
80+
"docker-git-browser-connection",
81+
"start",
82+
"--project",
83+
projectContainerName,
84+
"--network",
85+
`container:${projectContainerName}`
86+
],
87+
command: "docker",
88+
cwd: projectDir
89+
},
90+
[0],
91+
expect.any(Function)
92+
)
93+
expect(runCommandCaptureMock).toHaveBeenLastCalledWith(
94+
{
95+
args: ["inspect", "-f", "{{.Id}}\t{{.State.Running}}\t{{.State.Status}}", browserContainerName],
96+
command: "docker",
97+
cwd: projectDir
98+
},
99+
[0],
100+
expect.any(Function)
101+
)
102+
}).pipe(Effect.provide(NodeContext.layer)))
103+
104+
it.effect("returns a conflict when the project container cannot launch the browser helper", () =>
105+
Effect.gen(function*(_) {
106+
runCommandCaptureMock.mockImplementationOnce(() =>
107+
Effect.fail(new CommandFailedError({ command: "docker exec docker-git-browser-connection start", exitCode: 127 }))
108+
)
109+
110+
const result = yield* _(Effect.either(startProjectBrowserSession(projectId, "http://127.0.0.1:3334")))
111+
112+
expect(result._tag).toBe("Left")
113+
if (result._tag === "Left") {
114+
expect(result.left).toBeInstanceOf(ApiConflictError)
115+
expect(result.left.message).toContain("Playwright MCP is enabled")
116+
}
117+
}).pipe(Effect.provide(NodeContext.layer)))
118+
})

packages/app/src/web/actions-browser.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { type BrowserActionContext, requireSelectedProjectId, withBusy } from "./actions-shared.js"
2-
import { loadProjectBrowser, projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession } from "./api.js"
3-
import { openUrl } from "./open-url.js"
2+
import {
3+
loadProjectBrowser,
4+
projectBrowserCdpUrl,
5+
projectBrowserNoVncUrl,
6+
type ProjectBrowserSession,
7+
startProjectBrowser
8+
} from "./api.js"
9+
import { prepareOpenUrl } from "./open-url.js"
410

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

4854
export const openProjectBrowserById = (projectId: string, context: BrowserActionContext) => {
55+
const preparedUrl = prepareOpenUrl()
4956
withBusy({
5057
context,
51-
effect: loadProjectBrowser(projectId),
52-
label: "Opening project browser",
58+
effect: startProjectBrowser(projectId),
59+
label: "Starting project browser",
60+
onFailure: () => {
61+
preparedUrl.close()
62+
},
5363
onSuccess: (browser) => {
5464
context.setProjectBrowser(browser)
5565
if (browser.status !== "running") {
66+
preparedUrl.close()
5667
context.setMessage(`Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.`)
5768
return
5869
}
5970
const noVncUrl = projectBrowserNoVncUrl(browser)
6071
context.setMessage(
61-
openUrl(noVncUrl)
72+
preparedUrl.navigate(noVncUrl)
6273
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
6374
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
6475
)

packages/app/src/web/api.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,12 @@ export const loadProjectPortForwards = (projectId: string) =>
139139
)
140140

141141
export const loadProjectBrowser = (projectId: string) =>
142-
requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema).pipe(
143-
Effect.map((response) => response.browser)
144-
)
142+
requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema)
143+
.pipe(Effect.map((response) => response.browser))
144+
145+
export const startProjectBrowser = (projectId: string) =>
146+
requestJson("POST", `/projects/${encodeURIComponent(projectId)}/browser/start`, ProjectBrowserResponseSchema)
147+
.pipe(Effect.map((response) => response.browser))
145148

146149
export const createProjectPortForward = (
147150
projectId: string,

packages/app/src/web/app-ready-browser-openable.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import type { ProjectBrowserSession } from "./api.js"
22
import type { BrowserMenuTag } from "./menu.js"
33

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

76
export const canOpenProjectBrowser = (
8-
projectBrowser: ProjectBrowserSession | null,
7+
_projectBrowser: ProjectBrowserSession | null,
98
projectId: string | null | undefined
10-
): boolean =>
11-
projectId !== null &&
12-
projectId !== undefined &&
13-
projectBrowser?.projectId === projectId &&
14-
projectBrowser.status === "running"
9+
): boolean => projectId !== null && projectId !== undefined
1510

1611
export const canRunProjectBrowserAction = (
1712
menu: BrowserMenuTag,

packages/app/src/web/app-terminal-session-handlers.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import {
66
applyProject,
77
type ContainerTaskSnapshot,
88
createProjectTerminalSession,
9-
loadProjectBrowser,
109
loadProjectTaskLogs,
1110
loadProjectTasks,
1211
openSkiller,
1312
projectBrowserCdpUrl,
1413
projectBrowserNoVncUrl,
1514
type ProjectBrowserSession,
15+
startProjectBrowser,
1616
stopProjectTask
1717
} from "./api.js"
1818
import { openUrl, prepareOpenUrl } from "./open-url.js"
@@ -47,25 +47,32 @@ const confirmApplyProject = (label: string): boolean => {
4747
)
4848
}
4949

50-
const browserStatusMessage = (browser: ProjectBrowserSession): string => {
50+
const browserStatusMessage = (browser: ProjectBrowserSession, opened: boolean): string => {
5151
if (browser.status !== "running") {
5252
return `Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.`
5353
}
5454
const noVncUrl = projectBrowserNoVncUrl(browser)
55-
return openUrl(noVncUrl)
55+
return opened
5656
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
5757
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
5858
}
5959

6060
const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): void => {
61+
const preparedUrl = prepareOpenUrl()
6162
void Effect.runPromise(
62-
loadProjectBrowser(projectId).pipe(
63+
startProjectBrowser(projectId).pipe(
6364
Effect.match({
6465
onFailure: (error) => {
66+
preparedUrl.close()
6567
setMessage(`Failed to open browser: ${error}`)
6668
},
6769
onSuccess: (browser) => {
68-
setMessage(browserStatusMessage(browser))
70+
if (browser.status !== "running") {
71+
preparedUrl.close()
72+
setMessage(browserStatusMessage(browser, false))
73+
return
74+
}
75+
setMessage(browserStatusMessage(browser, preparedUrl.navigate(projectBrowserNoVncUrl(browser))))
6976
}
7077
})
7178
)

packages/app/src/web/panel-browser.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,27 @@ const BrowserLinks = ({ browser }: { readonly browser: ProjectBrowserSession }):
5656
const BrowserStatusDetails = (
5757
{
5858
browser,
59-
canOpenBrowser
59+
selectedProjectId
6060
}: {
6161
readonly browser: ProjectBrowserSession | null
62-
readonly canOpenBrowser: boolean
62+
readonly selectedProjectId: string | null
6363
}
6464
): JSX.Element => {
65-
if (browser === null) {
65+
if (browser === null || browser.projectId !== selectedProjectId) {
6666
return <Text fg="#8fa6c4" marginTop={1}>Browser status is not loaded.</Text>
6767
}
68+
const browserRunning = browser.status === "running"
6869
return (
6970
<Box flexDirection="column" gap={1} marginTop={1}>
7071
<Box alignItems="center" flexWrap="wrap" gap={1} justifyContent="space-between">
7172
<Text fg="#8fa6c4" wrap="truncate">Container: {browser.containerName}</Text>
7273
<Text bold={true} fg={statusColor(browser.status)}>{browser.status}</Text>
7374
</Box>
74-
{canOpenBrowser
75+
{browserRunning
7576
? <BrowserLinks browser={browser} />
7677
: (
7778
<Text fg="#ffb86c" wrap="wrap">
78-
Enable Playwright MCP for this project and start it before opening the browser.
79+
Open browser will start the runtime for this project.
7980
</Text>
8081
)}
8182
</Box>
@@ -111,7 +112,8 @@ export const BrowserPanel = (
111112
selectedProjectSummary
112113
}: BrowserPanelProps
113114
): JSX.Element => {
114-
const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectSummary?.id ?? null)
115+
const selectedProjectId = selectedProjectSummary?.id ?? null
116+
const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectId)
115117
return (
116118
<Box flexDirection="column">
117119
<Text bold={true} fg="#8be9fd">Browser</Text>
@@ -121,7 +123,7 @@ export const BrowserPanel = (
121123
<Text fg="#8fa6c4" marginTop={1} wrap="truncate">
122124
Project: {selectedProjectSummary?.displayName ?? "not selected"}
123125
</Text>
124-
<BrowserStatusDetails browser={browser} canOpenBrowser={canOpenBrowser} />
126+
<BrowserStatusDetails browser={browser} selectedProjectId={selectedProjectId} />
125127
<BrowserActions
126128
canOpenBrowser={canOpenBrowser}
127129
onOpenBrowser={onOpenBrowser}

0 commit comments

Comments
 (0)