From ee677aa275571a16a93ef3d5839f1ae876d2a0fd Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 4 Nov 2026 08:51:04 +0000 Subject: [PATCH] fix(web): preview chat file links and keep code copy controls visible (#230) --- .../web/src/components/Markdown.test.tsx | 80 ++++++++++++ .../web/src/components/Markdown.tsx | 118 +++++++++++++----- crates/ccteam-web/web/src/index.css | 12 +- .../ccteam-web/web/src/lib/filesApi.test.ts | 52 +++++++- crates/ccteam-web/web/src/lib/filesApi.ts | 43 ++++++- .../ccteam-web/web/src/pages/SessionView.tsx | 8 +- crates/ccteam-web/web/tests/v032-spa.spec.ts | 57 +++++++++ 7 files changed, 332 insertions(+), 38 deletions(-) create mode 100644 crates/ccteam-web/web/src/components/Markdown.test.tsx diff --git a/crates/ccteam-web/web/src/components/Markdown.test.tsx b/crates/ccteam-web/web/src/components/Markdown.test.tsx new file mode 100644 index 00000000..901c8c39 --- /dev/null +++ b/crates/ccteam-web/web/src/components/Markdown.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Markdown } from "./Markdown"; + +let container: HTMLDivElement; +let root: Root; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Markdown code copy", () => { + it("adds an accessible icon for each block and copies only its exact code", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + await act(() => root.render()); + const buttons = container.querySelectorAll("pre button"); + expect(buttons).toHaveLength(2); + expect(buttons[0].getAttribute("aria-label")).toBe("复制代码"); + expect(buttons[0].querySelector("svg")).not.toBeNull(); + await act(() => root.render()); + expect(container.querySelectorAll("pre button")).toHaveLength(2); + await act(() => buttons[0].click()); + expect(writeText).toHaveBeenCalledWith('const x = "";\n'); + expect(buttons[0].getAttribute("aria-label")).toBe("已复制"); + expect(buttons[1].getAttribute("aria-label")).toBe("复制代码"); + }); + + it("uses the shared fallback and reports a failed copy honestly", async () => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const exec = vi.fn().mockReturnValue(false); + Object.defineProperty(document, "execCommand", { configurable: true, value: exec }); + await act(() => root.render()); + const button = container.querySelector("button")!; + await act(() => button.click()); + expect(exec).toHaveBeenCalledWith("copy"); + expect(button.getAttribute("aria-label")).toBe("复制失败,点击重试"); + }); +}); + +describe("Markdown file previews", () => { + it("replaces document hrefs with minted same-site previews while retaining ordinary links", async () => { + const resolve = vi.fn().mockResolvedValue("/api/v1/projects/team/files/preview/fresh/docs/issue.md"); + const origin = window.location.origin; + await act(() => root.render()); + expect(resolve).toHaveBeenCalledExactlyOnceWith({ path: "/repo/docs/issue.md" }); + const links = container.querySelectorAll("a"); + expect(links[0].getAttribute("href")).toBe("/api/v1/projects/team/files/preview/fresh/docs/issue.md"); + expect(links[0].target).toBe("_blank"); + expect(links[1].getAttribute("href")).toBe("https://other.example/a.md"); + expect(links[2].getAttribute("href")).toBe("/repo/README.md:12"); + }); + + it("renews normal, modified and middle-click previews after backend tickets are lost", async () => { + const resolve = vi.fn().mockResolvedValue("/api/v1/projects/team/files/preview/old/README.md"); + const replace = vi.fn(); + vi.spyOn(window, "open").mockReturnValue({ opener: null, location: { replace }, close: vi.fn() } as unknown as Window); + await act(() => root.render()); + resolve.mockResolvedValue("/api/v1/projects/team/files/preview/new/README.md"); + const link = container.querySelector("a")!; + for (const [type, init] of [["click", {}], ["click", { ctrlKey: true }], ["auxclick", { button: 1 }]] as const) { + await act(() => { link.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, ...init })); }); + expect(resolve).toHaveBeenLastCalledWith({ path: "README.md" }, true); + expect(replace).toHaveBeenLastCalledWith("/api/v1/projects/team/files/preview/new/README.md"); + } + }); +}); diff --git a/crates/ccteam-web/web/src/components/Markdown.tsx b/crates/ccteam-web/web/src/components/Markdown.tsx index 9be564d8..8c0b4d29 100644 --- a/crates/ccteam-web/web/src/components/Markdown.tsx +++ b/crates/ccteam-web/web/src/components/Markdown.tsx @@ -4,69 +4,127 @@ // button is added to the DOM AFTER the sanitized innerHTML, so it never rides // through DOMPurify / can't be injected by message content). -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, type MouseEvent } from "react"; import { renderMarkdown } from "../lib/markdown"; -import { fileLinkTarget, type FileTarget } from "../lib/filesApi"; +import { fileLinkTarget, supportsFilePreview, type FileTarget } from "../lib/filesApi"; +import { copyText } from "../lib/clipboard"; + +// Static SVG only; never interpolate message content into button markup. +const COPY_ICON = ''; +const CHECK_ICON = ''; +const ERROR_ICON = ''; export function Markdown({ content, className, onFileOpen, + resolveFilePreview, }: { content: string; className?: string; onFileOpen?: (target: FileTarget) => void; + resolveFilePreview?: (target: FileTarget, renew?: boolean) => Promise; }) { const ref = useRef(null); + const previewLinks = useRef(new WeakMap()); + const ordinaryLinks = useRef(new WeakSet()); const html = useMemo(() => renderMarkdown(content), [content]); + // React otherwise reapplies a new innerHTML object on parent updates, + // removing the post-render controls without rerunning the html effect. + const markup = useMemo(() => ({ __html: html }), [html]); useEffect(() => { const root = ref.current; if (!root) return; + const timers = new Set(); + const buttons: HTMLButtonElement[] = []; root.querySelectorAll("pre").forEach((pre) => { if (pre.querySelector(".code-copy")) return; pre.classList.add("has-copy"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "code-copy"; - btn.textContent = "复制"; - btn.addEventListener("click", () => { - const code = - pre.querySelector("code")?.textContent ?? pre.textContent ?? ""; - void navigator.clipboard?.writeText(code); - btn.textContent = "已复制"; - window.setTimeout(() => { - btn.textContent = "复制"; - }, 1200); + const label = (text: string, icon: string) => { + btn.setAttribute("aria-label", text); + btn.title = text; + btn.innerHTML = icon; + }; + label("复制代码", COPY_ICON); + btn.addEventListener("click", async () => { + const code = pre.querySelector("code")?.textContent ?? ""; + const copied = await copyText(code); + if (!root.contains(btn)) return; + label(copied ? "已复制" : "复制失败,点击重试", copied ? CHECK_ICON : ERROR_ICON); + const timer = window.setTimeout(() => { + label("复制代码", COPY_ICON); + timers.delete(timer); + }, 1600); + timers.add(timer); }); pre.appendChild(btn); + buttons.push(btn); }); + return () => { + timers.forEach(window.clearTimeout); + buttons.forEach((button) => button.remove()); + }; }, [html]); + useEffect(() => { + const root = ref.current; + if (!root || !resolveFilePreview) return; + let active = true; + root.querySelectorAll("a[href]").forEach((link) => { + const target = previewLinks.current.get(link) ?? fileLinkTarget(link.getAttribute("href") ?? "", window.location.origin); + if (!target || target.line || !supportsFilePreview(target.path)) return; + void resolveFilePreview(target).then((url) => { + if (!active) return; + if (!url) { ordinaryLinks.current.add(link); return; } + previewLinks.current.set(link, target); + link.setAttribute("href", url); + link.target = "_blank"; + link.rel = "noopener noreferrer"; + }).catch(() => { + // A failed preview keeps the ordinary file-workspace click path. + }); + }); + return () => { active = false; }; + }, [html, resolveFilePreview]); + + const openLink = (event: MouseEvent) => { + const link = (event.target as Element).closest("a[href]"); + if (!link || ordinaryLinks.current.has(link)) return; + const preview = previewLinks.current.get(link); + if (preview && resolveFilePreview) { + event.preventDefault(); + // Each activation mints a fresh capability: TTL and daemon restarts + // invalidate old tickets, including on modified and middle clicks. + const page = window.open("about:blank", "_blank"); + if (page) page.opener = null; + void resolveFilePreview(preview, true).then((url) => { + if (page && url) { + link.setAttribute("href", url); + page.location.replace(url); + } else { page?.close(); onFileOpen?.(preview); } + }).catch(() => { page?.close(); onFileOpen?.(preview); }); + return; + } + if (!onFileOpen || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + const target = fileLinkTarget(link.getAttribute("href") ?? "", window.location.origin); + if (target) { + event.preventDefault(); + onFileOpen(target); + } + }; + return (
{ - if ( - !onFileOpen || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.altKey - ) - return; - const link = (event.target as Element).closest("a[href]"); - const target = link - ? fileLinkTarget(link.getAttribute("href") ?? "") - : null; - if (target) { - event.preventDefault(); - onFileOpen(target); - } - }} + onClick={openLink} + onAuxClick={(event) => { if (event.button === 1) openLink(event); }} // html is renderMarkdown() output = marked + DOMPurify.sanitize. - dangerouslySetInnerHTML={{ __html: html }} + dangerouslySetInnerHTML={markup} /> ); } diff --git a/crates/ccteam-web/web/src/index.css b/crates/ccteam-web/web/src/index.css index ff728363..9cd48e11 100644 --- a/crates/ccteam-web/web/src/index.css +++ b/crates/ccteam-web/web/src/index.css @@ -1140,7 +1140,12 @@ html.dark .cockpit-markdown a { color: #67e8f9; } position: absolute; top: 0.3rem; right: 0.3rem; - padding: 0.15rem 0.45rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0.3rem; font-family: var(--font-sans); font-size: 0.65rem; line-height: 1.3; @@ -1149,11 +1154,8 @@ html.dark .cockpit-markdown a { color: #67e8f9; } border: 1px solid var(--border); border-radius: 0.3rem; cursor: pointer; - opacity: 0; - transition: opacity 0.12s; } -.cockpit-markdown pre.has-copy:hover .code-copy, -.cockpit-markdown .code-copy:focus-visible { opacity: 1; } +.cockpit-markdown pre.has-copy { padding-top: 2.2rem; } .cockpit-markdown .code-copy:hover { color: var(--text); } .cockpit-markdown hr { border: none; border-top: 1px solid var(--border-soft); margin: 0.75rem 0; } .cockpit-markdown .cockpit-table-wrap { diff --git a/crates/ccteam-web/web/src/lib/filesApi.test.ts b/crates/ccteam-web/web/src/lib/filesApi.test.ts index 5bac9368..08a7f97f 100644 --- a/crates/ccteam-web/web/src/lib/filesApi.test.ts +++ b/crates/ccteam-web/web/src/lib/filesApi.test.ts @@ -1,12 +1,22 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fileLinkTarget, + createFilePreviewResolver, openFileTab, projectRelativePath, supportsFilePreview, } from "./filesApi"; describe("project file navigation", () => { + it("recognizes file URLs on the current origin without hardcoding a host", () => { + for (const origin of ["https://console.example", "http://localhost:7331"]) { + expect(fileLinkTarget(`${origin}/repo/docs/issue.md`, origin)).toEqual({ + path: "/repo/docs/issue.md", + }); + expect(fileLinkTarget(`${origin}/api/v1/projects/a`, origin)).toBeNull(); + expect(fileLinkTarget("https://elsewhere.example/repo/issue.md", origin)).toBeNull(); + } + }); it("shares preview controls between HTML and Markdown documents", () => { for (const path of [ "README.md", @@ -73,3 +83,43 @@ describe("project file navigation", () => { expect(projectRelativePath("/repo", p)).toBeNull(); }); }); + +describe("chat document preview links", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("resolves project paths and shares fresh tickets, then renews expired ones", async () => { + vi.useFakeTimers(); + let minted = 0; + const fetcher = vi.fn(async (_url: string, init?: RequestInit) => + new Response(JSON.stringify(init?.method === "POST" + ? { url: `/api/v1/projects/team/files/preview/t${++minted}/docs/issue.md`, expires_in: 3600 } + : { root: "/repo", paths: [], git_status: {}, truncated: false })), + ); + vi.stubGlobal("fetch", fetcher); + const resolve = createFilePreviewResolver("team"); + const first = await Promise.all([ + resolve({ path: "/repo/docs/issue.md" }), + resolve({ path: "docs/issue.md" }), + ]); + expect(first).toEqual(Array(2).fill("/api/v1/projects/team/files/preview/t1/docs/issue.md")); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetcher.mock.calls[1][1]?.body as string)).toEqual({ path: "docs/issue.md" }); + vi.advanceTimersByTime(3600_000); + expect(await resolve({ path: "/repo/docs/issue.md" })).toContain("/preview/t2/"); + expect(await resolve({ path: "/repo/docs/issue.md" }, true)).toContain("/preview/t3/"); + }); + + it("does not mint previews for sources, line links or files outside the project", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ root: "/repo" }))); + vi.stubGlobal("fetch", fetcher); + const resolve = createFilePreviewResolver("team"); + expect(await resolve({ path: "src/lib.rs" })).toBeNull(); + expect(await resolve({ path: "README.md", line: 10 })).toBeNull(); + expect(await resolve({ path: "/repository/issue.md" })).toBeNull(); + expect(await resolve({ path: "../issue.md" })).toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/crates/ccteam-web/web/src/lib/filesApi.ts b/crates/ccteam-web/web/src/lib/filesApi.ts index b82af920..c4e565e5 100644 --- a/crates/ccteam-web/web/src/lib/filesApi.ts +++ b/crates/ccteam-web/web/src/lib/filesApi.ts @@ -59,7 +59,18 @@ export const mintFilePreview = ( /** Local Markdown references only; ordinary web/navigation links retain their * browser behavior. Project containment is checked again by the server. */ -export function fileLinkTarget(href: string): FileTarget | null { +export function fileLinkTarget(href: string, origin?: string): FileTarget | null { + // Replies may spell a local path as an absolute URL on this console. + // Compare origins, never a deployment hostname or a string prefix. + if (origin && /^(?:https?:)?\/\//i.test(href)) { + try { + const url = new URL(href, origin); + if (url.origin !== origin) return null; + href = `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } + } if ( /^(?:https?|ftp|javascript|data|blob|mailto|tel|file):/i.test(href) || /^[a-z][a-z\d+.-]*:\/\//i.test(href) || @@ -91,6 +102,36 @@ export function fileLinkTarget(href: string): FileTarget | null { return { path, ...(line && line > 0 ? { line } : {}) }; } +/** One resolver per mounted conversation: share root discovery and preview + * requests across its messages, without caching capabilities across logins. */ +export function createFilePreviewResolver(slug: string) { + let root: Promise | undefined; + const previews = new Map }>(); + return async (target: FileTarget, renew = false): Promise => { + if (target.line || !supportsFilePreview(target.path)) return null; + root ??= getProjectFiles(slug).then((tree) => tree.root).catch((error: unknown) => { + root = undefined; + throw error; + }); + const path = projectRelativePath(await root, target.path); + if (!path) return null; + const cached = previews.get(path); + if (cached && (cached.expires === Infinity || (!renew && cached.expires > Date.now()))) return cached.url; + const entry = { + expires: Infinity, + url: mintFilePreview(slug, path).then((preview) => { + entry.expires = Date.now() + Math.max(0, preview.expires_in * 1000 - 5000); + return preview.url; + }).catch((error: unknown) => { + previews.delete(path); + throw error; + }), + }; + previews.set(path, entry); + return entry.url; + }; +} + export interface FileTab { path: string; pinned: boolean; diff --git a/crates/ccteam-web/web/src/pages/SessionView.tsx b/crates/ccteam-web/web/src/pages/SessionView.tsx index b06e18df..5afdebb2 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -38,7 +38,7 @@ import { } from "lucide-react"; import { ChatComposer } from "../components/ChatComposer"; import { ContextPanel } from "../components/ContextPanel"; -import type { FileTarget } from "../lib/filesApi"; +import { createFilePreviewResolver, type FileTarget } from "../lib/filesApi"; import "../components/fileWorkspace.css"; import { InlineRename } from "../components/InlineRename"; import { copyText } from "../lib/clipboard"; @@ -263,6 +263,11 @@ export default function SessionView({ onRename?: (sid: string, title: string) => void | Promise; }) { const t = makeT(lang); + const previewProject = session?.project; + const resolveFilePreview = useMemo( + () => previewProject ? createFilePreviewResolver(previewProject) : undefined, + [previewProject], + ); // Inline title edit in the header. `pendingTitle` is the optimistic value // shown only WHILE the shell's rename is in flight — without it the header // visibly snaps back to the old title for a beat after Enter. It is cleared @@ -1126,6 +1131,7 @@ export default function SessionView({ ) : null} { + test.use({ hasTouch: true }); + for (const width of [390, 1360]) { + test(`document previews and per-block copy at ${width}px`, async ({ page }) => { + await mockCcteamApi(page); + await page.setViewportSize({ width, height: 1000 }); + await page.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { + writeText: async (text: string) => { (window as typeof window & { copiedCode?: string }).copiedCode = text; }, + } }); + }); + const origin = new URL(page.url() === "about:blank" ? test.info().project.use.baseURL! : page.url()).origin; + await page.route("**/api/v1/sessions/s1", route => route.fulfill({ json: { + sid: "s1", events: [{ turn_id: "links-copy", role: "reviewer", user: "inspect", assistant: + `[issue](/repo/docs/issue.md) [same site](${origin}/repo/docs/issue.md) [external](https://elsewhere.example/issue.md)\n\n` + + '```ts\nconst answer = "";\n```\n\n```\nsecond block\n```', + }], + } })); + await page.route("**/api/v1/projects/dev-team/files", route => route.fulfill({ json: { + root: "/repo", paths: ["docs/", "docs/issue.md"], git_status: {}, truncated: false, + } })); + let minted = 0; + await page.route("**/api/v1/projects/dev-team/files/preview", route => { + expect(route.request().postDataJSON()).toEqual({ path: "docs/issue.md" }); + return route.fulfill({ json: { + url: `/api/v1/projects/dev-team/files/preview/ticket-${++minted}/docs/issue.md`, expires_in: 3600, + } }); + }); + await page.context().route("**/files/preview/ticket-*/docs/issue.md", route => route.fulfill({ + contentType: "text/html", body: "

