From 6f72c6242702f0dc465b68d9fae7e500fd3279e7 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Fri, 27 Feb 2026 11:51:05 +0000 Subject: [PATCH 1/9] fix(ui): restore singular project path helper name --- packages/ui/src/components/message-part.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e8c9dcf9505b..e7545812e729 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -210,6 +210,17 @@ function getDirectory(path: string | undefined) { return relativizeProjectPath(_getDirectory(path), data.directory) } +function openProjectFile( + path: string | undefined, + directory: string, + openFilePath?: (input: { path: string }) => void, +) { + if (!path) return + const file = relativizeProjectPath(path, directory).replace(/^\//, "") + if (!file) return + openFilePath?.({ path: file }) +} + import type { IconProps } from "./icon" export type ToolInfo = { From ab12c7adf22dc1cc7beaa24ad1739369fc9d9cbc Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 09:04:38 +0000 Subject: [PATCH 2/9] refactor(ui): extract markdown file reference parser Move inline file-path parsing into a shared helper and add focused tests, including Windows file URL handling, so clickable markdown file links stay stable as parsing rules evolve. --- .../src/components/markdown-file-ref.test.ts | 43 +++++++++ .../ui/src/components/markdown-file-ref.ts | 55 ++++++++++++ packages/ui/src/components/markdown.tsx | 87 +++++++++++++------ 3 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/components/markdown-file-ref.test.ts create mode 100644 packages/ui/src/components/markdown-file-ref.ts diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts new file mode 100644 index 000000000000..9f5614168c62 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { parseCodeFileRef } from "./markdown-file-ref" + +describe("parseCodeFileRef", () => { + test("parses relative path with line and trims punctuation", () => { + expect(parseCodeFileRef("src/app.ts:42,", "")).toEqual({ + path: "src/app.ts", + line: 42, + }) + }) + + test("parses hash-based line suffix", () => { + expect(parseCodeFileRef("src/app.ts#L12", "")).toEqual({ + path: "src/app.ts", + line: 12, + }) + }) + + test("parses file urls and strips project root", () => { + expect(parseCodeFileRef("file:///Users/test/repo/src/main.ts:9", "/Users/test/repo")).toEqual({ + path: "src/main.ts", + line: 9, + }) + }) + + test("normalizes windows paths", () => { + expect(parseCodeFileRef("C:\\repo\\src\\main.ts:7", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 7, + }) + }) + + test("parses windows file url paths", () => { + expect(parseCodeFileRef("file:///C:/repo/src/main.ts#L11", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 11, + }) + }) + + test("ignores non-path text", () => { + expect(parseCodeFileRef("hello-world", "")).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts new file mode 100644 index 000000000000..c3d0edbe5468 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -0,0 +1,55 @@ +export type FileRef = { + path: string + line?: number +} + +function looksLikePath(path: string) { + if (!path) return false + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true + if (/^[a-zA-Z]:[\\/]/.test(path)) return true + return path.includes("/") || path.includes("\\") +} + +function normalizeProjectPath(path: string, directory: string) { + if (!path) return path + const file = path.replace(/\\/g, "/") + const root = directory.replace(/\\/g, "/") + if (/^\/[a-zA-Z]:\//.test(file)) return file.slice(1) + if (file.startsWith(root + "/")) return file.slice(root.length + 1) + if (file === root) return "" + if (file.startsWith("./")) return file.slice(2) + return file +} + +export function parseCodeFileRef(text: string, directory: string): FileRef | undefined { + let value = text.trim().replace(/[),.;!?]+$/, "") + let lineFromUrlHash: number | undefined + if (!value) return + + if (value.startsWith("file://")) { + try { + const url = new URL(value) + value = decodeURIComponent(url.pathname) + const match = url.hash.match(/^#L(\d+)$/) + lineFromUrlHash = match ? Number(match[1]) : undefined + } catch { + return + } + } + + const hash = value.match(/#L(\d+)$/) + const lineFromHash = hash ? Number(hash[1]) : undefined + if (hash) value = value.slice(0, -hash[0].length) + + const line = value.match(/:(\d+)(?::\d+)?$/) + const lineFromSuffix = line ? Number(line[1]) : undefined + if (line) { + const maybePath = value.slice(0, -line[0].length) + if (looksLikePath(maybePath)) value = maybePath + } + + if (!looksLikePath(value)) return + const path = normalizeProjectPath(value, directory) + if (!path) return + return { path, line: lineFromUrlHash ?? lineFromHash ?? lineFromSuffix } +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 01254f118951..c24f4e00e56b 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,5 +1,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" +import { useData } from "../context/data" +import { parseCodeFileRef, type FileRef } from "./markdown-file-ref" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" @@ -143,7 +145,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { } } -function markCodeLinks(root: HTMLDivElement) { +function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolean) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { const href = codeUrl(code.textContent ?? "") @@ -152,35 +154,46 @@ function markCodeLinks(root: HTMLDivElement) { ? code.parentElement : null - if (!href) { - if (parentLink) parentLink.replaceWith(code) + if (href) { + if (parentLink) { + parentLink.href = href + } else { + const link = document.createElement("a") + link.href = href + link.className = "external-link" + link.target = "_blank" + link.rel = "noopener noreferrer" + code.parentNode?.replaceChild(link, code) + link.appendChild(code) + } continue } - if (parentLink) { - parentLink.href = href - continue - } + if (parentLink) parentLink.replaceWith(code) + if (!openable) continue + + const file = parseCodeFileRef(code.textContent ?? "", directory) + if (!file) continue - const link = document.createElement("a") - link.href = href - link.className = "external-link" - link.target = "_blank" - link.rel = "noopener noreferrer" - code.parentNode?.replaceChild(link, code) - link.appendChild(code) + const button = document.createElement("button") + button.type = "button" + button.className = "file-link" + button.setAttribute("data-file-path", file.path) + if (file.line) button.setAttribute("data-file-line", String(file.line)) + code.parentNode?.replaceChild(button, code) + button.appendChild(code) } } -function decorate(root: HTMLDivElement, labels: CopyLabels) { +function decorate(root: HTMLDivElement, labels: CopyLabels, directory: string, openable: boolean) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } - markCodeLinks(root) + markCodeLinks(root, directory, openable) } -function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { +function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (input: FileRef) => void) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { @@ -192,6 +205,18 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { const target = event.target if (!(target instanceof Element)) return + const file = target.closest("button.file-link") + if (file instanceof HTMLButtonElement) { + const path = file.getAttribute("data-file-path") + const raw = file.getAttribute("data-file-line") + const line = raw ? Number(raw) : undefined + if (!path || !onFileOpen) return + event.preventDefault() + event.stopPropagation() + onFileOpen({ path, line }) + return + } + const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLButtonElement)) return const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") @@ -207,8 +232,6 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { timeouts.set(button, timeout) } - decorate(root, labels) - const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) { if (button instanceof HTMLButtonElement) updateLabel(button) @@ -245,6 +268,7 @@ export function Markdown( ) { const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const marked = useMarked() + const data = useData() const i18n = useI18n() const [root, setRoot] = createSignal() const [html] = createResource( @@ -287,10 +311,15 @@ export function Markdown( const temp = document.createElement("div") temp.innerHTML = content - decorate(temp, { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - }) + decorate( + temp, + { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }, + data.directory, + !!data.openFilePath, + ) morphdom(container, temp, { childrenOnly: true, @@ -303,10 +332,14 @@ export function Markdown( if (copySetupTimer) clearTimeout(copySetupTimer) copySetupTimer = setTimeout(() => { if (copyCleanup) copyCleanup() - copyCleanup = setupCodeCopy(container, { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - }) + copyCleanup = setupCodeCopy( + container, + { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }, + data.openFilePath, + ) }, 150) }) From 2cea33770fe35d364b2f2b64cb2307cfb7542fd6 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 09:04:42 +0000 Subject: [PATCH 3/9] fix(ui): make tool file links keyboard-accessible Render clickable tool filenames as native buttons with focus-visible styles so edit/write/apply_patch file links are accessible and keep existing click-to-open behavior. --- packages/ui/src/components/message-part.css | 21 ++++++++++ packages/ui/src/components/message-part.tsx | 44 +++++++++++++++++++-- packages/ui/src/context/data.tsx | 4 ++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 8031bf2631dc..8709d3b05fff 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1199,6 +1199,27 @@ flex-shrink: 0; } + button[data-slot="apply-patch-filename"] { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + text-align: left; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + + &:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 2px; + } + } + [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e7545812e729..3f621b4f518c 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1181,7 +1181,12 @@ export const ToolRegistry = { render: getTool, } -function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { +function ToolFileAccordion(props: { + path: string + actions?: JSX.Element + children: JSX.Element + onPathClick?: () => void +}) { const value = createMemo(() => props.path || "tool-file") return ( @@ -1201,7 +1206,21 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre {`\u202A${getDirectory(props.path)}\u202C`} - {getFilename(props.path)} + {getFilename(props.path)}} + > + +
@@ -1771,6 +1790,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) @@ -1811,6 +1831,7 @@ ToolRegistry.register({ openProjectFile(path(), data.directory, data.openFilePath)} actions={ @@ -1843,6 +1864,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) @@ -1877,7 +1899,10 @@ ToolRegistry.register({ } > - + openProjectFile(path(), data.directory, data.openFilePath)} + >
(props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1990,7 +2016,16 @@ ToolRegistry.register({ {`\u202A${getDirectory(file.relativePath)}\u202C`} - {getFilename(file.relativePath)} +
@@ -2073,6 +2108,7 @@ ToolRegistry.register({ > openProjectFile(single()!.relativePath, data.directory, data.openFilePath)} actions={ diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb233..5fe5dc8aa906 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -26,6 +26,8 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string +export type OpenFilePathFn = (input: { path: string; line?: number }) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -33,6 +35,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn + onOpenFilePath?: OpenFilePathFn }) => { return { get store() { @@ -43,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, + openFilePath: props.onOpenFilePath, } }, }) From 734f9fcb03c82c1dc8fda29d74434e5059734fc0 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 11:52:42 +0000 Subject: [PATCH 4/9] feat(ui): open clicked file refs in desktop default apps Route markdown and tool file-link clicks to the desktop openPath integration when available, with in-app review fallback if external open fails. Also harden file-ref parsing for wrapped paths and ensure in-app fallback activates the opened tab. --- packages/app/src/pages/directory-layout.tsx | 31 +++++++++++++++++++ packages/app/src/pages/session.tsx | 1 + .../app/src/pages/session/helpers.test.ts | 10 +++++- packages/app/src/pages/session/helpers.ts | 10 +++--- .../src/components/markdown-file-ref.test.ts | 6 ++++ .../ui/src/components/markdown-file-ref.ts | 5 ++- packages/ui/src/components/markdown.css | 19 ++++++++++++ 7 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f993ffcd8906..c483d3128c93 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -11,10 +11,13 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" + function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + const platform = usePlatform() return ( ) { directory={props.directory} onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} + onOpenFilePath={async (input) => { + const file = input.path.replace(/^[\\/]+/, "") + const separator = props.directory.includes("\\") ? "\\" : "/" + const path = props.directory.endsWith(separator) ? props.directory + file : props.directory + separator + file + + if (platform.platform === "desktop" && platform.openPath) { + await platform.openPath(path).catch((error) => { + const description = error instanceof Error ? error.message : String(error) + showToast({ + variant: "error", + title: "Open failed", + description, + }) + window.dispatchEvent( + new CustomEvent("opencode:open-file-path", { + detail: input, + }), + ) + }) + return + } + + window.dispatchEvent( + new CustomEvent("opencode:open-file-path", { + detail: input, + }), + ) + }} > {props.children} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081ab..35c03633e3d9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -910,6 +910,7 @@ export default function Page() { const openReviewFile = createOpenReviewFile({ showAllFiles, + openReviewPanel, tabForPath: file.tab, openTab: tabs().open, setActive: tabs().setActive, diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 047946fc1efc..1c3083ed7a6f 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -14,6 +14,7 @@ describe("createOpenReviewFile", () => { const calls: string[] = [] const openReviewFile = createOpenReviewFile({ showAllFiles: () => calls.push("show"), + openReviewPanel: () => calls.push("review"), tabForPath: (path) => { calls.push(`tab:${path}`) return `file://${path}` @@ -25,7 +26,14 @@ describe("createOpenReviewFile", () => { openReviewFile("src/a.ts") - expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"]) + expect(calls).toEqual([ + "tab:src/a.ts", + "show", + "review", + "load:src/a.ts", + "open:file://src/a.ts", + "active:file://src/a.ts", + ]) }) }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c3571f3ffce7..3ae8819212d7 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -98,19 +98,21 @@ export const createOpenReviewFile = (input: { tabForPath: (path: string) => string openTab: (tab: string) => void setActive: (tab: string) => void + openReviewPanel: () => void loadFile: (path: string) => any | Promise }) => { return (path: string) => { + const tab = input.tabForPath(path) batch(() => { input.showAllFiles() + input.openReviewPanel() const maybePromise = input.loadFile(path) - const open = () => { - const tab = input.tabForPath(path) + const openTab = () => { input.openTab(tab) input.setActive(tab) } - if (maybePromise instanceof Promise) maybePromise.then(open) - else open() + if (maybePromise instanceof Promise) maybePromise.then(openTab) + else openTab() }) } } diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts index 9f5614168c62..4757449c1908 100644 --- a/packages/ui/src/components/markdown-file-ref.test.ts +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -37,6 +37,12 @@ describe("parseCodeFileRef", () => { }) }) + test("normalizes line breaks inside long paths", () => { + expect(parseCodeFileRef("clients/notes/reply-to-\nharry-2026-02-27.md", "")).toEqual({ + path: "clients/notes/reply-to-harry-2026-02-27.md", + }) + }) + test("ignores non-path text", () => { expect(parseCodeFileRef("hello-world", "")).toBeUndefined() }) diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts index c3d0edbe5468..afec68fba411 100644 --- a/packages/ui/src/components/markdown-file-ref.ts +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -22,7 +22,10 @@ function normalizeProjectPath(path: string, directory: string) { } export function parseCodeFileRef(text: string, directory: string): FileRef | undefined { - let value = text.trim().replace(/[),.;!?]+$/, "") + let value = text + .trim() + .replace(/\s*\n\s*/g, "") + .replace(/[),.;!?]+$/, "") let lineFromUrlHash: number | undefined if (!value) return diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f82723807d6c..07c96d34eb53 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -264,3 +264,22 @@ text-decoration: underline; text-underline-offset: 2px; } + +[data-component="markdown"] button.file-link { + appearance: none; + border: none; + background: transparent; + display: inline; + padding: 0; + margin: 0; + color: inherit; + font: inherit; + text-align: left; + white-space: normal; + cursor: pointer; +} + +[data-component="markdown"] button.file-link:hover > code { + text-decoration: underline; + text-underline-offset: 2px; +} From 27061eb64d0587691ba78dbc9cb1d0161f700c85 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 12:07:31 +0000 Subject: [PATCH 5/9] fix(app): apply file-link line targets in in-app fallback When external open is unavailable or fails, opening from clickable file refs now preserves optional line numbers by selecting the target line in the review file tab. --- packages/app/src/pages/session.tsx | 13 +++++++++++++ .../app/src/pages/session/helpers.test.ts | 19 +++++++++++++++++++ packages/app/src/pages/session/helpers.ts | 4 +++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 35c03633e3d9..bb147c50341c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -914,9 +914,22 @@ export default function Page() { tabForPath: file.tab, openTab: tabs().open, setActive: tabs().setActive, + setSelectedLines: file.setSelectedLines, loadFile: file.load, }) + onMount(() => { + const open = (event: Event) => { + const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail + const path = detail?.path + if (!path) return + openReviewFile(path, detail?.line) + } + + window.addEventListener("opencode:open-file-path", open) + onCleanup(() => window.removeEventListener("opencode:open-file-path", open)) + }) + const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 1c3083ed7a6f..5b2b7e9c3293 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -21,6 +21,7 @@ describe("createOpenReviewFile", () => { }, openTab: (tab) => calls.push(`open:${tab}`), setActive: (tab) => calls.push(`active:${tab}`), + setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`), loadFile: (path) => calls.push(`load:${path}`), }) @@ -33,8 +34,26 @@ describe("createOpenReviewFile", () => { "load:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts", + "select:src/a.ts:none", ]) }) + + test("selects the requested line when provided", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => calls.push("show"), + openReviewPanel: () => calls.push("review"), + tabForPath: (path) => `file://${path}`, + openTab: () => calls.push("open"), + setActive: () => calls.push("active"), + setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`), + loadFile: () => calls.push("load"), + }) + + openReviewFile("src/a.ts", 12) + + expect(calls).toContain("select:12-12") + }) }) describe("createOpenSessionFileTab", () => { diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 3ae8819212d7..ea71eda8b488 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -99,9 +99,10 @@ export const createOpenReviewFile = (input: { openTab: (tab: string) => void setActive: (tab: string) => void openReviewPanel: () => void + setSelectedLines: (path: string, range: { start: number; end: number } | null) => void loadFile: (path: string) => any | Promise }) => { - return (path: string) => { + return (path: string, line?: number) => { const tab = input.tabForPath(path) batch(() => { input.showAllFiles() @@ -110,6 +111,7 @@ export const createOpenReviewFile = (input: { const openTab = () => { input.openTab(tab) input.setActive(tab) + input.setSelectedLines(path, line ? { start: line, end: line } : null) } if (maybePromise instanceof Promise) maybePromise.then(openTab) else openTab() From b87602ee9de1879a390ee3e42c366af220552bce Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 23:16:59 +0000 Subject: [PATCH 6/9] refactor(ui): extract shared tool file header --- packages/ui/src/components/message-part.tsx | 52 +++++---------------- packages/ui/src/components/tool-file.tsx | 28 +++++++++++ 2 files changed, 39 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/components/tool-file.tsx diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3f621b4f518c..623c8d2631dc 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -52,6 +52,7 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" +import { ToolFile } from "./tool-file" import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" @@ -1200,29 +1201,11 @@ function ToolFileAccordion(props: {
-
- -
- - {`\u202A${getDirectory(props.path)}\u202C`} - - {getFilename(props.path)}} - > - - -
-
+
{props.actions} @@ -2010,24 +1993,11 @@ ToolRegistry.register({
-
- -
- - {`\u202A${getDirectory(file.relativePath)}\u202C`} - - -
-
+ openProjectFile(file.relativePath, data.directory, data.openFilePath)} + />
diff --git a/packages/ui/src/components/tool-file.tsx b/packages/ui/src/components/tool-file.tsx new file mode 100644 index 000000000000..d97d744fb058 --- /dev/null +++ b/packages/ui/src/components/tool-file.tsx @@ -0,0 +1,28 @@ +import { Show } from "solid-js" +import { FileIcon } from "./file-icon" +import { getFilename } from "@opencode-ai/util/path" + +export function ToolFile(props: { path: string; dir?: string; onClick?: () => void }) { + return ( +
+ +
+ + {`\u202A${props.dir}\u202C`} + + {getFilename(props.path)}}> + + +
+
+ ) +} From 4c29e6a6c81e68653d1fcc19016fc139c48e591c Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 23:39:06 +0000 Subject: [PATCH 7/9] fix(ui): align clickable file refs with web proposal --- packages/app/src/pages/session.tsx | 8 ++++ .../app/src/pages/session/helpers.test.ts | 21 ++++++++ packages/app/src/pages/session/helpers.ts | 15 +++++- packages/ui/src/components/file.tsx | 34 +++++++++++++ .../src/components/markdown-file-ref.test.ts | 16 ++++++- .../ui/src/components/markdown-file-ref.ts | 18 +++++++ packages/ui/src/components/markdown.css | 12 +++++ packages/ui/src/components/markdown.tsx | 48 ++++++++++++++++--- packages/ui/src/components/message-part.css | 23 +++++++++ packages/ui/src/components/message-part.tsx | 28 +++++++++-- 10 files changed, 212 insertions(+), 11 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index bb147c50341c..ca4585092d30 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -914,7 +914,15 @@ export default function Page() { tabForPath: file.tab, openTab: tabs().open, setActive: tabs().setActive, + getFile: file.get, setSelectedLines: file.setSelectedLines, + onLineError: ({ path, line, max }) => { + showToast({ + variant: "default", + title: "Line unavailable", + description: `${path}:${line} is out of range, opened line ${max} instead.`, + }) + }, loadFile: file.load, }) diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 5b2b7e9c3293..811c999fe9ee 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -21,6 +21,7 @@ describe("createOpenReviewFile", () => { }, openTab: (tab) => calls.push(`open:${tab}`), setActive: (tab) => calls.push(`active:${tab}`), + getFile: () => ({ content: { content: "one\ntwo" } }), setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`), loadFile: (path) => calls.push(`load:${path}`), }) @@ -46,6 +47,7 @@ describe("createOpenReviewFile", () => { tabForPath: (path) => `file://${path}`, openTab: () => calls.push("open"), setActive: () => calls.push("active"), + getFile: () => ({ content: { content: "one\n".repeat(20) } }), setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`), loadFile: () => calls.push("load"), }) @@ -54,6 +56,25 @@ describe("createOpenReviewFile", () => { expect(calls).toContain("select:12-12") }) + + test("clamps out of range lines", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => undefined, + openReviewPanel: () => undefined, + tabForPath: (path) => `file://${path}`, + openTab: () => undefined, + setActive: () => undefined, + getFile: () => ({ content: { content: "one\ntwo" } }), + setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`), + onLineError: ({ line, max }) => calls.push(`warn:${line}->${max}`), + loadFile: () => undefined, + }) + + openReviewFile("src/a.ts", 12) + + expect(calls).toEqual(["warn:12->2", "select:2-2"]) + }) }) describe("createOpenSessionFileTab", () => { diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index ea71eda8b488..9020d73dff57 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -100,8 +100,18 @@ export const createOpenReviewFile = (input: { setActive: (tab: string) => void openReviewPanel: () => void setSelectedLines: (path: string, range: { start: number; end: number } | null) => void + getFile: (path: string) => { content?: { content?: unknown } } | undefined + onLineError?: (input: { path: string; line: number; max: number }) => void loadFile: (path: string) => any | Promise }) => { + const lines = (value: unknown) => { + if (typeof value === "string") return Math.max(1, value.split("\n").length - (value.endsWith("\n") ? 1 : 0)) + if (Array.isArray(value)) return Math.max(1, value.length) + if (value == null) return 0 + const text = String(value) + return Math.max(1, text.split("\n").length - (text.endsWith("\n") ? 1 : 0)) + } + return (path: string, line?: number) => { const tab = input.tabForPath(path) batch(() => { @@ -109,9 +119,12 @@ export const createOpenReviewFile = (input: { input.openReviewPanel() const maybePromise = input.loadFile(path) const openTab = () => { + const max = lines(input.getFile(path)?.content?.content) + const next = typeof line === "number" && max > 0 ? Math.max(1, Math.min(line, max)) : undefined + if (typeof line === "number" && max > 0 && next !== line) input.onLineError?.({ path, line, max }) input.openTab(tab) input.setActive(tab) - input.setSelectedLines(path, line ? { start: line, end: line } : null) + input.setSelectedLines(path, next ? { start: next, end: next } : null) } if (maybePromise instanceof Promise) maybePromise.then(openTab) else openTab() diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 15915dd52d48..261e78bd0317 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -523,6 +523,30 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined { } } +function reveal(viewer: Viewer, range: SelectedLineRange | null | undefined) { + if (!range) return + const line = Math.min(range.start, range.end) + requestAnimationFrame(() => { + const root = viewer.getRoot() + const wrap = viewer.wrapper + if (!root || !wrap) return + const path = range.side ? `[data-${range.side}] [data-line="${line}"]` : `[data-line="${line}"]` + const node = root.querySelector(path) ?? root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + const parent = scrollParent(wrap) + if (!parent) { + node.scrollIntoView({ block: "center", inline: "nearest" }) + return + } + const box = parent.getBoundingClientRect() + const item = node.getBoundingClientRect() + const top = item.top - box.top + parent.scrollTop + const target = top - parent.clientHeight / 2 + item.height / 2 + const max = Math.max(0, parent.scrollHeight - parent.clientHeight) + parent.scrollTop = Math.max(0, Math.min(target, max)) + }) +} + function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy { let virtualizer: Virtualizer | undefined let root: Document | HTMLElement | undefined @@ -836,6 +860,11 @@ function TextViewer(props: TextFileProps) { }) } + createEffect(() => { + viewer.rendered() + reveal(viewer, local.selectedLines) + }) + useSearchHandle({ search: () => local.search, find: viewer.find, @@ -1027,6 +1056,11 @@ function DiffViewer(props: DiffFileProps) { }) } + createEffect(() => { + viewer.rendered() + reveal(viewer, local.selectedLines) + }) + useSearchHandle({ search: () => local.search, find: viewer.find, diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts index 4757449c1908..2b066cccd307 100644 --- a/packages/ui/src/components/markdown-file-ref.test.ts +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { parseCodeFileRef } from "./markdown-file-ref" +import { parseCodeFileRef, splitCodeText } from "./markdown-file-ref" describe("parseCodeFileRef", () => { test("parses relative path with line and trims punctuation", () => { @@ -47,3 +47,17 @@ describe("parseCodeFileRef", () => { expect(parseCodeFileRef("hello-world", "")).toBeUndefined() }) }) + +describe("splitCodeText", () => { + test("splits plain text file refs", () => { + expect(splitCodeText("See src/app.ts:42 for details", "")).toEqual([ + { text: "See " }, + { text: "src/app.ts:42", file: { path: "src/app.ts", line: 42 } }, + { text: " for details" }, + ]) + }) + + test("keeps plain text without refs intact", () => { + expect(splitCodeText("hello world", "")).toEqual([{ text: "hello world" }]) + }) +}) diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts index afec68fba411..ac794a856630 100644 --- a/packages/ui/src/components/markdown-file-ref.ts +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -3,6 +3,11 @@ export type FileRef = { line?: number } +export type FileText = { + text: string + file?: FileRef +} + function looksLikePath(path: string) { if (!path) return false if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true @@ -56,3 +61,16 @@ export function parseCodeFileRef(text: string, directory: string): FileRef | und if (!path) return return { path, line: lineFromUrlHash ?? lineFromHash ?? lineFromSuffix } } + +export function splitCodeText(text: string, directory: string): FileText[] { + return (text.match(/\s+|[^\s]+/g) ?? []).reduce((list, item) => { + const file = parseCodeFileRef(item, directory) + if (file) return [...list, { text: item, file }] + const last = list.at(-1) + if (last && !last.file) { + last.text += item + return list + } + return [...list, { text: item }] + }, []) +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 07c96d34eb53..4503915201de 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -279,6 +279,18 @@ cursor: pointer; } +[data-component="markdown"] button.file-link:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 3px; +} + +[data-component="markdown"] button.file-link:hover, +[data-component="markdown"] button.file-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; +} + [data-component="markdown"] button.file-link:hover > code { text-decoration: underline; text-underline-offset: 2px; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index c24f4e00e56b..8dfcdec9bb90 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,7 +1,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" import { useData } from "../context/data" -import { parseCodeFileRef, type FileRef } from "./markdown-file-ref" +import { parseCodeFileRef, splitCodeText, type FileRef } from "./markdown-file-ref" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" @@ -106,6 +106,16 @@ function createCopyButton(labels: CopyLabels) { return button } +function createFileButton(file: FileRef, text: string) { + const button = document.createElement("button") + button.type = "button" + button.className = "file-link" + button.setAttribute("data-file-path", file.path) + if (file.line) button.setAttribute("data-file-line", String(file.line)) + button.textContent = text + return button +} + function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) { if (copied) { button.setAttribute("data-copied", "true") @@ -175,22 +185,48 @@ function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolea const file = parseCodeFileRef(code.textContent ?? "", directory) if (!file) continue - const button = document.createElement("button") - button.type = "button" - button.className = "file-link" - button.setAttribute("data-file-path", file.path) - if (file.line) button.setAttribute("data-file-line", String(file.line)) + const button = createFileButton(file, "") code.parentNode?.replaceChild(button, code) button.appendChild(code) } } +function markTextLinks(root: HTMLDivElement, directory: string, openable: boolean) { + if (!openable) return + const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + const list: Text[] = [] + let node = walk.nextNode() + while (node) { + if (node instanceof Text) list.push(node) + node = walk.nextNode() + } + + for (const item of list) { + const parent = item.parentElement + const text = item.textContent ?? "" + if (!parent || !text.trim()) continue + if (parent.closest("pre, code, a, button")) continue + const parts = splitCodeText(text, directory) + if (!parts.some((part) => part.file)) continue + const frag = document.createDocumentFragment() + for (const part of parts) { + if (!part.file) { + frag.appendChild(document.createTextNode(part.text)) + continue + } + frag.appendChild(createFileButton(part.file, part.text)) + } + item.parentNode?.replaceChild(frag, item) + } +} + function decorate(root: HTMLDivElement, labels: CopyLabels, directory: string, openable: boolean) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } markCodeLinks(root, directory, openable) + markTextLinks(root, directory, openable) } function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (input: FileRef) => void) { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 8709d3b05fff..e08b72fd103a 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -417,6 +417,29 @@ white-space: pre-wrap; overflow-wrap: anywhere; } + + button.file-link { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 0; + cursor: pointer; + text-align: left; + } + + button.file-link:hover, + button.file-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; + } + + button.file-link:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 3px; + } } [data-component="edit-trigger"], diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 623c8d2631dc..dc3652d15e5e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -56,6 +56,7 @@ import { ToolFile } from "./tool-file" import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" +import { splitCodeText, type FileRef } from "./markdown-file-ref" function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined @@ -214,12 +215,31 @@ function getDirectory(path: string | undefined) { function openProjectFile( path: string | undefined, directory: string, - openFilePath?: (input: { path: string }) => void, + openFilePath?: (input: FileRef) => void, + line?: number, ) { if (!path) return const file = relativizeProjectPath(path, directory).replace(/^\//, "") if (!file) return - openFilePath?.({ path: file }) + openFilePath?.({ path: file, line }) +} + +function LinkText(props: { text: string }) { + const data = useData() + const parts = createMemo(() => splitCodeText(props.text, data.directory)) + + return ( + + {(part) => { + if (!part.file || !data.openFilePath) return part.text + return ( + + ) + }} + + ) } import type { IconProps } from "./icon" @@ -1761,7 +1781,9 @@ ToolRegistry.register({
-              {text()}
+              
+                
+              
             
From 685a0a8c2199d5f47f50bda76ad39348e863050d Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 19 Mar 2026 21:17:33 +0000 Subject: [PATCH 8/9] refactor(app): extract open file path handling --- packages/app/src/context/open-file-path.tsx | 20 +++++++++++ packages/app/src/pages/directory-layout.tsx | 37 ++++----------------- packages/app/src/pages/session.tsx | 5 +-- packages/app/src/utils/open-file-path.ts | 33 ++++++++++++++++++ 4 files changed, 62 insertions(+), 33 deletions(-) create mode 100644 packages/app/src/context/open-file-path.tsx create mode 100644 packages/app/src/utils/open-file-path.ts diff --git a/packages/app/src/context/open-file-path.tsx b/packages/app/src/context/open-file-path.tsx new file mode 100644 index 000000000000..4ec302d2b4e3 --- /dev/null +++ b/packages/app/src/context/open-file-path.tsx @@ -0,0 +1,20 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { usePlatform } from "@/context/platform" +import { openFilePath, type OpenFileInput } from "@/utils/open-file-path" + +export const { use: useOpenFilePath, provider: OpenFilePathProvider } = createSimpleContext({ + name: "OpenFilePath", + init: (props: { directory: string }) => { + const platform = usePlatform() + + return { + open(input: OpenFileInput) { + return openFilePath({ + directory: props.directory, + input, + platform, + }) + }, + } + }, +}) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index c483d3128c93..96768123fede 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -5,19 +5,19 @@ import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { useGlobalSDK } from "@/context/global-sdk" +import { OpenFilePathProvider, useOpenFilePath } from "@/context/open-file-path" import { DataProvider } from "@opencode-ai/ui/context" import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) - const platform = usePlatform() + const open = useOpenFilePath() return ( ) { directory={props.directory} onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} - onOpenFilePath={async (input) => { - const file = input.path.replace(/^[\\/]+/, "") - const separator = props.directory.includes("\\") ? "\\" : "/" - const path = props.directory.endsWith(separator) ? props.directory + file : props.directory + separator + file - - if (platform.platform === "desktop" && platform.openPath) { - await platform.openPath(path).catch((error) => { - const description = error instanceof Error ? error.message : String(error) - showToast({ - variant: "error", - title: "Open failed", - description, - }) - window.dispatchEvent( - new CustomEvent("opencode:open-file-path", { - detail: input, - }), - ) - }) - return - } - - window.dispatchEvent( - new CustomEvent("opencode:open-file-path", { - detail: input, - }), - ) - }} + onOpenFilePath={open.open} > {props.children} @@ -115,7 +88,9 @@ export default function Layout(props: ParentProps) { {(resolved) => ( resolved}> - {props.children} + + {props.children} + )} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ca4585092d30..025cfbe84ace 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -50,6 +50,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" +import { OPEN_FILE_PATH_EVENT } from "@/utils/open-file-path" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" @@ -934,8 +935,8 @@ export default function Page() { openReviewFile(path, detail?.line) } - window.addEventListener("opencode:open-file-path", open) - onCleanup(() => window.removeEventListener("opencode:open-file-path", open)) + window.addEventListener(OPEN_FILE_PATH_EVENT, open) + onCleanup(() => window.removeEventListener(OPEN_FILE_PATH_EVENT, open)) }) const changesOptions = ["session", "turn"] as const diff --git a/packages/app/src/utils/open-file-path.ts b/packages/app/src/utils/open-file-path.ts new file mode 100644 index 000000000000..3d9916836883 --- /dev/null +++ b/packages/app/src/utils/open-file-path.ts @@ -0,0 +1,33 @@ +import type { OpenFilePathFn } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import type { Platform } from "@/context/platform" + +export type OpenFileInput = Parameters[0] + +export const OPEN_FILE_PATH_EVENT = "opencode:open-file-path" + +export function dispatchOpenFilePath(input: OpenFileInput) { + window.dispatchEvent(new CustomEvent(OPEN_FILE_PATH_EVENT, { detail: input })) +} + +export function resolveOpenFilePath(dir: string, path: string) { + const file = path.replace(/^[\\/]+/, "") + const separator = dir.includes("\\") ? "\\" : "/" + return dir.endsWith(separator) ? dir + file : dir + separator + file +} + +export async function openFilePath(opts: { directory: string; input: OpenFileInput; platform: Platform }) { + if (opts.platform.platform !== "desktop" || !opts.platform.openPath) { + dispatchOpenFilePath(opts.input) + return + } + + await opts.platform.openPath(resolveOpenFilePath(opts.directory, opts.input.path)).catch((err) => { + showToast({ + variant: "error", + title: "Open failed", + description: err instanceof Error ? err.message : String(err), + }) + dispatchOpenFilePath(opts.input) + }) +} From 340027f1822fe93c8a58d3d7f6a73d19c43801ea Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 19 Mar 2026 21:34:40 +0000 Subject: [PATCH 9/9] Refine session review file links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open filenames in review and use the adjacent icon for external app launches — with clearer labels for both actions. --- packages/app/src/pages/session/review-tab.tsx | 3 ++ packages/ui/src/components/session-review.css | 13 ++++++++ packages/ui/src/components/session-review.tsx | 31 +++++++++++++++---- packages/ui/src/i18n/en.ts | 2 ++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index c073e621472c..cd943b54ef6a 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -7,6 +7,7 @@ import type { SessionReviewCommentUpdate, } from "@opencode-ai/ui/session-review" import type { SelectedLineRange } from "@/context/file" +import { useOpenFilePath } from "@/context/open-file-path" import { useSDK } from "@/context/sdk" import { useLayout } from "@/context/layout" import type { LineComment } from "@/context/comments" @@ -45,6 +46,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { const sdk = useSDK() const layout = useLayout() + const open = useOpenFilePath() const readFile = async (path: string) => { return sdk.client.file @@ -156,6 +158,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { diffStyle={props.diffStyle} onDiffStyleChange={props.onDiffStyleChange} onViewFile={props.onViewFile} + onOpenFile={(file) => open.open({ path: file })} focusedFile={props.focusedFile} readFile={readFile} onLineComment={props.onLineComment} diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 6b5b9ac8627c..c30f4738b5b8 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -131,6 +131,19 @@ white-space: nowrap; } + button[data-slot="session-review-filename"] { + border: none; + padding: 0; + background: transparent; + cursor: pointer; + text-align: left; + + &:hover, + &:focus-visible { + text-decoration: underline; + } + } + [data-slot="session-review-view-button"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f61a2..33390fd9bf84 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -87,6 +87,7 @@ export interface SessionReviewProps { actions?: JSX.Element diffs: ReviewDiff[] onViewFile?: (file: string) => void + onOpenFile?: (file: string) => void readFile?: (path: string) => Promise } @@ -166,7 +167,8 @@ export const SessionReview = (props: SessionReviewProps) => { handleChange(next) } - const openFileLabel = () => i18n.t("ui.sessionReview.openFile") + const openReviewLabel = () => i18n.t("ui.sessionReview.openInReview") + const openExternalLabel = () => i18n.t("ui.sessionReview.openExternally") const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" @@ -407,17 +409,34 @@ export const SessionReview = (props: SessionReviewProps) => { {`\u202A${getDirectory(file)}\u202C`} - {getFilename(file)} - - + {getFilename(file)}} + > + + + + + + diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 18823aeaa19b..b4a197341dd0 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -14,6 +14,8 @@ export const dict: Record = { "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.", "ui.sessionReview.largeDiff.renderAnyway": "Render anyway", "ui.sessionReview.openFile": "Open file", + "ui.sessionReview.openInReview": "Open in review", + "ui.sessionReview.openExternally": "Open externally", "ui.sessionReview.selection.line": "line {{line}}", "ui.sessionReview.selection.lines": "lines {{start}}-{{end}}",