Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}}
/>
</Show>
</Match>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1064,6 +1065,7 @@ export function MessageTimeline(props: {
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false}
virtualizeDiff={false}
onFileClick={props.onFileClick}
/>
)}
</Show>
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
text-underline-offset: 2px;
}

a.file-link {
cursor: pointer;
}

/* Lists */
ul,
ol {
Expand Down
43 changes: 42 additions & 1 deletion packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -243,11 +278,12 @@ export function Markdown(
text: string
cacheKey?: string
streaming?: boolean
onFileClick?: (path: string) => void
class?: string
classList?: Record<string, boolean>
},
) {
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<HTMLDivElement>()
Expand Down Expand Up @@ -288,6 +324,7 @@ export function Markdown(
)

let copyCleanup: (() => void) | undefined
let fileClickCleanup: (() => void) | undefined

createEffect(() => {
const container = root()
Expand Down Expand Up @@ -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"),
Expand All @@ -334,6 +374,7 @@ export function Markdown(

onCleanup(() => {
if (copyCleanup) copyCleanup()
if (fileClickCleanup) fileClickCleanup()
})

return (
Expand Down
14 changes: 8 additions & 6 deletions packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export interface MessagePartProps {
virtualizeDiff?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
onFileClick?: (path: string) => void
}

export type PartComponent = Component<MessagePartProps>
Expand Down Expand Up @@ -261,15 +262,15 @@ 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,
)

return (
<Show when={value()}>
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} />
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} onFileClick={props.onFileClick} />
</Show>
)
}
Expand Down Expand Up @@ -1274,6 +1275,7 @@ export function Part(props: MessagePartProps) {
virtualizeDiff={props.virtualizeDiff}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
onFileClick={props.onFileClick}
/>
</Show>
)
Expand Down Expand Up @@ -1546,8 +1548,8 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<Show when={text()}>
<div data-component="text-part" data-timeline-part-id={part().id}>
<div data-slot="text-part-body">
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} onFileClick={props.onFileClick} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} onFileClick={props.onFileClick} />
</Show>
</div>
<Show when={showCopy()}>
Expand Down Expand Up @@ -1589,8 +1591,8 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
<Show when={text()}>
<div data-component="reasoning-part" data-timeline-part-id={part().id}>
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} onFileClick={props.onFileClick} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} onFileClick={props.onFileClick} />
</Show>
</div>
</Show>
Expand Down
Loading