fix: open root-relative chat file links (#1400)
This commit is contained in:
@@ -9,6 +9,7 @@ import { useSessionMessages } from "@/hooks/domains/session/use-session-messages
|
||||
import { useSession } from "@/hooks/domains/session/use-session";
|
||||
import { useSessionLaunch } from "@/hooks/domains/session/use-session-launch";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useFileEditors } from "@/hooks/use-file-editors";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
|
||||
import { MessageRenderer } from "@/components/task/chat/message-renderer";
|
||||
@@ -60,12 +61,16 @@ function MessageList({
|
||||
isLoading,
|
||||
taskId,
|
||||
sessionId,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
scrollRef,
|
||||
}: {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
taskId: string;
|
||||
sessionId: string | null;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
if (isLoading && messages.length === 0) {
|
||||
@@ -87,6 +92,8 @@ function MessageList({
|
||||
isTaskDescription={idx === 0 && msg.author_type === "user"}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId ?? undefined}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -134,6 +141,7 @@ export function AdvancedChatPanel({ taskId, sessionId, hideInput }: AdvancedChat
|
||||
|
||||
const { session } = useSession(sessionId);
|
||||
const { messages, isLoading } = useSessionMessages(sessionId);
|
||||
const { openFile } = useFileEditors();
|
||||
const agentProfiles = useAppStore((s) => s.agentProfiles.items ?? []);
|
||||
const defaultProfile = agentProfiles[0] ?? null;
|
||||
|
||||
@@ -197,6 +205,8 @@ export function AdvancedChatPanel({ taskId, sessionId, hideInput }: AdvancedChat
|
||||
isLoading={isLoading}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
worktreePath={session?.worktree_path}
|
||||
onOpenFile={openFile}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
{!hideInput && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const openFile = vi.hoisted(() => vi.fn());
|
||||
@@ -13,7 +13,7 @@ const appState = vi.hoisted(() => ({
|
||||
"session-1": {
|
||||
worktree_path: "/root/.kandev/tasks/example/kandev",
|
||||
},
|
||||
},
|
||||
} as Record<string, { worktree_path: string }>,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -62,7 +62,14 @@ function Markdown({ children }: { children: string }) {
|
||||
|
||||
describe("markdownComponents", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
openFile.mockClear();
|
||||
appState.value.tasks.activeSessionId = "session-1";
|
||||
appState.value.taskSessions.items = {
|
||||
"session-1": {
|
||||
worktree_path: "/root/.kandev/tasks/example/kandev",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("keeps mermaid keywords in inline code as inline code", () => {
|
||||
@@ -112,6 +119,51 @@ describe("markdownComponents", () => {
|
||||
expect(openFile).toHaveBeenCalledWith("docs/specs/native/plan.md");
|
||||
});
|
||||
|
||||
it("opens repo-root file links in the editor", () => {
|
||||
render(<Markdown>{"[migration](/docs/nextjs-spa-migration.md)"}</Markdown>);
|
||||
|
||||
const link = screen.getByRole("link", { name: "migration" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md");
|
||||
});
|
||||
|
||||
it("opens repo-root file links when no worktree path is available", () => {
|
||||
appState.value.taskSessions.items = {};
|
||||
|
||||
render(<Markdown>{"[migration](/docs/nextjs-spa-migration.md)"}</Markdown>);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "migration" }));
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md");
|
||||
});
|
||||
|
||||
it("does not open host-absolute file links outside the worktree", () => {
|
||||
render(<Markdown>{"[secret](/root/other-project/secret.md)"}</Markdown>);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "secret" }));
|
||||
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat sibling workspace paths as repo-root file links", () => {
|
||||
appState.value.taskSessions.items["session-1"].worktree_path = "/workspace/current-project";
|
||||
|
||||
render(<Markdown>{"[schema](/workspace/sibling-project/schema.prisma)"}</Markdown>);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "schema" }));
|
||||
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not open Windows drive-letter absolute file links", () => {
|
||||
render(<Markdown>{"[hosts](/C:/Windows/System32/drivers/etc/hosts)"}</Markdown>);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "hosts" }));
|
||||
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat bare domains as relative file links", () => {
|
||||
render(<Markdown>{"[service](api.service.com)"}</Markdown>);
|
||||
|
||||
|
||||
@@ -92,6 +92,33 @@ function hasParentTraversal(path: string): boolean {
|
||||
return path.split("/").includes("..");
|
||||
}
|
||||
|
||||
function looksLikeHostAbsolutePath(path: string): boolean {
|
||||
return /^\/(?:[A-Za-z]:|Users|home|root|tmp|var|etc|usr|opt|mnt|Volumes)\//i.test(path);
|
||||
}
|
||||
|
||||
function firstAbsoluteSegment(path: string): string | null {
|
||||
const first = path.replace(/^\/+/, "").split("/")[0];
|
||||
return first || null;
|
||||
}
|
||||
|
||||
function resolveAbsoluteMarkdownFileHref(path: string, worktreePath: string | null | undefined) {
|
||||
const normalizedRoot = worktreePath?.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
const normalizedPath = path.replace(/\\/g, "/");
|
||||
if (normalizedRoot && normalizedPath.startsWith(`${normalizedRoot}/`)) {
|
||||
const relativePath = normalizedPath.slice(normalizedRoot.length + 1);
|
||||
return looksLikeFilePath(relativePath) ? relativePath : null;
|
||||
}
|
||||
if (
|
||||
normalizedRoot &&
|
||||
firstAbsoluteSegment(normalizedPath) === firstAbsoluteSegment(normalizedRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (looksLikeHostAbsolutePath(normalizedPath)) return null;
|
||||
const rootRelativePath = normalizedPath.replace(/^\/+/, "");
|
||||
return looksLikeFilePath(rootRelativePath) ? rootRelativePath : null;
|
||||
}
|
||||
|
||||
function resolveMarkdownFileHref(
|
||||
href: string | undefined,
|
||||
worktreePath: string | null | undefined,
|
||||
@@ -102,11 +129,7 @@ function resolveMarkdownFileHref(
|
||||
if (!path || path.startsWith("~/") || hasParentTraversal(path)) return null;
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
const normalizedRoot = worktreePath?.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
const normalizedPath = path.replace(/\\/g, "/");
|
||||
if (!normalizedRoot || !normalizedPath.startsWith(`${normalizedRoot}/`)) return null;
|
||||
const relativePath = normalizedPath.slice(normalizedRoot.length + 1);
|
||||
return looksLikeFilePath(relativePath) ? relativePath : null;
|
||||
return resolveAbsoluteMarkdownFileHref(path, worktreePath);
|
||||
}
|
||||
|
||||
const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageRenderer } from "./message-renderer";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
|
||||
|
||||
const fallbackOpenFile = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/hooks/use-panel-actions", () => ({
|
||||
usePanelActions: () => ({ openFile: fallbackOpenFile }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: () => null,
|
||||
}));
|
||||
|
||||
function message(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: "msg-1",
|
||||
session_id: toSessionId("sess-1"),
|
||||
task_id: toTaskId("task-1"),
|
||||
author_type: "agent",
|
||||
type: "message",
|
||||
content: "hello",
|
||||
created_at: "2026-06-16T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MessageRenderer markdown file links", () => {
|
||||
afterEach(() => {
|
||||
fallbackOpenFile.mockClear();
|
||||
});
|
||||
|
||||
it("opens agent plan file links with the renderer file opener", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageRenderer
|
||||
comment={message({
|
||||
type: "agent_plan",
|
||||
content: "# Plan\n\nRead [AGENTS](/apps/web/AGENTS.md).",
|
||||
})}
|
||||
isTaskDescription={false}
|
||||
worktreePath="/workspace/kandev"
|
||||
onOpenFile={onOpenFile}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "AGENTS" }));
|
||||
|
||||
expect(onOpenFile).toHaveBeenCalledWith("apps/web/AGENTS.md");
|
||||
expect(fallbackOpenFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -298,7 +298,13 @@ const adapters: MessageAdapter[] = [
|
||||
},
|
||||
{
|
||||
matches: (comment) => comment.type === "agent_plan",
|
||||
render: (comment) => <AgentPlanMessage comment={comment} />,
|
||||
render: (comment, ctx) => (
|
||||
<AgentPlanMessage
|
||||
comment={comment}
|
||||
worktreePath={ctx.worktreePath}
|
||||
onOpenFile={ctx.onOpenFile}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
matches: (comment) => comment.type === "script_execution",
|
||||
|
||||
@@ -18,17 +18,35 @@ function parsePlanContent(text: string): { title: string; body: string } {
|
||||
return { title: title || "Agent Plan", body };
|
||||
}
|
||||
|
||||
function PlanMarkdownBody({ text, className }: { text: string; className?: string }) {
|
||||
function PlanMarkdownBody({
|
||||
text,
|
||||
className,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`markdown-body max-w-none text-sm [&>*]:my-2 [&>p]:my-2 [&>ul]:my-2 [&>ol]:my-2 ${className ?? ""}`}
|
||||
>
|
||||
<MemoizedMarkdown content={text} />
|
||||
<MemoizedMarkdown content={text} worktreePath={worktreePath} onOpenFile={onOpenFile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AgentPlanMessage = memo(function AgentPlanMessage({ comment }: { comment: Message }) {
|
||||
export const AgentPlanMessage = memo(function AgentPlanMessage({
|
||||
comment,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
}: {
|
||||
comment: Message;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const text = comment.content;
|
||||
@@ -69,6 +87,8 @@ export const AgentPlanMessage = memo(function AgentPlanMessage({ comment }: { co
|
||||
<PlanMarkdownBody
|
||||
text={body}
|
||||
className="text-foreground/80 [&_strong]:text-foreground"
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +100,7 @@ export const AgentPlanMessage = memo(function AgentPlanMessage({ comment }: { co
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PlanMarkdownBody text={body} />
|
||||
<PlanMarkdownBody text={body} worktreePath={worktreePath} onOpenFile={onOpenFile} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user