From ad9c375f7aa38fee9ed7a688e8c9ac3a675a63f7 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 2 Apr 2026 14:38:57 +0200 Subject: [PATCH] feat: Add pending scroll store and file link navigation in messages --- .../components/CodeMirrorEditor.tsx | 24 +++ .../code-editor/stores/pendingScrollStore.ts | 32 ++++ .../editor/components/MarkdownRenderer.tsx | 11 +- .../session-update/AgentMessage.tsx | 146 +++++++++++++++++- 4 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 apps/code/src/renderer/features/code-editor/stores/pendingScrollStore.ts diff --git a/apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx b/apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx index da3762faa..5a6652aa9 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx @@ -4,6 +4,7 @@ import { Box, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; import { useCodeMirror } from "../hooks/useCodeMirror"; import { useEditorExtensions } from "../hooks/useEditorExtensions"; +import { usePendingScrollStore } from "../stores/pendingScrollStore"; interface CodeMirrorEditorProps { content: string; @@ -24,6 +25,29 @@ export function CodeMirrorEditor({ [content, extensions, filePath], ); const { containerRef, instanceRef } = useCodeMirror(options); + useEffect(() => { + if (!filePath) return; + const scrollToLine = () => { + const line = usePendingScrollStore.getState().pendingLine[filePath]; + if (line === undefined) return; + const view = instanceRef.current; + if (!view) return; + usePendingScrollStore.getState().consumeScroll(filePath); + const lineCount = view.state.doc.lines; + if (line < 1 || line > lineCount) return; + const lineInfo = view.state.doc.line(line); + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: "center" }), + }); + }; + const rafId = requestAnimationFrame(scrollToLine); + const unsub = usePendingScrollStore.subscribe(scrollToLine); + return () => { + cancelAnimationFrame(rafId); + unsub(); + }; + }, [filePath, instanceRef]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/apps/code/src/renderer/features/code-editor/stores/pendingScrollStore.ts b/apps/code/src/renderer/features/code-editor/stores/pendingScrollStore.ts new file mode 100644 index 000000000..e2b00ed87 --- /dev/null +++ b/apps/code/src/renderer/features/code-editor/stores/pendingScrollStore.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; + +interface PendingScrollState { + pendingLine: Record; +} + +interface PendingScrollActions { + requestScroll: (filePath: string, line: number) => void; + consumeScroll: (filePath: string) => number | null; +} + +type PendingScrollStore = PendingScrollState & PendingScrollActions; + +export const usePendingScrollStore = create()( + (set, get) => ({ + pendingLine: {}, + + requestScroll: (filePath, line) => + set((s) => ({ pendingLine: { ...s.pendingLine, [filePath]: line } })), + + consumeScroll: (filePath) => { + const line = get().pendingLine[filePath] ?? null; + if (line !== null) { + set((s) => { + const { [filePath]: _, ...rest } = s.pendingLine; + return { pendingLine: rest }; + }); + } + return line; + }, + }), +); diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 0b47016e2..765d4392a 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -12,6 +12,7 @@ import type { PluggableList } from "unified"; interface MarkdownRendererProps { content: string; remarkPluginsOverride?: PluggableList; + componentsOverride?: Partial; } // Preprocessor to prevent setext heading interpretation of horizontal rules @@ -170,14 +171,22 @@ export const defaultRemarkPlugins = [remarkGfm]; export const MarkdownRenderer = memo(function MarkdownRenderer({ content, remarkPluginsOverride, + componentsOverride, }: MarkdownRendererProps) { const processedContent = useMemo( () => preprocessMarkdown(content), [content], ); const plugins = remarkPluginsOverride ?? defaultRemarkPlugins; + const components = useMemo( + () => + componentsOverride + ? { ...baseComponents, ...componentsOverride } + : baseComponents, + [componentsOverride], + ); return ( - + {processedContent} ); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx index b046deb41..d024e14d8 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx @@ -1,8 +1,145 @@ +import { HighlightedCode } from "@components/HighlightedCode"; import { Tooltip } from "@components/ui/Tooltip"; +import { usePendingScrollStore } from "@features/code-editor/stores/pendingScrollStore"; import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { usePanelLayoutStore } from "@features/panels"; +import { useCwd } from "@features/sidebar/hooks/useCwd"; +import { useTaskStore } from "@features/tasks/stores/taskStore"; +import type { FileItem } from "@hooks/useRepoFiles"; +import { useRepoFiles } from "@hooks/useRepoFiles"; import { Check, Copy } from "@phosphor-icons/react"; -import { Box, IconButton } from "@radix-ui/themes"; -import { memo, useCallback, useState } from "react"; +import { Box, Code, IconButton } from "@radix-ui/themes"; +import { memo, useCallback, useMemo, useState } from "react"; +import type { Components } from "react-markdown"; + +const FILE_WITH_DIR_RE = + /^(?:\/|\.\.?\/|[a-zA-Z]:\\)?(?:[\w.@-]+\/)+[\w.@-]+\.\w+(?::\d+(?:-\d+)?)?$/; +const BARE_FILE_RE = /^[\w.@-]+\.\w+(?::\d+(?:-\d+)?)?$/; + +function hasDirectoryPath(text: string): boolean { + return FILE_WITH_DIR_RE.test(text); +} + +function looksLikeBareFilename(text: string): boolean { + return BARE_FILE_RE.test(text); +} + +function parseFilePath(text: string): { filePath: string; lineSuffix: string } { + const match = text.match(/^(.+?)(?::(\d+(?:-\d+)?))?$/); + if (!match) return { filePath: text, lineSuffix: "" }; + return { filePath: match[1], lineSuffix: match[2] ?? "" }; +} + +function resolveFilename(filename: string, files: FileItem[]): FileItem | null { + const matches = files.filter((f) => f.name === filename); + if (matches.length === 1) return matches[0]; + return null; +} + +function InlineFileLink({ + text, + resolvedPath, +}: { + text: string; + resolvedPath?: string; +}) { + const { filePath: rawPath, lineSuffix } = parseFilePath(text); + const filePath = resolvedPath ?? rawPath; + const filename = rawPath.split("/").pop() ?? rawPath; + const taskId = useTaskStore((s) => s.selectedTaskId); + const repoPath = useCwd(taskId ?? ""); + const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); + const requestScroll = usePendingScrollStore((s) => s.requestScroll); + + const handleClick = useCallback(() => { + if (!taskId) return; + const relativePath = + repoPath && filePath.startsWith(`${repoPath}/`) + ? filePath.slice(repoPath.length + 1) + : filePath; + const absolutePath = repoPath + ? `${repoPath}/${relativePath}` + : relativePath; + if (lineSuffix) { + const line = Number.parseInt(lineSuffix.split("-")[0], 10); + if (line > 0) requestScroll(absolutePath, line); + } + openFileInSplit(taskId, relativePath, true); + }, [taskId, filePath, lineSuffix, repoPath, openFileInSplit, requestScroll]); + + const tooltipText = resolvedPath ?? text; + + return ( + + + + ); +} + +function BareFileLink({ text }: { text: string }) { + const { filePath: bareFilename } = parseFilePath(text); + const taskId = useTaskStore((s) => s.selectedTaskId); + const repoPath = useCwd(taskId ?? ""); + const { files } = useRepoFiles(repoPath ?? undefined); + const resolved = useMemo( + () => resolveFilename(bareFilename, files), + [bareFilename, files], + ); + + if (!resolved) { + return ( + + {text} + + ); + } + return ; +} + +const agentComponents: Partial = { + code: ({ children, className }) => { + const langMatch = className?.match(/language-(\w+)/); + if (langMatch) { + return ( + + ); + } + + const text = String(children).replace(/\n$/, ""); + if (hasDirectoryPath(text)) { + return ; + } + + if (looksLikeBareFilename(text)) { + return ; + } + + return ( + + {children} + + ); + }, +}; interface AgentMessageProps { content: string; @@ -21,7 +158,10 @@ export const AgentMessage = memo(function AgentMessage({ return ( - +