From e968149e19743c389d5fb3fb73b615780e7f5d8f Mon Sep 17 00:00:00 2001 From: Di Chen Date: Sat, 31 Jan 2026 19:09:38 +0800 Subject: [PATCH 1/2] feat: add clickable file paths in agent responses - Enable clicking on file paths in markdown to open them in tabs - Normalize file paths before opening for consistency --- bun.lock | 15 ++++++++++ packages/app/src/pages/directory-layout.tsx | 31 +++++++++++++++++++ packages/ui/src/components/markdown.css | 6 ++++ packages/ui/src/components/markdown.tsx | 33 +++++++++++++++++++++ packages/ui/src/context/data.tsx | 4 +++ 5 files changed, 89 insertions(+) diff --git a/bun.lock b/bun.lock index 746360f1b518..f2b2a1c66d44 100644 --- a/bun.lock +++ b/bun.lock @@ -180,6 +180,19 @@ "cloudflare": "5.2.0", }, }, + "packages/conversation-recorder": { + "name": "opencode-conversation-recorder", + "version": "0.1.0", + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + }, + }, "packages/desktop": { "name": "@opencode-ai/desktop", "version": "1.1.47", @@ -3201,6 +3214,8 @@ "opencode": ["opencode@workspace:packages/opencode"], + "opencode-conversation-recorder": ["opencode-conversation-recorder@workspace:packages/conversation-recorder"], + "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 037b08c723a6..2e41b29ecbde 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" +import { useLayout } from "@/context/layout" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" @@ -14,6 +15,7 @@ import { useLanguage } from "@/context/language" export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() + const layout = useLayout() const language = useLanguage() const directory = createMemo(() => { return decode64(params.dir) ?? "" @@ -51,6 +53,34 @@ export default function Layout(props: ParentProps) { navigate(`/${params.dir}/session/${sessionID}`) } + const openFile = (path: string) => { + // Only works when we're in a session context + const sessionId = params.id + if (!sessionId) return + + const sessionKey = `${params.dir}${sessionId ? "/" + sessionId : ""}` + const tabs = layout.tabs(sessionKey) + + // Normalize path the same way file.tab() does + let normalized = path + if (normalized.startsWith("file://")) { + normalized = normalized.slice(7) + } + const root = directory() + if (root && normalized.startsWith(root)) { + normalized = normalized.slice(root.length) + } + if (normalized.startsWith("./")) { + normalized = normalized.slice(2) + } + if (normalized.startsWith("/")) { + normalized = normalized.slice(1) + } + + const tabValue = `file://${normalized}` + tabs.open(tabValue) + } + return ( {props.children} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index ef43187336e6..544699dace5e 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -166,6 +166,7 @@ font-feature-settings: var(--font-family-mono--font-feature-settings); color: var(--syntax-string); font-weight: var(--font-weight-medium); + cursor: pointer; /* font-size: 13px; */ /* padding: 2px 2px; */ @@ -175,6 +176,11 @@ /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ } + :not(pre) > code:hover { + text-decoration: underline; + text-underline-offset: 2px; + } + /* Tables */ table { width: 100%; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index e3102214bf54..34ca94362dcb 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,4 +1,5 @@ import { useMarked } from "../context/marked" +import { useData } from "../context" import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" @@ -6,6 +7,21 @@ import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" +// Matches common file path patterns: +// - ./path/file.ext, ../path/file.ext, /absolute/path.ext +// - relative/path/file.ext, file.ext +function isFilePath(text: string): boolean { + // Must have a file extension + if (!/\.\w+$/.test(text)) return false + // Match paths starting with ./, ../, or / + if (/^\.{1,2}\/[\w\-./]+$/.test(text)) return true + // Match absolute paths + if (/^\/[\w\-./]+$/.test(text)) return true + // Match relative paths (word/word.ext pattern, must have at least one slash or be a simple filename) + if (/^[\w\-]+(\/[\w\-./]+)?$/.test(text)) return true + return false +} + type Entry = { hash: string html: string @@ -169,8 +185,24 @@ 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 handleClick = (e: MouseEvent) => { + if (!data?.openFile) return + + const target = e.target as HTMLElement + // Check if clicked on inline code (not inside a code block) + if (target.tagName === "CODE" && target.parentElement?.tagName !== "PRE") { + const text = target.textContent?.trim() + if (text && isFilePath(text)) { + e.preventDefault() + data.openFile(text) + } + } + } + const [html] = createResource( () => local.text, async (markdown) => { @@ -258,6 +290,7 @@ export function Markdown( [local.class ?? ""]: !!local.class, }} ref={setRoot} + onClick={handleClick} {...others} /> ) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index dcb9adb39c8d..24e3c7bfa1a9 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -48,6 +48,8 @@ export type QuestionRejectFn = (input: { requestID: string }) => void export type NavigateToSessionFn = (sessionID: string) => void +export type OpenFileFn = (path: string) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -57,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onQuestionReply?: QuestionReplyFn onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn + onOpenFile?: OpenFileFn }) => { return { get store() { @@ -69,6 +72,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ replyToQuestion: props.onQuestionReply, rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, + openFile: props.onOpenFile, } }, }) From 6c96ee013c7cdeab3e12c9f08919b085ba887467 Mon Sep 17 00:00:00 2001 From: Di Chen Date: Sat, 31 Jan 2026 19:45:31 +0800 Subject: [PATCH 2/2] Save --- bun.lock | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/bun.lock b/bun.lock index f2b2a1c66d44..746360f1b518 100644 --- a/bun.lock +++ b/bun.lock @@ -180,19 +180,6 @@ "cloudflare": "5.2.0", }, }, - "packages/conversation-recorder": { - "name": "opencode-conversation-recorder", - "version": "0.1.0", - "dependencies": { - "@opencode-ai/plugin": "workspace:*", - "@opencode-ai/sdk": "workspace:*", - }, - "devDependencies": { - "@tsconfig/node22": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - }, - }, "packages/desktop": { "name": "@opencode-ai/desktop", "version": "1.1.47", @@ -3214,8 +3201,6 @@ "opencode": ["opencode@workspace:packages/opencode"], - "opencode-conversation-recorder": ["opencode-conversation-recorder@workspace:packages/conversation-recorder"], - "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],