From 11739469763476ba589437f90bd3097074d88bdb Mon Sep 17 00:00:00 2001 From: weihua Date: Mon, 8 Jun 2026 16:37:40 +0800 Subject: [PATCH] Open message links externally --- apps/desktop/electron/main.ts | 52 +++++++++++++++- .../desktop/tests/core/external-links.spec.ts | 59 +++++++++++++++++++ apps/desktop/tests/helpers/electron-app.ts | 46 +++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/tests/core/external-links.spec.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dbbcf24b..cbf9f1db 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -91,6 +91,38 @@ const appIconPath = app.isPackaged : path.join(__dirname, "..", "..", "resources", "icon.png"); const appIcon = nativeImage.createFromPath(appIconPath); +function parseWebUrl(url: string): URL | null { + try { + const parsed = new URL(url); + return ["http:", "https:"].includes(parsed.protocol) ? parsed : null; + } catch { + return null; + } +} + +function isInAppUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (isDev && process.env.ELECTRON_RENDERER_URL) { + return parsed.origin === new URL(process.env.ELECTRON_RENDERER_URL).origin; + } + return parsed.protocol === "file:"; + } catch { + return false; + } +} + +function openExternalWebUrl(url: string): boolean { + const parsed = parseWebUrl(url); + if (!parsed) { + return false; + } + void shell.openExternal(parsed.toString()).catch((error) => { + console.error(`Failed to open external URL: ${parsed.toString()}`, error); + }); + return true; +} + function readClipboardImageAttachment(): ComposerImageAttachment | null { const image = clipboard.readImage(); if (image.isEmpty()) { @@ -141,6 +173,20 @@ function createWindow(): BrowserWindow { }, }); + window.webContents.setWindowOpenHandler(({ url }) => { + if (!isInAppUrl(url)) { + openExternalWebUrl(url); + } + return { action: "deny" }; + }); + window.webContents.on("will-navigate", (event, url) => { + if (isInAppUrl(url)) { + return; + } + event.preventDefault(); + openExternalWebUrl(url); + }); + window.once("ready-to-show", () => { if (!backgroundTestMode) { window.show(); @@ -488,11 +534,11 @@ app.whenReady().then(async () => { return mode; }); ipcMain.handle(desktopIpc.openExternal, (_event, url: string) => { - const parsed = new URL(url); - if (!["http:", "https:"].includes(parsed.protocol)) { + const parsed = parseWebUrl(url); + if (!parsed) { throw new Error(`Refusing to open unsupported URL: ${url}`); } - return shell.openExternal(url); + return shell.openExternal(parsed.toString()); }); ipcMain.handle(desktopIpc.stateRequest, () => store.getState()); ipcMain.handle(desktopIpc.selectedTranscriptRequest, () => store.getSelectedTranscript()); diff --git a/apps/desktop/tests/core/external-links.spec.ts b/apps/desktop/tests/core/external-links.spec.ts new file mode 100644 index 00000000..36933173 --- /dev/null +++ b/apps/desktop/tests/core/external-links.spec.ts @@ -0,0 +1,59 @@ +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; +import { + launchDesktop, + makeUserDataDir, + makeWorkspace, + seedAgentDir, + seedExternalLinkSessionFixture, + selectSession, +} from "../helpers/electron-app"; + +test("opens markdown web links externally without leaving the current session", async () => { + test.setTimeout(60_000); + const userDataDir = await makeUserDataDir(); + const agentDir = join(userDataDir, "agent"); + const workspacePath = await makeWorkspace("external-links-workspace"); + const targetUrl = "https://github.com/minghinmatthewlam/pi-gui/issues/20"; + await seedAgentDir(agentDir); + await seedExternalLinkSessionFixture(agentDir, workspacePath); + + const harness = await launchDesktop(userDataDir, { + agentDir, + initialWorkspaces: [workspacePath], + testMode: "background", + }); + + try { + const window = await harness.firstWindow(); + await selectSession(window, "External link fixture session"); + const appUrl = window.url(); + + await harness.electronApp.evaluate(({ shell }) => { + const globals = globalThis as typeof globalThis & { __piGuiOpenedExternalUrls?: string[] }; + globals.__piGuiOpenedExternalUrls = []; + shell.openExternal = async (url: string) => { + globals.__piGuiOpenedExternalUrls?.push(url); + }; + }); + + await window.getByRole("link", { name: "GitHub issue" }).click(); + + await expect + .poll(() => + harness.electronApp.evaluate( + () => + (globalThis as typeof globalThis & { __piGuiOpenedExternalUrls?: string[] }) + .__piGuiOpenedExternalUrls ?? [], + ), + ) + .toEqual([targetUrl]); + await expect + .poll(() => harness.electronApp.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)) + .toBe(1); + expect(window.url()).toBe(appUrl); + await expect(window.getByTestId("transcript")).toContainText("GitHub issue"); + } finally { + await harness.close(); + } +}); diff --git a/apps/desktop/tests/helpers/electron-app.ts b/apps/desktop/tests/helpers/electron-app.ts index cb6c8849..5d132dec 100644 --- a/apps/desktop/tests/helpers/electron-app.ts +++ b/apps/desktop/tests/helpers/electron-app.ts @@ -634,6 +634,52 @@ export async function seedToolResultTreeSessionFixture( }); } +export async function seedExternalLinkSessionFixture( + agentDir: string, + workspacePath: string, +): Promise<{ + readonly sessionId: string; + readonly title: "External link fixture session"; +}> { + const { SessionManager } = (await import( + "../../../../node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js" + )) as { + SessionManager: { + create(cwd: string): { + appendMessage(message: { role: "user" | "assistant"; content: string; timestamp: number }): string; + appendSessionInfo(name: string): string; + getSessionId(): string; + }; + }; + }; + + return withAgentDirEnv(agentDir, async () => { + const sessionManager = SessionManager.create(workspacePath); + let timestamp = Date.now(); + const nextTimestamp = () => { + timestamp += 1_000; + return timestamp; + }; + + sessionManager.appendMessage({ + role: "user", + content: "Show me the related issue.", + timestamp: nextTimestamp(), + }); + sessionManager.appendMessage({ + role: "assistant", + content: "Track this in [GitHub issue](https://github.com/minghinmatthewlam/pi-gui/issues/20).", + timestamp: nextTimestamp(), + }); + sessionManager.appendSessionInfo("External link fixture session"); + + return { + sessionId: sessionManager.getSessionId(), + title: "External link fixture session", + }; + }); +} + async function withAgentDirEnv(agentDir: string, action: () => Promise): Promise { const previousAgentDir = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = agentDir;