diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e4a0f73ca22e..613d50f56e4f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1810,6 +1810,13 @@ export default function Page() { setRevealMessage={(fn) => { revealMessage = fn }} + onFileClick={(path) => { + const tab = file.tab(path) + tabs().open(tab) + tabs().setActive(tab) + file.load(path) + openReviewPanel() + }} /> diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e4ae4a23e364..182421e90d95 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -282,6 +282,7 @@ export function MessageTimeline(props: { userMessages: UserMessage[] anchor: (id: string) => string setRevealMessage?: (fn: (id: string) => void) => void + onFileClick?: (path: string) => void }) { let touchGesture: number | undefined @@ -1064,6 +1065,7 @@ export function MessageTimeline(props: { onToolOpenChange={(open) => setToolOpen(part().id, open)} deferToolContent={false} virtualizeDiff={false} + onFileClick={props.onFileClick} /> )} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 26c9efd475ef..9faf9d251b73 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -55,6 +55,10 @@ text-underline-offset: 2px; } + a.file-link { + cursor: pointer; + } + /* Lists */ ul, ol { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index e12b661dba63..11801786480f 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -175,12 +175,33 @@ function markCodeLinks(root: HTMLDivElement) { } } +function markFileLinks(root: HTMLDivElement) { + const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) + for (const code of codeNodes) { + const text = code.textContent ?? "" + if (code.parentElement instanceof HTMLAnchorElement) continue + + const raw = text.trim().replace(/[),.;!?]+$/, "") + if (!raw.includes(".")) continue + if (/^https?:\/\//i.test(raw)) continue + const path = raw.replace(/([:#]\d+).*$/, "") + if (/[<>:"|?*]/.test(path)) continue + + const link = document.createElement("a") + link.setAttribute("data-file-path", path) + link.classList.add("file-link") + code.parentNode?.replaceChild(link, code) + link.appendChild(code) + } +} + function decorate(root: HTMLDivElement, labels: CopyLabels) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } markCodeLinks(root) + markFileLinks(root) } function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { @@ -227,6 +248,20 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { } } +function setupFileClick(root: HTMLDivElement, onFileClick: (path: string) => void) { + const handler = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof Element)) return + const link = target.closest("[data-file-path]") + if (!(link instanceof HTMLAnchorElement)) return + event.preventDefault() + const path = link.getAttribute("data-file-path") + if (path) onFileClick(path) + } + root.addEventListener("click", handler) + return () => root.removeEventListener("click", handler) +} + function touch(key: string, value: Entry) { cache.delete(key) cache.set(key, value) @@ -243,11 +278,12 @@ export function Markdown( text: string cacheKey?: string streaming?: boolean + onFileClick?: (path: string) => void class?: string classList?: Record }, ) { - const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"]) + const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList", "onFileClick"]) const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() @@ -288,6 +324,7 @@ export function Markdown( ) let copyCleanup: (() => void) | undefined +let fileClickCleanup: (() => void) | undefined createEffect(() => { const container = root() @@ -325,6 +362,9 @@ export function Markdown( }, }) + if (local.onFileClick && !fileClickCleanup) + fileClickCleanup = setupFileClick(container, local.onFileClick) + if (!copyCleanup) copyCleanup = setupCodeCopy(container, () => ({ copy: i18n.t("ui.message.copy"), @@ -334,6 +374,7 @@ export function Markdown( onCleanup(() => { if (copyCleanup) copyCleanup() + if (fileClickCleanup) fileClickCleanup() }) return ( diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 35912e119d40..8fe535c74d62 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -181,6 +181,7 @@ export interface MessagePartProps { virtualizeDiff?: boolean showAssistantCopyPartID?: string | null turnDurationMs?: number + onFileClick?: (path: string) => void } export type PartComponent = Component @@ -261,7 +262,7 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { return value } -function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) { +function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean; onFileClick?: (path: string) => void }) { const value = createPacedValue( () => props.text, () => props.streaming, @@ -269,7 +270,7 @@ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boole return ( - + ) } @@ -1274,6 +1275,7 @@ export function Part(props: MessagePartProps) { virtualizeDiff={props.virtualizeDiff} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + onFileClick={props.onFileClick} /> ) @@ -1546,8 +1548,8 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- }> - + }> +
@@ -1589,8 +1591,8 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { return (
- }> - + }> +