fix: open root-relative chat file links (#1400)

This commit is contained in:
Carlos Florêncio
2026-06-16 22:55:24 +01:00
committed by GitHub
parent 9702cffff5
commit b374b50c4c
6 changed files with 177 additions and 12 deletions
@@ -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>
</>