From fcd2cc00e90bc1062cf8c61ab473bcc660e51a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 17 Jun 2026 08:08:44 +0100 Subject: [PATCH] fix: open markdown file links with line suffixes (#1404) --- .../shared/markdown-components.test.tsx | 22 +++++++++++++++++++ .../components/shared/markdown-components.tsx | 6 ++++- docs/specs/ui/comment-markdown.md | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx index 4b5a512b49..91c33be6d9 100644 --- a/apps/web/components/shared/markdown-components.test.tsx +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -108,6 +108,20 @@ describe("markdownComponents", () => { expect(openFile).toHaveBeenCalledWith("docs/specs/native/spec.md"); }); + it("opens absolute worktree file links with line suffixes in the editor", () => { + render( + + { + "[workflow](/root/.kandev/tasks/example/kandev/.github/workflows/opencode-code-review.yml:1)" + } + , + ); + + fireEvent.click(screen.getByRole("link", { name: "workflow" })); + + expect(openFile).toHaveBeenCalledWith(".github/workflows/opencode-code-review.yml"); + }); + it("opens relative file links in the editor", () => { render({"[plan.md](docs/specs/native/plan.md)"}); @@ -128,6 +142,14 @@ describe("markdownComponents", () => { expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md"); }); + it("opens repo-root file links with line and column suffixes in the editor", () => { + render({"[migration](/docs/nextjs-spa-migration.md:12:3)"}); + + fireEvent.click(screen.getByRole("link", { name: "migration" })); + + expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md"); + }); + it("opens repo-root file links when no worktree path is available", () => { appState.value.taskSessions.items = {}; diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index 8f643288bc..bd6ffbb486 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -80,9 +80,13 @@ function stripHashAndQuery(href: string): string { return href.split(/[?#]/, 1)[0] ?? ""; } +function stripSourceLocationSuffix(path: string): string { + return path.replace(/:\d+(?::\d+)?$/, ""); +} + function decodeHrefPath(href: string): string | null { try { - return decodeURIComponent(stripHashAndQuery(href)); + return stripSourceLocationSuffix(decodeURIComponent(stripHashAndQuery(href))); } catch { return null; } diff --git a/docs/specs/ui/comment-markdown.md b/docs/specs/ui/comment-markdown.md index 2b060f138b..fe15606a68 100644 --- a/docs/specs/ui/comment-markdown.md +++ b/docs/specs/ui/comment-markdown.md @@ -16,6 +16,7 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r - `react-markdown` with `remark-gfm` processes comment content. `rehype-sanitize` blocks raw HTML injection (already in dependencies). No `dangerouslySetInnerHTML`. - Fenced code blocks display syntax highlighting and a one-click copy button. - Bare URLs in comment text are auto-linked (handled by `remark-gfm` linkify). +- Markdown links to files in the active worktree open the in-app file editor; absolute worktree paths and repo-root paths may include `:line` or `:line:column` suffixes without navigating the browser away from the task. - Patterns matching `[A-Z]+-\d+` (e.g., `KAN-42`, `QA-7`) are rendered as links to `/office/tasks/`. The link uses the raw identifier as the URL segment; resolution to an internal task ID (if needed) is handled by the issue page. - The comment list in `TaskChat` uses `react-virtuoso` (already in dependencies). Only visible comments plus a small buffer are mounted. - Scroll position is preserved when new comments arrive if the user has scrolled away from the bottom. @@ -27,6 +28,7 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r - **GIVEN** a comment containing a fenced code block, **WHEN** rendered, **THEN** the block has syntax highlighting and a copy button that copies the block contents. - **GIVEN** a comment containing the text `see KAN-42 for context`, **WHEN** rendered, **THEN** `KAN-42` is a clickable link navigating to `/office/tasks/KAN-42`. - **GIVEN** a comment containing `https://example.com`, **WHEN** rendered, **THEN** the URL is a clickable hyperlink. +- **GIVEN** a comment contains a markdown link to `/root/.kandev/tasks/example/kandev/.github/workflows/build.yml:12`, **WHEN** the user clicks it while the active worktree is `/root/.kandev/tasks/example/kandev`, **THEN** the in-app editor opens `.github/workflows/build.yml` instead of navigating to that absolute URL. - **GIVEN** a thread with 200 comments, **WHEN** the user opens the issue, **THEN** fewer than 30 comment nodes are in the DOM at any time. - **GIVEN** the user has scrolled up to read earlier comments and a new comment arrives, **WHEN** the comment is appended, **THEN** the scroll position does not move. - **GIVEN** the user is at the bottom of the thread and a new comment arrives, **WHEN** the comment is appended, **THEN** the viewport auto-scrolls to show the new comment.