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
30 changes: 21 additions & 9 deletions apps/desktop/src/browserRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ interface TestProjectRuntime {
activeTabId: string | null;
}

function getProjectRuntime(registry: BrowserRuntimeRegistry, projectId: ProjectId): TestProjectRuntime {
function getProjectRuntime(
registry: BrowserRuntimeRegistry,
projectId: ProjectId,
): TestProjectRuntime {
const runtime = ((registry as any).runtimes as Map<ProjectId, TestProjectRuntime>).get(projectId);
expect(runtime).toBeDefined();
return runtime!;
Expand All @@ -181,7 +184,10 @@ function hasProjectRuntime(registry: BrowserRuntimeRegistry, projectId: ProjectI
return ((registry as any).runtimes as Map<ProjectId, TestProjectRuntime>).has(projectId);
}

function getActiveTabRuntime(registry: BrowserRuntimeRegistry, projectId: ProjectId): TestTabRuntime {
function getActiveTabRuntime(
registry: BrowserRuntimeRegistry,
projectId: ProjectId,
): TestTabRuntime {
const projectRuntime = getProjectRuntime(registry, projectId);
expect(projectRuntime.activeTabId).toBeTruthy();
const tab = projectRuntime.activeTabId
Expand Down Expand Up @@ -234,7 +240,9 @@ describe("BrowserRuntimeRegistry", () => {
throw new Error("ERR_ABORTED");
};

await expect(registry.navigate(projectId, "https://example.com")).rejects.toThrow("ERR_ABORTED");
await expect(registry.navigate(projectId, "https://example.com")).rejects.toThrow(
"ERR_ABORTED",
);
});

it("uses the shared persistent browser partition", async () => {
Expand Down Expand Up @@ -565,7 +573,12 @@ describe("BrowserRuntimeRegistry", () => {

const staleOpen = registry.open(projectId, { x: 540, y: 35, width: 200, height: 360 });
await Promise.resolve();
const latestSnapshot = await registry.open(projectId, { x: 420, y: 35, width: 260, height: 360 });
const latestSnapshot = await registry.open(projectId, {
x: 420,
y: 35,
width: 260,
height: 360,
});
resolveFirstOpen();
await staleOpen;
vi.runAllTimers();
Expand Down Expand Up @@ -750,7 +763,7 @@ describe("BrowserRuntimeRegistry", () => {
expect(closed.activeTabId).not.toBe(firstTabId);
});

it("recreates a default-start replacement tab when closing the last tab", async () => {
it("leaves an empty browser state when closing the last tab", async () => {
const registry = new BrowserRuntimeRegistry({ browserPreloadPath: "test-preload.js" });
const projectId = ProjectId.makeUnsafe("project-tabs-2");

Expand All @@ -762,10 +775,9 @@ describe("BrowserRuntimeRegistry", () => {
}

const afterClose = await registry.closeTab(projectId, initialTabId);
expect(afterClose.tabs).toHaveLength(1);
expect(afterClose.activeTabId).toBeTruthy();
expect(afterClose.activeTabId).not.toBe(initialTabId);
expect(afterClose.session?.navigation.url).toBe("https://www.google.com");
expect(afterClose.tabs).toHaveLength(0);
expect(afterClose.activeTabId).toBeNull();
expect(afterClose.session).toBeNull();
});

it("injects a visible agent cursor for browser interactions", async () => {
Expand Down
68 changes: 39 additions & 29 deletions apps/desktop/src/browserRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,19 +621,20 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
projectRuntime.tabOrder = projectRuntime.tabOrder.filter((entry) => entry !== tabId);
this.closeTabWebContents(tab);

if (projectRuntime.tabOrder.length === 0) {
await this.createTab(projectRuntime, { url: DEFAULT_NEW_TAB_URL, activate: true });
} else if (
projectRuntime.activeTabId === tabId ||
!projectRuntime.activeTabId ||
!projectRuntime.tabs.has(projectRuntime.activeTabId)
if (
projectRuntime.tabOrder.length > 0 &&
(projectRuntime.activeTabId === tabId ||
!projectRuntime.activeTabId ||
!projectRuntime.tabs.has(projectRuntime.activeTabId))
) {
const nextIndex = Math.min(
Math.max(closedIndex, 0),
Math.max(projectRuntime.tabOrder.length - 1, 0),
);
projectRuntime.activeTabId =
projectRuntime.tabOrder[nextIndex] ?? projectRuntime.tabOrder[0] ?? null;
} else if (projectRuntime.tabOrder.length === 0) {
projectRuntime.activeTabId = null;
}

if (this.window && this.paneOpen && this.paneProjectId === projectId && this.paneBounds) {
Expand Down Expand Up @@ -771,7 +772,10 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
CAPTURE_SELECTION_SCRIPT,
true,
)) as
| (Omit<BrowserInspectCapture, "sessionId" | "projectId" | "screenshotDataUrl" | "capturedAt"> & {
| (Omit<
BrowserInspectCapture,
"sessionId" | "projectId" | "screenshotDataUrl" | "capturedAt"
> & {
boundingBox: BrowserInspectCapture["boundingBox"];
})
| null;
Expand Down Expand Up @@ -839,7 +843,10 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
});
}

async waitFor(projectId: ProjectId, input: { selector?: string; text?: string; timeoutMs?: number }) {
async waitFor(
projectId: ProjectId,
input: { selector?: string; text?: string; timeoutMs?: number },
) {
const tab = await this.ensureActiveTab(projectId);
const timeoutMs = Math.max(100, Math.min(input.timeoutMs ?? 10_000, 60_000));
const startedAt = Date.now();
Expand Down Expand Up @@ -871,7 +878,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
await tab.view.webContents.executeJavaScript(
selectorInteractionScript(
selector,
`
`
window.__t3BrowserAgentCursor?.moveTo(x, y, "click");
element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: x, clientY: y }));
element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y }));
Expand All @@ -889,7 +896,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
await tab.view.webContents.executeJavaScript(
selectorInteractionScript(
selector,
`
`
element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: x, clientY: y }));
element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y }));
`,
Expand All @@ -903,7 +910,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
await tab.view.webContents.executeJavaScript(
selectorInteractionScript(
input.selector,
`
`
if (!("value" in element)) {
throw new Error("Target element does not support value assignment.");
}
Expand All @@ -923,7 +930,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
await tab.view.webContents.executeJavaScript(
selectorInteractionScript(
input.selector,
`
`
if (!("value" in element)) {
throw new Error("Target element does not support text input.");
}
Expand Down Expand Up @@ -1171,10 +1178,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
} else if (nudgedBounds.height > 1) {
nudgedBounds.height -= 1;
}
if (
nudgedBounds.width !== nextBounds.width ||
nudgedBounds.height !== nextBounds.height
) {
if (nudgedBounds.width !== nextBounds.width || nudgedBounds.height !== nextBounds.height) {
tab.view.setBounds(nudgedBounds);
}
}
Expand All @@ -1189,19 +1193,25 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
.catch(() => undefined);
}

private attachActiveTab(window: BrowserWindow, projectId: ProjectId, bounds: BrowserPaneBounds): void {
private attachActiveTab(
window: BrowserWindow,
projectId: ProjectId,
bounds: BrowserPaneBounds,
): void {
const projectRuntime = this.runtimes.get(projectId);
const activeTab = this.getActiveTab(projectRuntime);
if (!activeTab) {
return;
}

const contentView = (window as BrowserWindow & {
contentView: {
addChildView: (view: Electron.WebContentsView) => void;
removeChildView: (view: Electron.WebContentsView) => void;
};
}).contentView;
const contentView = (
window as BrowserWindow & {
contentView: {
addChildView: (view: Electron.WebContentsView) => void;
removeChildView: (view: Electron.WebContentsView) => void;
};
}
).contentView;

const sameAttachment =
this.attachedProjectId === projectId && this.attachedTabId === activeTab.tabId;
Expand Down Expand Up @@ -1264,9 +1274,11 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
this.attachedViews.delete(view);
return;
}
const contentView = (window as BrowserWindow & {
contentView: { removeChildView: (view: Electron.WebContentsView) => void };
}).contentView;
const contentView = (
window as BrowserWindow & {
contentView: { removeChildView: (view: Electron.WebContentsView) => void };
}
).contentView;
hideView(view);
contentView.removeChildView(view);
this.attachedViews.delete(view);
Expand Down Expand Up @@ -1353,9 +1365,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{
}
}

private findTabByWebContentsId(
webContentsId: number,
): { tab: BrowserTabRuntimeRecord } | null {
private findTabByWebContentsId(webContentsId: number): { tab: BrowserTabRuntimeRecord } | null {
for (const projectRuntime of this.runtimes.values()) {
for (const tab of projectRuntime.tabs.values()) {
if (tab.view.webContents.id === webContentsId) {
Expand Down
64 changes: 24 additions & 40 deletions apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ interface ExternalServerDescriptor {
createdAt: string | null;
}

type ExternalServerDiscoverer = (filter: ExternalServerFilter) => Promise<ExternalServerDescriptor[]>;
type ExternalServerDiscoverer = (
filter: ExternalServerFilter,
) => Promise<ExternalServerDescriptor[]>;
type ExternalProcessKiller = (pid: number) => Promise<void>;

function defaultShellResolver(): string {
Expand Down Expand Up @@ -303,8 +305,7 @@ function samePath(left: string, right: string): boolean {
function pathContains(parent: string, child: string): boolean {
const relativePath = path.relative(path.resolve(parent), path.resolve(child));
return (
relativePath.length === 0 ||
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
);
}

Expand All @@ -331,25 +332,7 @@ function escapeRegExp(value: string): string {
}

function normalizeProjectRootForMatch(value: string): string {
return value
.trim()
.replaceAll("\\", "/")
.replace(/\/+/g, "/")
.replace(/\/+$/g, "")
.toLowerCase();
}

function encodePowershellCommand(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}

function normalizeProjectRootForMatch(value: string): string {
return value
.trim()
.replaceAll("\\", "/")
.replace(/\/+/g, "/")
.replace(/\/+$/g, "")
.toLowerCase();
return value.trim().replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/+$/g, "").toLowerCase();
}

function parseJsonArrayOrObject<T>(value: string): T[] {
Expand Down Expand Up @@ -386,10 +369,7 @@ function commandMatchesProjectRoot(
const normalizedCommandLine = normalizeProjectRootForMatch(commandLine).replaceAll('"', "");
return projectRoots.some((root) => {
const normalizedRoot = normalizeProjectRootForMatch(root);
const boundaryPattern = new RegExp(
`(^|\\s)${escapeRegExp(normalizedRoot)}(?:/|\\s|$)`,
"i",
);
const boundaryPattern = new RegExp(`(^|\\s)${escapeRegExp(normalizedRoot)}(?:/|\\s|$)`, "i");
return (
normalizedCommandLine === normalizedRoot ||
normalizedCommandLine.includes(`${normalizedRoot}/`) ||
Expand All @@ -400,7 +380,9 @@ function commandMatchesProjectRoot(

function extractRecentOutputPorts(value: string): Set<number> {
const ports = new Set<number>();
for (const match of value.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{2,5})/gi)) {
for (const match of value.matchAll(
/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{2,5})/gi,
)) {
const port = Number(match[1]);
if (Number.isInteger(port) && port > 0) {
ports.add(port);
Expand Down Expand Up @@ -520,16 +502,12 @@ async function discoverPosixExternalServers(
let commandLine = "";
let parentPid: number | null = null;
try {
const psResult = await runProcess(
"ps",
["-p", String(currentPid), "-o", "ppid=,command="],
{
timeoutMs: 1_500,
allowNonZeroExit: true,
maxBufferBytes: 65_536,
outputMode: "truncate",
},
);
const psResult = await runProcess("ps", ["-p", String(currentPid), "-o", "ppid=,command="], {
timeoutMs: 1_500,
allowNonZeroExit: true,
maxBufferBytes: 65_536,
outputMode: "truncate",
});
const raw = psResult.stdout.trim();
const firstSpace = raw.search(/\s/);
if (firstSpace > 0) {
Expand All @@ -553,7 +531,9 @@ async function discoverPosixExternalServers(
});
}

return descriptors.filter((server) => commandMatchesProjectRoot(server.commandLine, projectRoots));
return descriptors.filter((server) =>
commandMatchesProjectRoot(server.commandLine, projectRoots),
);
}

async function defaultExternalServerDiscoverer(
Expand Down Expand Up @@ -891,7 +871,10 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
.filter((session) => includeInactive || session.status === "running")
.filter((session) => !input.threadId || session.threadId === input.threadId)
.filter((session) => !input.cwd || samePath(session.cwd, input.cwd))
.filter((session) => !input.projectRoot || this.sessionMatchesProjectRoot(session, input.projectRoot))
.filter(
(session) =>
!input.projectRoot || this.sessionMatchesProjectRoot(session, input.projectRoot),
)
.map((session) => this.summary(session))
.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt));

Expand Down Expand Up @@ -1324,7 +1307,8 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
} catch (migrationError) {
this.logger.warn("failed to rename legacy terminal history", {
threadId,
error: migrationError instanceof Error ? migrationError.message : String(migrationError),
error:
migrationError instanceof Error ? migrationError.message : String(migrationError),
});
}
}
Expand Down
11 changes: 3 additions & 8 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1526,7 +1526,8 @@ function createDesktopBrowserBridge(
closePane: async () => undefined,
newTab: async () => {
const tabId = `tab-${tabs.length + 1}`;
const base = buildSnapshot().tabs?.[0];
const base =
buildSnapshot().tabs?.[0] ?? createDesktopBrowserSnapshot(projectId, paneBounds).tabs?.[0];
if (!base) {
return buildSnapshot();
}
Expand Down Expand Up @@ -1554,12 +1555,6 @@ function createDesktopBrowserBridge(
},
closeTab: async (input) => {
tabs = tabs.filter((tab) => tab.tabId !== input.tabId);
if (tabs.length === 0) {
const fallback = createDesktopBrowserSnapshot(projectId, paneBounds).tabs?.[0];
if (fallback) {
tabs = [fallback];
}
}
if (!tabs.some((tab) => tab.tabId === activeTabId)) {
activeTabId = tabs[0]?.tabId ?? null;
}
Expand Down Expand Up @@ -2322,7 +2317,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
await expect.element(page.getByLabelText("Reload")).toBeVisible();
await expect.element(page.getByLabelText("Browser URL")).toBeVisible();
await expect.element(page.getByLabelText("Inspect element")).toBeVisible();
await expect.element(page.getByLabelText("Collapse browser")).toBeVisible();
await expect.element(page.getByLabelText("Collapse browser")).not.toBeInTheDocument();
await expect
.poll(() => elementHeightByTestId("integrated-browser-top-header"))
.toBeGreaterThanOrEqual(40);
Expand Down
Loading
Loading