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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { create } from "zustand";

interface PendingScrollState {
pendingLine: Record<string, number>;
}

interface PendingScrollActions {
requestScroll: (filePath: string, line: number) => void;
consumeScroll: (filePath: string) => number | null;
}

type PendingScrollStore = PendingScrollState & PendingScrollActions;

export const usePendingScrollStore = create<PendingScrollStore>()(
(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;
},
}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { PluggableList } from "unified";
interface MarkdownRendererProps {
content: string;
remarkPluginsOverride?: PluggableList;
componentsOverride?: Partial<Components>;
}

// Preprocessor to prevent setext heading interpretation of horizontal rules
Expand Down Expand Up @@ -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 (
<ReactMarkdown remarkPlugins={plugins} components={baseComponents}>
<ReactMarkdown remarkPlugins={plugins} components={components}>
{processedContent}
</ReactMarkdown>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip content={tooltipText}>
<button
type="button"
onClick={taskId ? handleClick : undefined}
disabled={!taskId}
className={
taskId ? "cursor-pointer underline-offset-2 hover:underline" : ""
}
style={{
all: "unset",
color: "var(--accent-11)",
font: "inherit",
display: "inline",
}}
>
{filename}
{lineSuffix ? `:${lineSuffix}` : ""}
</button>
</Tooltip>
);
}

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 (
<Code size="1" variant="ghost" style={{ color: "var(--accent-11)" }}>
{text}
</Code>
);
}
return <InlineFileLink text={text} resolvedPath={resolved.path} />;
}

const agentComponents: Partial<Components> = {
code: ({ children, className }) => {
const langMatch = className?.match(/language-(\w+)/);
if (langMatch) {
return (
<HighlightedCode
code={String(children).replace(/\n$/, "")}
language={langMatch[1]}
/>
);
}

const text = String(children).replace(/\n$/, "");
if (hasDirectoryPath(text)) {
return <InlineFileLink text={text} />;
}

if (looksLikeBareFilename(text)) {
return <BareFileLink text={text} />;
}

return (
<Code size="1" variant="ghost" style={{ color: "var(--accent-11)" }}>
{children}
</Code>
);
},
};

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

return (
<Box className="group/msg relative py-1 pl-3 [&>*:last-child]:mb-0">
<MarkdownRenderer content={content} />
<MarkdownRenderer
content={content}
componentsOverride={agentComponents}
/>
<Box className="absolute top-1 right-1 opacity-0 transition-opacity group-hover/msg:opacity-100">
<Tooltip content={copied ? "Copied!" : "Copy message"}>
<IconButton
Expand Down
Loading