From b374b50c4c438cb7f3ae007b1ae87daeb32826b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 16 Jun 2026 22:55:24 +0100 Subject: [PATCH] fix: open root-relative chat file links (#1400) --- .../tasks/[id]/advanced-panels/chat-panel.tsx | 10 ++++ .../shared/markdown-components.test.tsx | 56 ++++++++++++++++++- .../components/shared/markdown-components.tsx | 33 +++++++++-- .../task/chat/message-renderer.test.tsx | 54 ++++++++++++++++++ .../components/task/chat/message-renderer.tsx | 8 ++- .../task/chat/messages/agent-plan-message.tsx | 28 ++++++++-- 6 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 apps/web/components/task/chat/message-renderer.test.tsx diff --git a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx index 6471b20773..4949426fb1 100644 --- a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx +++ b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx @@ -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; }) { 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} /> ))} @@ -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 && ( diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx index ab4e0fc1b2..4b5a512b49 100644 --- a/apps/web/components/shared/markdown-components.test.tsx +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -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, }, }, })); @@ -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({"[migration](/docs/nextjs-spa-migration.md)"}); + + 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({"[migration](/docs/nextjs-spa-migration.md)"}); + + 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({"[secret](/root/other-project/secret.md)"}); + + 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({"[schema](/workspace/sibling-project/schema.prisma)"}); + + fireEvent.click(screen.getByRole("link", { name: "schema" })); + + expect(openFile).not.toHaveBeenCalled(); + }); + + it("does not open Windows drive-letter absolute file links", () => { + render({"[hosts](/C:/Windows/System32/drivers/etc/hosts)"}); + + fireEvent.click(screen.getByRole("link", { name: "hosts" })); + + expect(openFile).not.toHaveBeenCalled(); + }); + it("does not treat bare domains as relative file links", () => { render({"[service](api.service.com)"}); diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index 47f8b38c91..8f643288bc 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -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(/^\.\//, ""); diff --git a/apps/web/components/task/chat/message-renderer.test.tsx b/apps/web/components/task/chat/message-renderer.test.tsx new file mode 100644 index 0000000000..ae7be14602 --- /dev/null +++ b/apps/web/components/task/chat/message-renderer.test.tsx @@ -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 { + 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( + , + ); + + fireEvent.click(screen.getByRole("link", { name: "AGENTS" })); + + expect(onOpenFile).toHaveBeenCalledWith("apps/web/AGENTS.md"); + expect(fallbackOpenFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 4b56f669b7..2fcef0e5fd 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -298,7 +298,13 @@ const adapters: MessageAdapter[] = [ }, { matches: (comment) => comment.type === "agent_plan", - render: (comment) => , + render: (comment, ctx) => ( + + ), }, { matches: (comment) => comment.type === "script_execution", diff --git a/apps/web/components/task/chat/messages/agent-plan-message.tsx b/apps/web/components/task/chat/messages/agent-plan-message.tsx index e7cd388f01..707cd1400a 100644 --- a/apps/web/components/task/chat/messages/agent-plan-message.tsx +++ b/apps/web/components/task/chat/messages/agent-plan-message.tsx @@ -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 (
*]:my-2 [&>p]:my-2 [&>ul]:my-2 [&>ol]:my-2 ${className ?? ""}`} > - +
); } -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 @@ -80,7 +100,7 @@ export const AgentPlanMessage = memo(function AgentPlanMessage({ comment }: { co {title} - +