From ced8cc6dc7fee84e7d09be51f6482c4bfee69cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E5=80=9E?= Date: Tue, 4 Aug 2026 16:02:05 +0800 Subject: [PATCH] fix(gui): open external links in the default browser via tauri-plugin-opener In the Tauri desktop shell, anchor tags with target="_blank" navigate the webview instead of launching the system browser, so clicking links in assistant messages (rendered as markdown) does nothing useful. - Add tauri-plugin-opener to Cargo.toml and register it in lib.rs - Grant the opener:default permission in capabilities/default.json - Intercept link clicks in Markdown.tsx and hand the URL to openExternal(), which uses the opener plugin to launch the default browser (falls back to window.open in browser dev mode) - Update the existing openExternal() comment that said the opener plugin was not wired yet - Add a test verifying that clicking a link calls openExternal with the URL --- surfaces/gui/src-tauri/Cargo.toml | 1 + .../gui/src-tauri/capabilities/default.json | 3 ++- surfaces/gui/src-tauri/src/lib.rs | 1 + surfaces/gui/src/components/Markdown.test.tsx | 15 ++++++++++++++- surfaces/gui/src/components/Markdown.tsx | 18 +++++++++++++++++- surfaces/gui/src/tauri.ts | 7 ++++--- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/surfaces/gui/src-tauri/Cargo.toml b/surfaces/gui/src-tauri/Cargo.toml index e8e721ab..107aa253 100644 --- a/surfaces/gui/src-tauri/Cargo.toml +++ b/surfaces/gui/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-plugin-dialog = "2" tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" +tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } diff --git a/surfaces/gui/src-tauri/capabilities/default.json b/surfaces/gui/src-tauri/capabilities/default.json index b2548af6..042b33da 100644 --- a/surfaces/gui/src-tauri/capabilities/default.json +++ b/surfaces/gui/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-set-focus", "core:window:allow-unminimize", "dialog:default", - "autostart:default" + "autostart:default", + "opener:default" ] } diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index 460021b4..cc6de86a 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -597,6 +597,7 @@ pub fn run() { show_main(app); })) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, diff --git a/surfaces/gui/src/components/Markdown.test.tsx b/surfaces/gui/src/components/Markdown.test.tsx index b2d4fee0..1b22cb1f 100644 --- a/surfaces/gui/src/components/Markdown.test.tsx +++ b/surfaces/gui/src/components/Markdown.test.tsx @@ -2,7 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown"; -afterEach(cleanup); +vi.mock("../tauri", () => ({ openExternal: vi.fn() })); +const { openExternal } = await import("../tauri"); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); // §34 (UX-016): [Title](artifact:path) renders as a chip that opens the artifact viewer via // a window event; ordinary links keep the open-externally treatment. @@ -30,6 +36,13 @@ describe("Markdown artifact links", () => { expect(a.getAttribute("href")).toBe("https://example.com"); }); + it("clicking an ordinary link opens it via openExternal (default browser)", () => { + const { container } = render(); + const a = container.querySelector("a")!; + fireEvent.click(a); + expect(openExternal).toHaveBeenCalledWith("https://example.com"); + }); + it("chip title falls back to the filename when the link text is empty", () => { vi.spyOn(window, "dispatchEvent"); render(); diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx index 33670427..84ee3932 100644 --- a/surfaces/gui/src/components/Markdown.tsx +++ b/surfaces/gui/src/components/Markdown.tsx @@ -1,6 +1,7 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Icon } from "./Icon"; +import { openExternal } from "../tauri"; // §34 (UX-016): the agent ends a deliverable turn with plain markdown — // [Title](artifact:relative/path) — and the renderer turns it into a chip that opens the @@ -50,7 +51,22 @@ export function Markdown({ text }: { text: string }) { return ; } return ( - + { + // In the Tauri desktop shell, target="_blank" navigates the webview + // instead of opening the system browser. Intercept the click and + // hand the URL to the opener plugin (which launches the default + // browser); fall back to the default anchor behaviour in browser dev. + if (href) { + e.preventDefault(); + openExternal(href); + } + }} + > {children} ); diff --git a/surfaces/gui/src/tauri.ts b/surfaces/gui/src/tauri.ts index 95f3ccdb..d7677948 100644 --- a/surfaces/gui/src/tauri.ts +++ b/surfaces/gui/src/tauri.ts @@ -125,9 +125,10 @@ export const clearPendingUpdate = () => invokeStrict("clear_pending_update * Windows hands off to the installer). */ export const installUpdate = () => invokeStrict("install_update"); -/** Best-effort open a URL in the user's browser. Uses the Tauri opener plugin if present, else - * `window.open`. The caller should also render the raw URL so it stays copyable if both no-op - * (the desktop webview has no opener plugin wired yet). */ +/** Best-effort open a URL in the user's default browser. Uses the Tauri opener + * plugin if present (desktop shell), else falls back to `window.open` (browser + * dev mode). The caller should also render the raw URL so it stays copyable if + * both no-op. */ export function openExternal(url: string): void { const opener = (globalThis as any).__TAURI__?.opener; if (opener?.openUrl) {