diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ede1450a2d7..4412153f113 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -89,6 +89,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MarkdownBody } from "./MarkdownBody"; +import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; @@ -669,15 +670,14 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { return ; } return ( - {text} - + ); }); diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 95098a1b9bd..48b695e8a82 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -28,7 +28,6 @@ vi.mock("@/lib/router", () => ({ }: { children: ReactNode; to: string } & React.ComponentProps<"a">) => ( {children} ), - useLocation: () => ({ pathname: "/", search: "", hash: "" }), })); vi.mock("../api/issues", () => ({ @@ -534,22 +533,4 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/issues/ACME-1"'); }); - it("renders workspace file refs in inline code as workspace file links when enabled", () => { - const html = renderToStaticMarkup( - - - - {"Check `ui/src/pages/IssueDetail.tsx:42` please."} - - - , - ); - expect(html).toContain('data-workspace-file-link="true"'); - expect(html).toContain("ui/src/pages/IssueDetail.tsx:42"); - }); - - it("does not create workspace file links when the flag is off", () => { - const html = renderMarkdown("See `ui/src/pages/IssueDetail.tsx:42` for details."); - expect(html).not.toContain("data-workspace-file-link"); - }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 100c8ae2761..4d4cb8e7bf7 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -12,13 +12,7 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; -import { - parseWorkspaceFileHref, - remarkWorkspaceFileRefs, - WORKSPACE_FILE_HREF_PREFIX, -} from "../lib/remark-workspace-file-refs"; import { StatusIcon } from "./StatusIcon"; -import { WorkspaceFileLink } from "./WorkspaceFileLink"; interface MarkdownBodyProps { children: string; @@ -32,12 +26,6 @@ interface MarkdownBodyProps { wikiLinkRoot?: string; /** Optional href resolver for wikilinks. Return null to leave a token as plain text. */ resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; - /** - * When true, auto-link workspace file references inside inline code spans - * (e.g. `ui/src/pages/IssueDetail.tsx:42`). Requires a FileViewerProvider - * ancestor so that clicking a link opens the viewer. - */ - linkWorkspaceFileReferences?: boolean; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ @@ -172,7 +160,6 @@ function extractMermaidSource(children: ReactNode): string | null { } function safeMarkdownUrlTransform(url: string): string { - if (url.startsWith(WORKSPACE_FILE_HREF_PREFIX)) return url; return parseMentionChipHref(url) ? url : defaultUrlTransform(url); } @@ -580,7 +567,6 @@ export function MarkdownBody({ enableWikiLinks = false, wikiLinkRoot, resolveWikiLinkHref, - linkWorkspaceFileReferences = false, resolveImageSrc, onImageClick, }: MarkdownBodyProps) { @@ -599,9 +585,6 @@ export function MarkdownBody({ if (linkIssueReferences) { remarkPlugins.push([remarkLinkIssueReferences, { knownPrefixes }]); } - if (linkWorkspaceFileReferences) { - remarkPlugins.push(remarkWorkspaceFileRefs); - } if (softBreaks) { remarkPlugins.push(remarkSoftBreaks); } @@ -653,13 +636,6 @@ export function MarkdownBody({ a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => { const dataProps = anchorProps as Record; const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true"; - if (linkWorkspaceFileReferences) { - const workspaceFileRef = parseWorkspaceFileHref(href); - if (workspaceFileRef) { - return ; - } - } - if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) { return ( { + it("links workspace file refs in inline code to the current issue file viewer", () => { + const markdown = linkWorkspaceFileInlineCode( + "Check `ui/src/pages/IssueDetail.tsx:42` please.", + "/issues/PAP-1", + "?tab=chat", + "#comment-1", + ); + + expect(markdown).toContain("[`ui/src/pages/IssueDetail.tsx:42`]("); + expect(markdown).toContain("/issues/PAP-1?tab=chat&file=ui%2Fsrc%2Fpages%2FIssueDetail.tsx&line=42#comment-1"); + }); + + it("leaves non-file inline code unchanged", () => { + expect(linkWorkspaceFileInlineCode("Run `pnpm test`.", "/issues/PAP-1", "", "")).toBe("Run `pnpm test`."); + }); +}); diff --git a/ui/src/components/WorkspaceFileMarkdownBody.tsx b/ui/src/components/WorkspaceFileMarkdownBody.tsx new file mode 100644 index 00000000000..e3764444a77 --- /dev/null +++ b/ui/src/components/WorkspaceFileMarkdownBody.tsx @@ -0,0 +1,62 @@ +import type { MouseEvent } from "react"; +import { useMemo } from "react"; +import { useLocation } from "@/lib/router"; +import { readFileViewerStateFromSearch, useFileViewer, writeFileViewerStateToSearch } from "@/context/FileViewerContext"; +import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser"; +import { MarkdownBody } from "./MarkdownBody"; + +type MarkdownBodyProps = Parameters[0]; + +const INLINE_CODE_RE = /`([^`\r\n]+)`/g; + +function escapeMarkdownLinkLabel(value: string) { + return value.replace(/([\\\]])/g, "\\$1"); +} + +export function linkWorkspaceFileInlineCode(markdown: string, currentPathname: string, currentSearch: string, currentHash: string) { + return markdown.replace(INLINE_CODE_RE, (token, rawCode: string) => { + const ref = parseWorkspaceFileRef(rawCode); + if (!ref) return token; + const nextSearch = writeFileViewerStateToSearch(currentSearch, { + path: ref.path, + line: ref.line, + column: ref.column, + workspace: "auto", + }); + const href = `${currentPathname}${nextSearch}${currentHash}`; + return `[\`${escapeMarkdownLinkLabel(ref.raw)}\`](${href})`; + }); +} + +export function WorkspaceFileMarkdownBody({ + children, + ...props +}: MarkdownBodyProps) { + const location = useLocation(); + const viewer = useFileViewer(); + const linkedMarkdown = useMemo( + () => linkWorkspaceFileInlineCode(children, location.pathname, location.search, location.hash), + [children, location.hash, location.pathname, location.search], + ); + + const handleClick = (event: MouseEvent) => { + if (!viewer) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const anchor = (event.target as HTMLElement | null)?.closest("a"); + if (!anchor) return; + + const url = new URL(anchor.href, window.location.href); + if (url.origin !== window.location.origin || url.pathname !== location.pathname) return; + const next = readFileViewerStateFromSearch(url.search); + if (!next) return; + + event.preventDefault(); + viewer.open(next); + }; + + return ( +
+ {linkedMarkdown} +
+ ); +}