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) {