Skip to content

Commit 82ded12

Browse files
authored
Add pending scroll store and file link navigation in messages (#1445)
1 parent 21acb10 commit 82ded12

4 files changed

Lines changed: 209 additions & 4 deletions

File tree

apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Box, Flex, Text } from "@radix-ui/themes";
44
import { useEffect, useMemo } from "react";
55
import { useCodeMirror } from "../hooks/useCodeMirror";
66
import { useEditorExtensions } from "../hooks/useEditorExtensions";
7+
import { usePendingScrollStore } from "../stores/pendingScrollStore";
78

89
interface CodeMirrorEditorProps {
910
content: string;
@@ -24,6 +25,29 @@ export function CodeMirrorEditor({
2425
[content, extensions, filePath],
2526
);
2627
const { containerRef, instanceRef } = useCodeMirror(options);
28+
useEffect(() => {
29+
if (!filePath) return;
30+
const scrollToLine = () => {
31+
const line = usePendingScrollStore.getState().pendingLine[filePath];
32+
if (line === undefined) return;
33+
const view = instanceRef.current;
34+
if (!view) return;
35+
usePendingScrollStore.getState().consumeScroll(filePath);
36+
const lineCount = view.state.doc.lines;
37+
if (line < 1 || line > lineCount) return;
38+
const lineInfo = view.state.doc.line(line);
39+
view.dispatch({
40+
selection: { anchor: lineInfo.from },
41+
effects: EditorView.scrollIntoView(lineInfo.from, { y: "center" }),
42+
});
43+
};
44+
const rafId = requestAnimationFrame(scrollToLine);
45+
const unsub = usePendingScrollStore.subscribe(scrollToLine);
46+
return () => {
47+
cancelAnimationFrame(rafId);
48+
unsub();
49+
};
50+
}, [filePath, instanceRef]);
2751

2852
useEffect(() => {
2953
const handleKeyDown = (e: KeyboardEvent) => {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { create } from "zustand";
2+
3+
interface PendingScrollState {
4+
pendingLine: Record<string, number>;
5+
}
6+
7+
interface PendingScrollActions {
8+
requestScroll: (filePath: string, line: number) => void;
9+
consumeScroll: (filePath: string) => number | null;
10+
}
11+
12+
type PendingScrollStore = PendingScrollState & PendingScrollActions;
13+
14+
export const usePendingScrollStore = create<PendingScrollStore>()(
15+
(set, get) => ({
16+
pendingLine: {},
17+
18+
requestScroll: (filePath, line) =>
19+
set((s) => ({ pendingLine: { ...s.pendingLine, [filePath]: line } })),
20+
21+
consumeScroll: (filePath) => {
22+
const line = get().pendingLine[filePath] ?? null;
23+
if (line !== null) {
24+
set((s) => {
25+
const { [filePath]: _, ...rest } = s.pendingLine;
26+
return { pendingLine: rest };
27+
});
28+
}
29+
return line;
30+
},
31+
}),
32+
);

apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { PluggableList } from "unified";
1212
interface MarkdownRendererProps {
1313
content: string;
1414
remarkPluginsOverride?: PluggableList;
15+
componentsOverride?: Partial<Components>;
1516
}
1617

1718
// Preprocessor to prevent setext heading interpretation of horizontal rules
@@ -170,14 +171,22 @@ export const defaultRemarkPlugins = [remarkGfm];
170171
export const MarkdownRenderer = memo(function MarkdownRenderer({
171172
content,
172173
remarkPluginsOverride,
174+
componentsOverride,
173175
}: MarkdownRendererProps) {
174176
const processedContent = useMemo(
175177
() => preprocessMarkdown(content),
176178
[content],
177179
);
178180
const plugins = remarkPluginsOverride ?? defaultRemarkPlugins;
181+
const components = useMemo(
182+
() =>
183+
componentsOverride
184+
? { ...baseComponents, ...componentsOverride }
185+
: baseComponents,
186+
[componentsOverride],
187+
);
179188
return (
180-
<ReactMarkdown remarkPlugins={plugins} components={baseComponents}>
189+
<ReactMarkdown remarkPlugins={plugins} components={components}>
181190
{processedContent}
182191
</ReactMarkdown>
183192
);

apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,145 @@
1+
import { HighlightedCode } from "@components/HighlightedCode";
12
import { Tooltip } from "@components/ui/Tooltip";
3+
import { usePendingScrollStore } from "@features/code-editor/stores/pendingScrollStore";
24
import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer";
5+
import { usePanelLayoutStore } from "@features/panels";
6+
import { useCwd } from "@features/sidebar/hooks/useCwd";
7+
import { useTaskStore } from "@features/tasks/stores/taskStore";
8+
import type { FileItem } from "@hooks/useRepoFiles";
9+
import { useRepoFiles } from "@hooks/useRepoFiles";
310
import { Check, Copy } from "@phosphor-icons/react";
4-
import { Box, IconButton } from "@radix-ui/themes";
5-
import { memo, useCallback, useState } from "react";
11+
import { Box, Code, IconButton } from "@radix-ui/themes";
12+
import { memo, useCallback, useMemo, useState } from "react";
13+
import type { Components } from "react-markdown";
14+
15+
const FILE_WITH_DIR_RE =
16+
/^(?:\/|\.\.?\/|[a-zA-Z]:\\)?(?:[\w.@-]+\/)+[\w.@-]+\.\w+(?::\d+(?:-\d+)?)?$/;
17+
const BARE_FILE_RE = /^[\w.@-]+\.\w+(?::\d+(?:-\d+)?)?$/;
18+
19+
function hasDirectoryPath(text: string): boolean {
20+
return FILE_WITH_DIR_RE.test(text);
21+
}
22+
23+
function looksLikeBareFilename(text: string): boolean {
24+
return BARE_FILE_RE.test(text);
25+
}
26+
27+
function parseFilePath(text: string): { filePath: string; lineSuffix: string } {
28+
const match = text.match(/^(.+?)(?::(\d+(?:-\d+)?))?$/);
29+
if (!match) return { filePath: text, lineSuffix: "" };
30+
return { filePath: match[1], lineSuffix: match[2] ?? "" };
31+
}
32+
33+
function resolveFilename(filename: string, files: FileItem[]): FileItem | null {
34+
const matches = files.filter((f) => f.name === filename);
35+
if (matches.length === 1) return matches[0];
36+
return null;
37+
}
38+
39+
function InlineFileLink({
40+
text,
41+
resolvedPath,
42+
}: {
43+
text: string;
44+
resolvedPath?: string;
45+
}) {
46+
const { filePath: rawPath, lineSuffix } = parseFilePath(text);
47+
const filePath = resolvedPath ?? rawPath;
48+
const filename = rawPath.split("/").pop() ?? rawPath;
49+
const taskId = useTaskStore((s) => s.selectedTaskId);
50+
const repoPath = useCwd(taskId ?? "");
51+
const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit);
52+
const requestScroll = usePendingScrollStore((s) => s.requestScroll);
53+
54+
const handleClick = useCallback(() => {
55+
if (!taskId) return;
56+
const relativePath =
57+
repoPath && filePath.startsWith(`${repoPath}/`)
58+
? filePath.slice(repoPath.length + 1)
59+
: filePath;
60+
const absolutePath = repoPath
61+
? `${repoPath}/${relativePath}`
62+
: relativePath;
63+
if (lineSuffix) {
64+
const line = Number.parseInt(lineSuffix.split("-")[0], 10);
65+
if (line > 0) requestScroll(absolutePath, line);
66+
}
67+
openFileInSplit(taskId, relativePath, true);
68+
}, [taskId, filePath, lineSuffix, repoPath, openFileInSplit, requestScroll]);
69+
70+
const tooltipText = resolvedPath ?? text;
71+
72+
return (
73+
<Tooltip content={tooltipText}>
74+
<button
75+
type="button"
76+
onClick={taskId ? handleClick : undefined}
77+
disabled={!taskId}
78+
className={
79+
taskId ? "cursor-pointer underline-offset-2 hover:underline" : ""
80+
}
81+
style={{
82+
all: "unset",
83+
color: "var(--accent-11)",
84+
font: "inherit",
85+
display: "inline",
86+
}}
87+
>
88+
{filename}
89+
{lineSuffix ? `:${lineSuffix}` : ""}
90+
</button>
91+
</Tooltip>
92+
);
93+
}
94+
95+
function BareFileLink({ text }: { text: string }) {
96+
const { filePath: bareFilename } = parseFilePath(text);
97+
const taskId = useTaskStore((s) => s.selectedTaskId);
98+
const repoPath = useCwd(taskId ?? "");
99+
const { files } = useRepoFiles(repoPath ?? undefined);
100+
const resolved = useMemo(
101+
() => resolveFilename(bareFilename, files),
102+
[bareFilename, files],
103+
);
104+
105+
if (!resolved) {
106+
return (
107+
<Code size="1" variant="ghost" style={{ color: "var(--accent-11)" }}>
108+
{text}
109+
</Code>
110+
);
111+
}
112+
return <InlineFileLink text={text} resolvedPath={resolved.path} />;
113+
}
114+
115+
const agentComponents: Partial<Components> = {
116+
code: ({ children, className }) => {
117+
const langMatch = className?.match(/language-(\w+)/);
118+
if (langMatch) {
119+
return (
120+
<HighlightedCode
121+
code={String(children).replace(/\n$/, "")}
122+
language={langMatch[1]}
123+
/>
124+
);
125+
}
126+
127+
const text = String(children).replace(/\n$/, "");
128+
if (hasDirectoryPath(text)) {
129+
return <InlineFileLink text={text} />;
130+
}
131+
132+
if (looksLikeBareFilename(text)) {
133+
return <BareFileLink text={text} />;
134+
}
135+
136+
return (
137+
<Code size="1" variant="ghost" style={{ color: "var(--accent-11)" }}>
138+
{children}
139+
</Code>
140+
);
141+
},
142+
};
6143

7144
interface AgentMessageProps {
8145
content: string;
@@ -21,7 +158,10 @@ export const AgentMessage = memo(function AgentMessage({
21158

22159
return (
23160
<Box className="group/msg relative py-1 pl-3 [&>*:last-child]:mb-0">
24-
<MarkdownRenderer content={content} />
161+
<MarkdownRenderer
162+
content={content}
163+
componentsOverride={agentComponents}
164+
/>
25165
<Box className="absolute top-1 right-1 opacity-0 transition-opacity group-hover/msg:opacity-100">
26166
<Tooltip content={copied ? "Copied!" : "Copy message"}>
27167
<IconButton

0 commit comments

Comments
 (0)