diff --git a/surfaces/gui/src/components/Markdown.test.tsx b/surfaces/gui/src/components/Markdown.test.tsx
index b2d4fee0..cfa09bbb 100644
--- a/surfaces/gui/src/components/Markdown.test.tsx
+++ b/surfaces/gui/src/components/Markdown.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown";
+import { Markdown, OPEN_ARTIFACT_EVENT, normalizeArtifactPath } from "./Markdown";
afterEach(cleanup);
@@ -35,4 +35,30 @@ describe("Markdown artifact links", () => {
render();
expect(screen.getByTestId("artifact-chip").textContent).toContain("report.pdf");
});
+
+ // Chinese (and other non-ASCII) filenames: micromark percent-encodes the href; without
+ // decode the backend looks up the literal %E6β¦ path and returns "not found".
+ it("decodes percent-encoded non-ASCII artifact paths before opening", () => {
+ const seen: string[] = [];
+ const listener = (e: Event) => seen.push((e as CustomEvent).detail.path);
+ window.addEventListener(OPEN_ARTIFACT_EVENT, listener);
+
+ render();
+ const chip = screen.getByTestId("artifact-chip");
+ expect(chip.getAttribute("title")).toBe("reports/2026-04_ζεΊ¦ζ₯ε.md");
+ fireEvent.click(chip);
+ expect(seen).toEqual(["reports/2026-04_ζεΊ¦ζ₯ε.md"]);
+
+ window.removeEventListener(OPEN_ARTIFACT_EVENT, listener);
+ });
+});
+
+describe("normalizeArtifactPath", () => {
+ it("decodes percent-encoded segments and strips a leading slash", () => {
+ expect(normalizeArtifactPath("reports/2026-04_%E6%9C%88%E5%BA%A6%E6%8A%A5%E5%91%8A.md")).toBe(
+ "reports/2026-04_ζεΊ¦ζ₯ε.md",
+ );
+ expect(normalizeArtifactPath("/reports/foo.md")).toBe("reports/foo.md");
+ expect(normalizeArtifactPath("reports/ascii.md")).toBe("reports/ascii.md");
+ });
});
diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx
index b4bbc5c9..fa2a0c79 100644
--- a/surfaces/gui/src/components/Markdown.tsx
+++ b/surfaces/gui/src/components/Markdown.tsx
@@ -10,16 +10,36 @@ import { Icon } from "./Icon";
// the session's artifact list, App un-hides the rail.
export const OPEN_ARTIFACT_EVENT = "ocw-open-artifact";
+/** Normalize an artifact: href path for the session workspace.
+ *
+ * react-markdown / micromark percent-encodes non-ASCII URL characters, so
+ * `artifact:reports/ζ₯ε.md` becomes `artifact:reports/%E6%8A%A5%E5%91%8A.md`.
+ * The backend looks up the literal filesystem path β encoded names 404 as "not found".
+ * Also strip a single leading `/` so `artifact:/reports/x.md` stays workspace-relative
+ * (Path(workspace) / "/abs" would otherwise escape the workspace root).
+ */
+export function normalizeArtifactPath(raw: string): string {
+ let path = raw;
+ try {
+ path = decodeURIComponent(raw);
+ } catch {
+ // malformed % sequences β keep raw
+ }
+ if (path.startsWith("/")) path = path.replace(/^\/+/, "");
+ return path;
+}
+
function ArtifactChip({ path, title }: { path: string; title: string }) {
const { t } = useTranslation();
- const file = path.split("/").pop() || path;
+ const resolved = normalizeArtifactPath(path);
+ const file = resolved.split("/").pop() || resolved;
return (