) => {
+ 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,
}) => {