Issue preview

", + })); + await page.goto("/app/chat/s/s1"); + const expected = "/api/v1/projects/dev-team/files/preview/ticket-1/docs/issue.md"; + await expect(page.getByRole("link", { name: "issue", exact: true })).toHaveAttribute("href", expected); + await expect(page.getByRole("link", { name: "same site", exact: true })).toHaveAttribute("href", expected); + await expect(page.getByRole("link", { name: "external", exact: true })).toHaveAttribute("href", "https://elsewhere.example/issue.md"); + expect(minted).toBe(1); + const popupEvent = page.waitForEvent("popup"); + await page.getByRole("link", { name: "same site", exact: true }).tap(); + const popup = await popupEvent; + await expect(popup.getByRole("heading", { name: "Issue preview" })).toBeVisible(); + expect(new URL(popup.url()).origin).toBe(origin); + await popup.close(); + const buttons = page.locator("pre .code-copy"); + await expect(buttons).toHaveCount(2); + await expect(buttons.first().locator("svg")).toBeVisible(); + await expect(buttons.first()).toHaveCSS("opacity", "1"); + await buttons.first().tap(); + await expect(buttons.first()).toHaveAttribute("aria-label", "已复制"); + expect(await page.evaluate(() => (window as typeof window & { copiedCode?: string }).copiedCode)).toBe('const answer = "";\n'); + await buttons.last().focus(); + await page.keyboard.press("Enter"); + expect(await page.evaluate(() => (window as typeof window & { copiedCode?: string }).copiedCode)).toBe("second block\n"); + }); + } +}); + test("file workspace previews Markdown and opens the rendered document in a new page", async ({ page, }) => {