fix: open registered repository links in file viewer (#3400)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -14,6 +14,7 @@ import { useFileEditors } from "@/hooks/use-file-editors";
|
|||||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||||
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
|
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
|
||||||
import { MessageRenderer } from "@/components/task/chat/message-renderer";
|
import { MessageRenderer } from "@/components/task/chat/message-renderer";
|
||||||
|
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
|
||||||
import type { Message } from "@/lib/types/http";
|
import type { Message } from "@/lib/types/http";
|
||||||
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
@@ -213,16 +214,23 @@ export function AdvancedChatPanel({ taskId, sessionId, hideInput }: AdvancedChat
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<MessageList
|
<TaskMarkdownFileLinkProvider
|
||||||
messages={messages}
|
|
||||||
isLoading={isLoading}
|
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
worktreePath={getSessionWorkspacePath(session)}
|
worktreePath={getSessionWorkspacePath(session)}
|
||||||
onOpenFile={openFile}
|
onOpenFile={openFile}
|
||||||
activeTurnId={activeTurnId}
|
>
|
||||||
scrollRef={scrollRef}
|
<MessageList
|
||||||
/>
|
messages={messages}
|
||||||
|
isLoading={isLoading}
|
||||||
|
taskId={taskId}
|
||||||
|
sessionId={sessionId}
|
||||||
|
worktreePath={getSessionWorkspacePath(session)}
|
||||||
|
onOpenFile={openFile}
|
||||||
|
activeTurnId={activeTurnId}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</TaskMarkdownFileLinkProvider>
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
<ChatInput
|
<ChatInput
|
||||||
message={message}
|
message={message}
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ vi.mock("@/components/state-provider", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
MarkdownFileLinkContext,
|
||||||
MarkdownTaskContext,
|
MarkdownTaskContext,
|
||||||
markdownComponents,
|
markdownComponents,
|
||||||
normalizeMarkdown,
|
normalizeMarkdown,
|
||||||
remarkPlugins,
|
remarkPlugins,
|
||||||
|
type MarkdownFileLinkContextValue,
|
||||||
} from "./markdown-components";
|
} from "./markdown-components";
|
||||||
|
|
||||||
function renderMarkdown(source: string): string {
|
function renderMarkdown(source: string): string {
|
||||||
@@ -208,9 +210,11 @@ describe("markdownComponents", () => {
|
|||||||
it("does not open Windows drive-letter absolute file links", () => {
|
it("does not open Windows drive-letter absolute file links", () => {
|
||||||
render(<Markdown>{"[hosts](/C:/Windows/System32/drivers/etc/hosts)"}</Markdown>);
|
render(<Markdown>{"[hosts](/C:/Windows/System32/drivers/etc/hosts)"}</Markdown>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("link", { name: "hosts" }));
|
const link = screen.getByRole("link", { name: "hosts" });
|
||||||
|
fireEvent.click(link);
|
||||||
|
|
||||||
expect(openFile).not.toHaveBeenCalled();
|
expect(openFile).not.toHaveBeenCalled();
|
||||||
|
expect(link.getAttribute("aria-disabled")).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat bare domains as relative file links", () => {
|
it("does not treat bare domains as relative file links", () => {
|
||||||
@@ -225,6 +229,43 @@ describe("markdownComponents", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("markdownComponents registered source links", () => {
|
||||||
|
afterEach(resetMarkdownComponentTestState);
|
||||||
|
|
||||||
|
it("opens registered repository paths through the active task worktree", () => {
|
||||||
|
const fileLinkContext: MarkdownFileLinkContextValue = {
|
||||||
|
worktreePath: "/root/.kandev/tasks/example",
|
||||||
|
onOpenFile: openFile,
|
||||||
|
fileRootAliases: [
|
||||||
|
{
|
||||||
|
repositoryId: "repo-1",
|
||||||
|
sourceRoot: "/home/jcfs/kandev-plugins/project",
|
||||||
|
workspaceRelativeRoot: "kandev",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownFileLinkContext.Provider value={fileLinkContext}>
|
||||||
|
<Markdown>{"[bundle](/home/jcfs/kandev-plugins/project/ui/bundle.js:61)"}</Markdown>
|
||||||
|
</MarkdownFileLinkContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "bundle" }));
|
||||||
|
|
||||||
|
expect(openFile).toHaveBeenCalledWith("kandev/ui/bundle.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an unmatched host path inert", () => {
|
||||||
|
render(<Markdown>{"[secret](/home/other-project/secret.md)"}</Markdown>);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "secret" });
|
||||||
|
expect(fireEvent.click(link)).toBe(false);
|
||||||
|
expect(openFile).not.toHaveBeenCalled();
|
||||||
|
expect(link.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("markdownComponents workspace roots", () => {
|
describe("markdownComponents workspace roots", () => {
|
||||||
afterEach(resetMarkdownComponentTestState);
|
afterEach(resetMarkdownComponentTestState);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-ext
|
|||||||
import { usePanelActions } from "@/hooks/use-panel-actions";
|
import { usePanelActions } from "@/hooks/use-panel-actions";
|
||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
||||||
|
import {
|
||||||
|
resolveMarkdownFileTarget,
|
||||||
|
type MarkdownFileRootAlias,
|
||||||
|
} from "@/lib/markdown/file-link-target";
|
||||||
|
|
||||||
/** Shared remark plugins used by all markdown renderers */
|
/** Shared remark plugins used by all markdown renderers */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -56,6 +60,7 @@ type MarkdownCodeProps = {
|
|||||||
export type MarkdownFileLinkContextValue = {
|
export type MarkdownFileLinkContextValue = {
|
||||||
worktreePath?: string | null;
|
worktreePath?: string | null;
|
||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
|
fileRootAliases?: readonly MarkdownFileRootAlias[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MarkdownFileLinkContext = createContext<MarkdownFileLinkContextValue>({});
|
export const MarkdownFileLinkContext = createContext<MarkdownFileLinkContextValue>({});
|
||||||
@@ -65,89 +70,6 @@ function isBlockCode(rawContent: string, hasLanguage: boolean): boolean {
|
|||||||
return hasLanguage || rawContent.includes("\n");
|
return hasLanguage || rawContent.includes("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEB_TLD_EXTENSIONS = new Set(["ai", "app", "cloud", "co", "com", "dev", "io", "net", "org"]);
|
|
||||||
|
|
||||||
function looksLikeFilePath(path: string): boolean {
|
|
||||||
const lastSegment = path.split("/").pop() ?? "";
|
|
||||||
if (!lastSegment.includes(".") || path.endsWith("/")) return false;
|
|
||||||
const extension = lastSegment.split(".").pop() ?? "";
|
|
||||||
if (!/^[a-z0-9]{1,8}$/i.test(extension)) return false;
|
|
||||||
return !WEB_TLD_EXTENSIONS.has(extension.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExternalHref(href: string): boolean {
|
|
||||||
return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//");
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripHashAndQuery(href: string): string {
|
|
||||||
return href.split(/[?#]/, 1)[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip a trailing source-location / omp read selector so a file link resolves
|
|
||||||
// to the bare path: ":42", ":42:5", and omp ranges like ":16-20", ":50+150",
|
|
||||||
// ":5-16,960-973", ":2-4:raw", ":raw", ":conflicts".
|
|
||||||
function stripSourceLocationSuffix(path: string): string {
|
|
||||||
const part = String.raw`\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*|raw|conflicts`;
|
|
||||||
return path.replace(new RegExp(`:(?:${part})(?::(?:${part}))*$`), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeHrefPath(href: string): string | null {
|
|
||||||
try {
|
|
||||||
return stripSourceLocationSuffix(decodeURIComponent(stripHashAndQuery(href)));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
if (!href || href.startsWith("#") || isExternalHref(href)) return null;
|
|
||||||
|
|
||||||
const path = decodeHrefPath(href);
|
|
||||||
if (!path || path.startsWith("~/") || hasParentTraversal(path)) return null;
|
|
||||||
|
|
||||||
if (path.startsWith("/")) {
|
|
||||||
return resolveAbsoluteMarkdownFileHref(path, worktreePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
||||||
if (normalizedPath.startsWith("../")) return null;
|
|
||||||
return looksLikeFilePath(normalizedPath) ? normalizedPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MarkdownLinkProps = {
|
type MarkdownLinkProps = {
|
||||||
href?: string;
|
href?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -158,17 +80,24 @@ function MarkdownFileAnchor({
|
|||||||
children,
|
children,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
openFile,
|
openFile,
|
||||||
|
fileRootAliases,
|
||||||
}: MarkdownLinkProps & {
|
}: MarkdownLinkProps & {
|
||||||
worktreePath: string | null | undefined;
|
worktreePath: string | null | undefined;
|
||||||
openFile: (path: string) => void;
|
openFile: (path: string) => void;
|
||||||
|
fileRootAliases?: readonly MarkdownFileRootAlias[];
|
||||||
}) {
|
}) {
|
||||||
const filePath = resolveMarkdownFileHref(href, worktreePath);
|
const target = resolveMarkdownFileTarget(href, {
|
||||||
const isInternal = !!filePath || href?.startsWith("/") || href?.startsWith("#");
|
workspaceRoot: worktreePath,
|
||||||
|
fileRootAliases,
|
||||||
|
});
|
||||||
|
const filePath = target?.kind === "file" ? target.path : null;
|
||||||
|
const isBlocked = target?.kind === "blocked";
|
||||||
|
const isInternal = !!target || href?.startsWith("/") || href?.startsWith("#");
|
||||||
|
|
||||||
const handleClick = filePath
|
const handleClick = target
|
||||||
? (event: MouseEvent<HTMLAnchorElement>) => {
|
? (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openFile(filePath);
|
if (filePath) openFile(filePath);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -178,21 +107,34 @@ function MarkdownFileAnchor({
|
|||||||
target={isInternal ? "_self" : "_blank"}
|
target={isInternal ? "_self" : "_blank"}
|
||||||
rel={isInternal ? undefined : "noopener noreferrer"}
|
rel={isInternal ? undefined : "noopener noreferrer"}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
aria-disabled={isBlocked ? true : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownFallbackLink(props: MarkdownLinkProps) {
|
function MarkdownFallbackLink(
|
||||||
|
props: MarkdownLinkProps & {
|
||||||
|
worktreePath?: string | null;
|
||||||
|
fileRootAliases?: readonly MarkdownFileRootAlias[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
const { openFile } = usePanelActions();
|
const { openFile } = usePanelActions();
|
||||||
const worktreePath = useAppStore((state) => {
|
const activeWorktreePath = useAppStore((state) => {
|
||||||
const sessionId = state.tasks.activeSessionId;
|
const sessionId = state.tasks.activeSessionId;
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
return getSessionWorkspacePath(state.taskSessions.items[sessionId]);
|
return getSessionWorkspacePath(state.taskSessions.items[sessionId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MarkdownFileAnchor {...props} worktreePath={worktreePath} openFile={openFile} />;
|
return (
|
||||||
|
<MarkdownFileAnchor
|
||||||
|
{...props}
|
||||||
|
worktreePath={props.worktreePath ?? activeWorktreePath}
|
||||||
|
fileRootAliases={props.fileRootAliases}
|
||||||
|
openFile={openFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownLink(props: MarkdownLinkProps) {
|
function MarkdownLink(props: MarkdownLinkProps) {
|
||||||
@@ -202,11 +144,18 @@ function MarkdownLink(props: MarkdownLinkProps) {
|
|||||||
<MarkdownFileAnchor
|
<MarkdownFileAnchor
|
||||||
{...props}
|
{...props}
|
||||||
worktreePath={linkContext.worktreePath}
|
worktreePath={linkContext.worktreePath}
|
||||||
|
fileRootAliases={linkContext.fileRootAliases}
|
||||||
openFile={linkContext.onOpenFile}
|
openFile={linkContext.onOpenFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <MarkdownFallbackLink {...props} />;
|
return (
|
||||||
|
<MarkdownFallbackLink
|
||||||
|
{...props}
|
||||||
|
worktreePath={linkContext.worktreePath}
|
||||||
|
fileRootAliases={linkContext.fileRootAliases}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownCode({ className, children }: MarkdownCodeProps) {
|
function MarkdownCode({ className, children }: MarkdownCodeProps) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useContext, useMemo } from "react";
|
||||||
import ReactMarkdown, { type Components } from "react-markdown";
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
import {
|
import {
|
||||||
MarkdownFileLinkContext,
|
MarkdownFileLinkContext,
|
||||||
@@ -30,10 +30,26 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({
|
|||||||
content,
|
content,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
fileRootAliases,
|
||||||
components,
|
components,
|
||||||
taskId = null,
|
taskId = null,
|
||||||
}: MemoizedMarkdownProps) {
|
}: MemoizedMarkdownProps) {
|
||||||
const fileLinkContext = useMemo(() => ({ worktreePath, onOpenFile }), [worktreePath, onOpenFile]);
|
const inheritedContext = useContext(MarkdownFileLinkContext);
|
||||||
|
const fileLinkContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
worktreePath: worktreePath ?? inheritedContext.worktreePath,
|
||||||
|
onOpenFile: onOpenFile ?? inheritedContext.onOpenFile,
|
||||||
|
fileRootAliases: fileRootAliases ?? inheritedContext.fileRootAliases,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
fileRootAliases,
|
||||||
|
inheritedContext.fileRootAliases,
|
||||||
|
inheritedContext.onOpenFile,
|
||||||
|
inheritedContext.worktreePath,
|
||||||
|
onOpenFile,
|
||||||
|
worktreePath,
|
||||||
|
],
|
||||||
|
);
|
||||||
const resolvedComponents = components ?? markdownComponents;
|
const resolvedComponents = components ?? markdownComponents;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useContext, type ReactNode } from "react";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MarkdownFileLinkContext } from "./markdown-components";
|
||||||
|
|
||||||
|
const WORKSPACE_ID = vi.hoisted(() => "workspace-1");
|
||||||
|
const storeState = vi.hoisted(() => ({
|
||||||
|
workspaces: { activeId: WORKSPACE_ID as string | null },
|
||||||
|
taskSessions: {
|
||||||
|
items: {
|
||||||
|
"session-1": {
|
||||||
|
repository_id: "repo-1",
|
||||||
|
workspace_path: "/root/.kandev/tasks/example" as string | undefined,
|
||||||
|
worktree_path: "/root/.kandev/tasks/example/kandev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
repositories: {
|
||||||
|
itemsByWorkspaceId: {
|
||||||
|
[WORKSPACE_ID]: [
|
||||||
|
{ id: "repo-1", local_path: "/home/jcfs/projects/kandev" },
|
||||||
|
{ id: "repo-2", local_path: "/home/jcfs/projects/plugin" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
office: {
|
||||||
|
tasks: { items: [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const taskState = vi.hoisted(() => ({
|
||||||
|
workspaceId: WORKSPACE_ID as string | undefined,
|
||||||
|
repositoryIds: ["repo-1", "repo-2"],
|
||||||
|
}));
|
||||||
|
const sessionWorktreeState = vi.hoisted(() => ({
|
||||||
|
items: [
|
||||||
|
{ repositoryId: "repo-1", path: "/root/.kandev/tasks/example/kandev" },
|
||||||
|
{ repositoryId: "repo-2", path: "/root/.kandev/tasks/example/plugin" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/state-provider", () => ({
|
||||||
|
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-task", () => ({
|
||||||
|
useTask: () => ({
|
||||||
|
id: "task-1",
|
||||||
|
workspaceId: taskState.workspaceId,
|
||||||
|
repositoryId: taskState.repositoryIds[0],
|
||||||
|
repositories: taskState.repositoryIds.map((repository_id, position) => ({
|
||||||
|
repository_id,
|
||||||
|
position,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/domains/session/use-session-worktrees", () => ({
|
||||||
|
useSessionWorktrees: () => sessionWorktreeState.items,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/domains/workspace/use-repositories", () => ({
|
||||||
|
useRepositories: (workspaceId?: string | null) => ({
|
||||||
|
repositories:
|
||||||
|
workspaceId === WORKSPACE_ID ? storeState.repositories.itemsByWorkspaceId[WORKSPACE_ID] : [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TaskMarkdownFileLinkProvider } from "./task-markdown-file-link-provider";
|
||||||
|
|
||||||
|
function ContextProbe() {
|
||||||
|
const context = useContext(MarkdownFileLinkContext);
|
||||||
|
return (
|
||||||
|
<output data-testid="context" data-has-action={context.onOpenFile ? "true" : "false"}>
|
||||||
|
{JSON.stringify(context.fileRootAliases)}
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<TaskMarkdownFileLinkProvider taskId="task-1" sessionId="session-1">
|
||||||
|
{children}
|
||||||
|
</TaskMarkdownFileLinkProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TaskMarkdownFileLinkProvider", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
storeState.workspaces.activeId = WORKSPACE_ID;
|
||||||
|
storeState.taskSessions.items["session-1"].workspace_path = "/root/.kandev/tasks/example";
|
||||||
|
taskState.workspaceId = WORKSPACE_ID;
|
||||||
|
taskState.repositoryIds = ["repo-1", "repo-2"];
|
||||||
|
sessionWorktreeState.items = [
|
||||||
|
{ repositoryId: "repo-1", path: "/root/.kandev/tasks/example/kandev" },
|
||||||
|
{ repositoryId: "repo-2", path: "/root/.kandev/tasks/example/plugin" },
|
||||||
|
];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides repository aliases scoped to the rendered task and session", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<ContextProbe />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("context").textContent).toBe(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
repositoryId: "repo-1",
|
||||||
|
sourceRoot: "/home/jcfs/projects/kandev",
|
||||||
|
workspaceRelativeRoot: "kandev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repositoryId: "repo-2",
|
||||||
|
sourceRoot: "/home/jcfs/projects/plugin",
|
||||||
|
workspaceRelativeRoot: "plugin",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the existing context action and inherits it for nested renderers", () => {
|
||||||
|
const onOpenFile = vi.fn();
|
||||||
|
render(
|
||||||
|
<MarkdownFileLinkContext.Provider value={{ onOpenFile }}>
|
||||||
|
<TaskMarkdownFileLinkProvider taskId="task-1" sessionId="session-1">
|
||||||
|
<ContextProbe />
|
||||||
|
</TaskMarkdownFileLinkProvider>
|
||||||
|
</MarkdownFileLinkContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("context").dataset.hasAction).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the active workspace while the task projection omits its workspace id", () => {
|
||||||
|
taskState.workspaceId = undefined;
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<ContextProbe />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("context").textContent).toContain('"repositoryId":"repo-2"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the legacy session repository and worktree when no worktree list is available", () => {
|
||||||
|
taskState.repositoryIds = ["repo-1"];
|
||||||
|
sessionWorktreeState.items = [];
|
||||||
|
storeState.taskSessions.items["session-1"].workspace_path = undefined;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<ContextProbe />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("context").textContent).toBe(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
repositoryId: "repo-1",
|
||||||
|
sourceRoot: "/home/jcfs/projects/kandev",
|
||||||
|
workspaceRelativeRoot: "",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inherit aliases from an unrelated parent context", () => {
|
||||||
|
taskState.workspaceId = undefined;
|
||||||
|
taskState.repositoryIds = [];
|
||||||
|
storeState.workspaces.activeId = null;
|
||||||
|
render(
|
||||||
|
<MarkdownFileLinkContext.Provider
|
||||||
|
value={{
|
||||||
|
fileRootAliases: [
|
||||||
|
{
|
||||||
|
repositoryId: "unrelated-repo",
|
||||||
|
sourceRoot: "/home/other/project",
|
||||||
|
workspaceRelativeRoot: "project",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TaskMarkdownFileLinkProvider taskId="task-1" sessionId="session-1">
|
||||||
|
<ContextProbe />
|
||||||
|
</TaskMarkdownFileLinkProvider>
|
||||||
|
</MarkdownFileLinkContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("context").textContent).toBe("[]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useContext, useMemo, type ReactNode } from "react";
|
||||||
|
import { useAppStore } from "@/components/state-provider";
|
||||||
|
import { useTask } from "@/hooks/use-task";
|
||||||
|
import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees";
|
||||||
|
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||||
|
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
||||||
|
import {
|
||||||
|
buildMarkdownFileRootAliases,
|
||||||
|
type MarkdownAliasRepository,
|
||||||
|
type MarkdownAliasWorktree,
|
||||||
|
} from "@/lib/markdown/file-link-target";
|
||||||
|
import {
|
||||||
|
MarkdownFileLinkContext,
|
||||||
|
type MarkdownFileLinkContextValue,
|
||||||
|
} from "@/components/shared/markdown-components";
|
||||||
|
|
||||||
|
type TaskRepositoryLink = {
|
||||||
|
repository_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskLinkSource = {
|
||||||
|
workspaceId?: string | null;
|
||||||
|
repositories?: readonly TaskRepositoryLink[] | null;
|
||||||
|
repositoryId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacySessionWorktreeSource = {
|
||||||
|
repository_id?: string | null;
|
||||||
|
worktree_path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskMarkdownFileLinkProviderProps = {
|
||||||
|
taskId?: string | null;
|
||||||
|
sessionId?: string | null;
|
||||||
|
worktreePath?: string | null;
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectTaskRepositoryIds(sources: readonly (TaskLinkSource | null | undefined)[]) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const source of sources) {
|
||||||
|
if (source?.repositoryId) ids.add(source.repositoryId);
|
||||||
|
for (const repository of source?.repositories ?? []) {
|
||||||
|
if (repository.repository_id) ids.add(repository.repository_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAliasRepositories(
|
||||||
|
repositories: readonly { id: string; local_path?: string | null }[],
|
||||||
|
): MarkdownAliasRepository[] {
|
||||||
|
return repositories.map((repository) => ({
|
||||||
|
id: repository.id,
|
||||||
|
localPath: repository.local_path,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAliasWorktrees(
|
||||||
|
worktrees: readonly { repositoryId?: string; path?: string }[],
|
||||||
|
legacySession?: LegacySessionWorktreeSource | null,
|
||||||
|
): MarkdownAliasWorktree[] {
|
||||||
|
const aliases = worktrees.map((worktree) => ({
|
||||||
|
repositoryId: worktree.repositoryId,
|
||||||
|
path: worktree.path,
|
||||||
|
}));
|
||||||
|
if (aliases.length > 0 || !legacySession?.repository_id || !legacySession.worktree_path) {
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
repositoryId: legacySession.repository_id,
|
||||||
|
path: legacySession.worktree_path,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies one task/session-scoped Markdown file-link context to all message
|
||||||
|
* renderers in an interactive transcript.
|
||||||
|
*/
|
||||||
|
export function TaskMarkdownFileLinkProvider({
|
||||||
|
taskId,
|
||||||
|
sessionId,
|
||||||
|
worktreePath,
|
||||||
|
onOpenFile,
|
||||||
|
children,
|
||||||
|
}: TaskMarkdownFileLinkProviderProps) {
|
||||||
|
const task = useTask(taskId ?? null) as TaskLinkSource | null;
|
||||||
|
const officeTask = useAppStore((state) =>
|
||||||
|
taskId ? (state.office.tasks.items.find((item) => item.id === taskId) ?? null) : null,
|
||||||
|
) as TaskLinkSource | null;
|
||||||
|
const session = useAppStore((state) =>
|
||||||
|
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
|
||||||
|
);
|
||||||
|
const sessionWorktrees = useSessionWorktrees(sessionId ?? null);
|
||||||
|
const inheritedContext = useContext(MarkdownFileLinkContext);
|
||||||
|
|
||||||
|
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||||
|
const workspaceId = task?.workspaceId ?? officeTask?.workspaceId ?? activeWorkspaceId;
|
||||||
|
const taskRepositoryIds = useMemo(
|
||||||
|
() => collectTaskRepositoryIds([task, officeTask]),
|
||||||
|
[officeTask, task],
|
||||||
|
);
|
||||||
|
const { repositories: workspaceRepositories } = useRepositories(
|
||||||
|
workspaceId,
|
||||||
|
Boolean(workspaceId),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const effectiveWorkspacePath = worktreePath ?? getSessionWorkspacePath(session);
|
||||||
|
const fileRootAliases = useMemo(
|
||||||
|
() =>
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot: effectiveWorkspacePath,
|
||||||
|
taskRepositoryIds,
|
||||||
|
repositories: toAliasRepositories(workspaceRepositories),
|
||||||
|
sessionWorktrees: toAliasWorktrees(sessionWorktrees, session),
|
||||||
|
}),
|
||||||
|
[effectiveWorkspacePath, session, sessionWorktrees, taskRepositoryIds, workspaceRepositories],
|
||||||
|
);
|
||||||
|
const contextValue = useMemo<MarkdownFileLinkContextValue>(
|
||||||
|
() => ({
|
||||||
|
worktreePath: effectiveWorkspacePath ?? inheritedContext.worktreePath,
|
||||||
|
onOpenFile: onOpenFile ?? inheritedContext.onOpenFile,
|
||||||
|
fileRootAliases,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
effectiveWorkspacePath,
|
||||||
|
fileRootAliases,
|
||||||
|
inheritedContext.onOpenFile,
|
||||||
|
inheritedContext.worktreePath,
|
||||||
|
onOpenFile,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownFileLinkContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</MarkdownFileLinkContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-wi
|
|||||||
import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
|
import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
|
||||||
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
||||||
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
|
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
|
||||||
|
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
|
||||||
|
|
||||||
/** Returns a `clarificationKey` that increments each time a pending
|
/** Returns a `clarificationKey` that increments each time a pending
|
||||||
* clarification is resolved, letting the composer reset its input state for
|
* clarification is resolved, letting the composer reset its input state for
|
||||||
@@ -646,40 +647,47 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
|||||||
repositories={launchErrorContext.repositories}
|
repositories={launchErrorContext.repositories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageList
|
<TaskMarkdownFileLinkProvider
|
||||||
ref={messageListRef}
|
taskId={taskId}
|
||||||
items={groupedItems}
|
|
||||||
messages={allMessages}
|
|
||||||
footerActionMessages={footerActionMessages}
|
|
||||||
permissionsByToolCallId={permissionsByToolCallId}
|
|
||||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
|
||||||
taskId={taskId ?? undefined}
|
|
||||||
sessionId={resolvedSessionId}
|
sessionId={resolvedSessionId}
|
||||||
messagesLoading={messagesLoading}
|
|
||||||
historyRefreshPending={historyRefreshPending}
|
|
||||||
isWorking={isWorking}
|
|
||||||
sessionState={session?.state}
|
|
||||||
worktreePath={getSessionWorkspacePath(session)}
|
worktreePath={getSessionWorkspacePath(session)}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
dividerBeforeItemKey={dividerBeforeItemKey}
|
>
|
||||||
lastPromptMessageId={lastPromptMessageId}
|
<MessageList
|
||||||
onLastPromptEdgeChange={setLastPromptEdge}
|
ref={messageListRef}
|
||||||
firstMessageId={firstMessageId}
|
items={groupedItems}
|
||||||
onFirstMessageHiddenChange={setIsFirstMessageHidden}
|
messages={allMessages}
|
||||||
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
|
footerActionMessages={footerActionMessages}
|
||||||
isVisible={transcriptIsVisible}
|
permissionsByToolCallId={permissionsByToolCallId}
|
||||||
stickyPromptBar={
|
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||||
showAnchoredBar && lastPromptMessage ? (
|
taskId={taskId ?? undefined}
|
||||||
<AnchoredLastPromptBar
|
sessionId={resolvedSessionId}
|
||||||
promptText={lastPromptMessage.content}
|
messagesLoading={messagesLoading}
|
||||||
isVisible={anchoredBarVisible}
|
historyRefreshPending={historyRefreshPending}
|
||||||
onScrollUp={scrollToLastPrompt}
|
isWorking={isWorking}
|
||||||
showScrollToLastPrompt={showScrollToLastPrompt}
|
sessionState={session?.state}
|
||||||
onHeightChange={setAnchoredBarHeight}
|
worktreePath={getSessionWorkspacePath(session)}
|
||||||
/>
|
onOpenFile={onOpenFile}
|
||||||
) : undefined
|
dividerBeforeItemKey={dividerBeforeItemKey}
|
||||||
}
|
lastPromptMessageId={lastPromptMessageId}
|
||||||
/>
|
onLastPromptEdgeChange={setLastPromptEdge}
|
||||||
|
firstMessageId={firstMessageId}
|
||||||
|
onFirstMessageHiddenChange={setIsFirstMessageHidden}
|
||||||
|
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
|
||||||
|
isVisible={transcriptIsVisible}
|
||||||
|
stickyPromptBar={
|
||||||
|
showAnchoredBar && lastPromptMessage ? (
|
||||||
|
<AnchoredLastPromptBar
|
||||||
|
promptText={lastPromptMessage.content}
|
||||||
|
isVisible={anchoredBarVisible}
|
||||||
|
onScrollUp={scrollToLastPrompt}
|
||||||
|
showScrollToLastPrompt={showScrollToLastPrompt}
|
||||||
|
onHeightChange={setAnchoredBarHeight}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TaskMarkdownFileLinkProvider>
|
||||||
{isJumpLoading && (
|
{isJumpLoading && (
|
||||||
<div
|
<div
|
||||||
data-testid="transcript-jump-loading"
|
data-testid="transcript-jump-loading"
|
||||||
|
|||||||
@@ -303,9 +303,11 @@ test.describe("Attach local workspace sources", () => {
|
|||||||
if (!linkedRepoPath) throw new Error("attached repository worktree path was not returned");
|
if (!linkedRepoPath) throw new Error("attached repository worktree path was not returned");
|
||||||
const primaryFilePath = path.join(repoPaths[0], "changes/repository-0.txt");
|
const primaryFilePath = path.join(repoPaths[0], "changes/repository-0.txt");
|
||||||
const linkedFilePath = path.join(linkedRepoPath, "second-source.txt");
|
const linkedFilePath = path.join(linkedRepoPath, "second-source.txt");
|
||||||
|
const registeredSourceFilePath = path.join(repositoryPath, "second-source.txt");
|
||||||
|
fs.writeFileSync(linkedFilePath, "active worktree source\n");
|
||||||
await apiClient.seedSessionMessage(activeSessionId, {
|
await apiClient.seedSessionMessage(activeSessionId, {
|
||||||
type: "message",
|
type: "message",
|
||||||
content: `[primary source](${primaryFilePath}) [second source](${linkedFilePath})`,
|
content: `[registered source](${registeredSourceFilePath}:1) [primary source](${primaryFilePath}) [second source](${linkedFilePath})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await testPage.reload();
|
await testPage.reload();
|
||||||
@@ -313,17 +315,34 @@ test.describe("Attach local workspace sources", () => {
|
|||||||
await session.waitForChatIdle({ timeout: 30_000 });
|
await session.waitForChatIdle({ timeout: 30_000 });
|
||||||
await session.showSessionContext();
|
await session.showSessionContext();
|
||||||
const primaryChatLink = session.activeChat().getByRole("link", { name: "primary source" });
|
const primaryChatLink = session.activeChat().getByRole("link", { name: "primary source" });
|
||||||
|
const registeredChatLink = session
|
||||||
|
.activeChat()
|
||||||
|
.getByRole("link", { name: "registered source" });
|
||||||
const siblingChatLink = session.activeChat().getByRole("link", { name: "second source" });
|
const siblingChatLink = session.activeChat().getByRole("link", { name: "second source" });
|
||||||
|
await expect(registeredChatLink).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(primaryChatLink).toBeVisible({ timeout: 15_000 });
|
await expect(primaryChatLink).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(siblingChatLink).toBeVisible({ timeout: 15_000 });
|
await expect(siblingChatLink).toBeVisible({ timeout: 15_000 });
|
||||||
await primaryChatLink.click();
|
const taskUrlBeforeSourceLink = testPage.url();
|
||||||
|
await registeredChatLink.click();
|
||||||
const fileEditor = activeFileEditor(testPage);
|
const fileEditor = activeFileEditor(testPage);
|
||||||
await expect(fileEditor).toBeVisible({ timeout: 15_000 });
|
await expect(fileEditor).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(fileEditor.locator(".view-lines")).toContainText("active worktree source");
|
||||||
|
await expect(activeFileTab(testPage, "second-source.txt")).toBeVisible({ timeout: 15_000 });
|
||||||
|
expect(testPage.url()).toBe(taskUrlBeforeSourceLink);
|
||||||
|
await prCapture.screenshot("registered-source-file-opened-desktop", {
|
||||||
|
caption: "Desktop file tab showing content from the active task worktree",
|
||||||
|
});
|
||||||
|
await session.showSessionContext();
|
||||||
|
await prCapture.screenshot("registered-source-link-opened-desktop", {
|
||||||
|
caption: "Desktop task transcript link with the matching active-worktree file tab open",
|
||||||
|
});
|
||||||
|
await primaryChatLink.click();
|
||||||
|
await expect(fileEditor).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(fileEditor.locator(".view-lines")).toContainText("repository 0");
|
await expect(fileEditor.locator(".view-lines")).toContainText("repository 0");
|
||||||
await expect(activeFileTab(testPage, "repository-0.txt")).toBeVisible({ timeout: 15_000 });
|
await expect(activeFileTab(testPage, "repository-0.txt")).toBeVisible({ timeout: 15_000 });
|
||||||
await session.showSessionContext();
|
await session.showSessionContext();
|
||||||
await session.activeChat().getByRole("link", { name: "second source" }).click();
|
await session.activeChat().getByRole("link", { name: "second source" }).click();
|
||||||
await expect(fileEditor.locator(".view-lines")).toContainText("repository source");
|
await expect(fileEditor.locator(".view-lines")).toContainText("active worktree source");
|
||||||
await expect(activeFileTab(testPage, "second-source.txt")).toBeVisible({ timeout: 15_000 });
|
await expect(activeFileTab(testPage, "second-source.txt")).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -290,9 +290,12 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor
|
|||||||
.map((worktree) => worktree.worktree_path)
|
.map((worktree) => worktree.worktree_path)
|
||||||
.find((worktreePath) => worktreePath?.endsWith("mobile-local-repository-main"));
|
.find((worktreePath) => worktreePath?.endsWith("mobile-local-repository-main"));
|
||||||
expect(linkedRepoPath).toBeTruthy();
|
expect(linkedRepoPath).toBeTruthy();
|
||||||
|
const activeFilePath = path.join(linkedRepoPath!, "mobile-repository.txt");
|
||||||
|
fs.writeFileSync(activeFilePath, "active mobile worktree source\n");
|
||||||
|
const registeredSourceFilePath = path.join(repositoryPath, "mobile-repository.txt");
|
||||||
await apiClient.seedSessionMessage(activeSessionId!, {
|
await apiClient.seedSessionMessage(activeSessionId!, {
|
||||||
type: "message",
|
type: "message",
|
||||||
content: `[mobile source](${path.join(linkedRepoPath!, "mobile-repository.txt")})`,
|
content: `[mobile source](${registeredSourceFilePath}:1)`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await testPage.reload();
|
await testPage.reload();
|
||||||
@@ -304,7 +307,9 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor
|
|||||||
await chatLink.tap();
|
await chatLink.tap();
|
||||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||||
await expect(viewer).toBeVisible({ timeout: 15_000 });
|
await expect(viewer).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(viewer.locator(".cm-line").filter({ hasText: "repository source" })).toBeVisible();
|
await expect(
|
||||||
|
viewer.locator(".cm-line").filter({ hasText: "active mobile worktree source" }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(viewer.getByRole("button", { name: "Close" })).toBeVisible();
|
await expect(viewer.getByRole("button", { name: "Close" })).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
viewer.getByText("mobile-local-repository-main/mobile-repository.txt"),
|
viewer.getByText("mobile-local-repository-main/mobile-repository.txt"),
|
||||||
@@ -313,4 +318,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor
|
|||||||
"scrollWidth",
|
"scrollWidth",
|
||||||
await testPage.evaluate(() => innerWidth),
|
await testPage.evaluate(() => innerWidth),
|
||||||
);
|
);
|
||||||
|
await prCapture.screenshot("registered-source-link-opened-mobile", {
|
||||||
|
caption: "Pixel 5 file viewer showing content opened from a registered source-path link",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildMarkdownFileRootAliases,
|
||||||
|
resolveMarkdownFileTarget,
|
||||||
|
type MarkdownFileRootAlias,
|
||||||
|
} from "./file-link-target";
|
||||||
|
|
||||||
|
const workspaceRoot = "/root/.kandev/tasks/example";
|
||||||
|
const primaryRepositoryId = "repo-primary";
|
||||||
|
const siblingRepositoryId = "repo-sibling";
|
||||||
|
const primarySourceRoot = "/home/jcfs/projects/kandev";
|
||||||
|
const siblingSourceRoot = "/home/jcfs/projects/plugin";
|
||||||
|
const singleRepositoryId = "repo-1";
|
||||||
|
const singleSourceRoot = "/home/jcfs/projects/kandev";
|
||||||
|
const aliases: MarkdownFileRootAlias[] = [
|
||||||
|
{
|
||||||
|
repositoryId: primaryRepositoryId,
|
||||||
|
sourceRoot: primarySourceRoot,
|
||||||
|
workspaceRelativeRoot: "kandev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repositoryId: siblingRepositoryId,
|
||||||
|
sourceRoot: siblingSourceRoot,
|
||||||
|
workspaceRelativeRoot: "plugin",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("buildMarkdownFileRootAliases", () => {
|
||||||
|
it("maps task-linked repositories to their active worktree roots", () => {
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: [primaryRepositoryId, siblingRepositoryId],
|
||||||
|
repositories: [
|
||||||
|
{ id: primaryRepositoryId, localPath: primarySourceRoot },
|
||||||
|
{ id: siblingRepositoryId, localPath: siblingSourceRoot },
|
||||||
|
],
|
||||||
|
sessionWorktrees: [
|
||||||
|
{ repositoryId: primaryRepositoryId, path: `${workspaceRoot}/kandev` },
|
||||||
|
{ repositoryId: siblingRepositoryId, path: `${workspaceRoot}/plugin` },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
repositoryId: primaryRepositoryId,
|
||||||
|
sourceRoot: primarySourceRoot,
|
||||||
|
workspaceRelativeRoot: "kandev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repositoryId: siblingRepositoryId,
|
||||||
|
sourceRoot: siblingSourceRoot,
|
||||||
|
workspaceRelativeRoot: "plugin",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports a repository worktree that is the workspace root", () => {
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: [singleRepositoryId],
|
||||||
|
repositories: [{ id: singleRepositoryId, localPath: singleSourceRoot }],
|
||||||
|
sessionWorktrees: [{ repositoryId: singleRepositoryId, path: workspaceRoot }],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
repositoryId: singleRepositoryId,
|
||||||
|
sourceRoot: singleSourceRoot,
|
||||||
|
workspaceRelativeRoot: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not authorize session worktrees absent from the task repository set", () => {
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: [primaryRepositoryId],
|
||||||
|
repositories: [
|
||||||
|
{ id: primaryRepositoryId, localPath: primarySourceRoot },
|
||||||
|
{ id: siblingRepositoryId, localPath: siblingSourceRoot },
|
||||||
|
],
|
||||||
|
sessionWorktrees: [
|
||||||
|
{ repositoryId: primaryRepositoryId, path: `${workspaceRoot}/kandev` },
|
||||||
|
{ repositoryId: siblingRepositoryId, path: `${workspaceRoot}/plugin` },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
repositoryId: primaryRepositoryId,
|
||||||
|
sourceRoot: primarySourceRoot,
|
||||||
|
workspaceRelativeRoot: "kandev",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer repository aliases from session worktrees alone", () => {
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
repositories: [{ id: siblingRepositoryId, localPath: siblingSourceRoot }],
|
||||||
|
sessionWorktrees: [{ repositoryId: siblingRepositoryId, path: `${workspaceRoot}/plugin` }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildMarkdownFileRootAliases fail-closed cases", () => {
|
||||||
|
it("fails closed when repository identity or workspace containment is incomplete", () => {
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: [singleRepositoryId],
|
||||||
|
repositories: [{ id: singleRepositoryId, localPath: singleSourceRoot }],
|
||||||
|
sessionWorktrees: [{ repositoryId: "other-repo", path: `${workspaceRoot}/kandev` }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: ["repo-1"],
|
||||||
|
repositories: [{ id: "repo-1", localPath: "/home/jcfs/projects/kandev" }],
|
||||||
|
sessionWorktrees: [{ repositoryId: singleRepositoryId, path: "/tmp/kandev" }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits ambiguous source or worktree identities", () => {
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: [singleRepositoryId],
|
||||||
|
repositories: [
|
||||||
|
{ id: singleRepositoryId, localPath: singleSourceRoot },
|
||||||
|
{ id: singleRepositoryId, localPath: "/home/jcfs/other-kandev" },
|
||||||
|
],
|
||||||
|
sessionWorktrees: [{ repositoryId: singleRepositoryId, path: `${workspaceRoot}/kandev` }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds: [singleRepositoryId],
|
||||||
|
repositories: [{ id: singleRepositoryId, localPath: singleSourceRoot }],
|
||||||
|
sessionWorktrees: [
|
||||||
|
{ repositoryId: singleRepositoryId, path: `${workspaceRoot}/kandev` },
|
||||||
|
{ repositoryId: singleRepositoryId, path: `${workspaceRoot}/other` },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMarkdownFileTarget mapped paths", () => {
|
||||||
|
it("resolves active workspace paths before repository aliases", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(`${workspaceRoot}/kandev/docs/README.md:12:4`, {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "file", path: "kandev/docs/README.md" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps registered source paths to the active worktree", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(`${siblingSourceRoot}/ui/bundle.js:61`, {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "file", path: "plugin/ui/bundle.js" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Windows workspace and registered source paths case-insensitively", () => {
|
||||||
|
const windowsWorkspaceRoot = String.raw`C:\Users\Me\worktree`;
|
||||||
|
const windowsAliases: MarkdownFileRootAlias[] = [
|
||||||
|
{
|
||||||
|
repositoryId: siblingRepositoryId,
|
||||||
|
sourceRoot: String.raw`C:\Users\Me\source`,
|
||||||
|
workspaceRelativeRoot: "plugin",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(String.raw`c:\users\me\worktree\src\file.ts:4`, {
|
||||||
|
workspaceRoot: windowsWorkspaceRoot,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "file", path: "src/file.ts" });
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget("/c:/users/me/source/ui/bundle.js:61", {
|
||||||
|
workspaceRoot: windowsWorkspaceRoot,
|
||||||
|
fileRootAliases: windowsAliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "file", path: "plugin/ui/bundle.js" });
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(String.raw`C:\Windows\System32\kernel32.dll`, {
|
||||||
|
workspaceRoot: windowsWorkspaceRoot,
|
||||||
|
fileRootAliases: windowsAliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
expect(resolveMarkdownFileTarget("c://example.com/file.ts")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks contained paths that cannot be opened as files", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(`${workspaceRoot}/LICENSE`, {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(`${siblingSourceRoot}/Makefile`, {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMarkdownFileTarget safety boundaries", () => {
|
||||||
|
it("rejects traversal and ambiguous aliases", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(`${siblingSourceRoot}/../kandev/secret.md`, {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget(`${siblingSourceRoot}/ui/bundle.js`, {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: [
|
||||||
|
...aliases,
|
||||||
|
{
|
||||||
|
repositoryId: "repo-other",
|
||||||
|
sourceRoot: siblingSourceRoot,
|
||||||
|
workspaceRelativeRoot: "other",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies unmatched host paths as blocked and leaves web links alone", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget("/home/other-project/secret.md", {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileTarget("/home/other-project/%E0%A4%A", {
|
||||||
|
workspaceRoot,
|
||||||
|
fileRootAliases: aliases,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "blocked" });
|
||||||
|
expect(resolveMarkdownFileTarget("https://example.com/docs.md", { workspaceRoot })).toBeNull();
|
||||||
|
expect(resolveMarkdownFileTarget("/office/tasks/KAN-42", { workspaceRoot })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves relative and workspace-root file links", () => {
|
||||||
|
expect(resolveMarkdownFileTarget("docs/README.md", { workspaceRoot })).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "docs/README.md",
|
||||||
|
});
|
||||||
|
expect(resolveMarkdownFileTarget("/docs/README.md", { workspaceRoot })).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "docs/README.md",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
export type MarkdownFileRootAlias = {
|
||||||
|
repositoryId: string;
|
||||||
|
sourceRoot: string;
|
||||||
|
workspaceRelativeRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkdownAliasRepository = {
|
||||||
|
id: string;
|
||||||
|
localPath?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkdownAliasWorktree = {
|
||||||
|
repositoryId?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildMarkdownFileRootAliasesInput = {
|
||||||
|
workspaceRoot?: string | null;
|
||||||
|
taskRepositoryIds?: readonly string[];
|
||||||
|
repositories?: readonly MarkdownAliasRepository[];
|
||||||
|
sessionWorktrees?: readonly MarkdownAliasWorktree[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkdownFileTarget = { kind: "file"; path: string } | { kind: "blocked" };
|
||||||
|
|
||||||
|
export type ResolveMarkdownFileTargetOptions = {
|
||||||
|
workspaceRoot?: string | null;
|
||||||
|
fileRootAliases?: readonly MarkdownFileRootAlias[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEB_TLD_EXTENSIONS = new Set(["ai", "app", "cloud", "co", "com", "dev", "io", "net", "org"]);
|
||||||
|
|
||||||
|
function isWindowsDriveAbsolutePath(path: string): boolean {
|
||||||
|
return /^\/?[A-Za-z]:[\\/](?![\\/])/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
let normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||||
|
if (/^\/[A-Za-z]:\//.test(normalized)) normalized = normalized.slice(1);
|
||||||
|
if (normalized === "/" || /^[A-Za-z]:\/$/.test(normalized)) return normalized;
|
||||||
|
return normalized.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalPath(path: string | null | undefined): string | null {
|
||||||
|
if (!path?.trim()) return null;
|
||||||
|
const normalized = normalizePath(path.trim());
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(path: string, root: string): boolean {
|
||||||
|
const windowsPath = isWindowsDriveAbsolutePath(path);
|
||||||
|
const windowsRoot = isWindowsDriveAbsolutePath(root);
|
||||||
|
const comparablePath = windowsPath || windowsRoot ? path.toLowerCase() : path;
|
||||||
|
const comparableRoot = windowsPath || windowsRoot ? root.toLowerCase() : root;
|
||||||
|
if (comparableRoot === "/") return comparablePath.startsWith("/");
|
||||||
|
const rootPrefix = comparableRoot.endsWith("/") ? comparableRoot : `${comparableRoot}/`;
|
||||||
|
return comparablePath === comparableRoot || comparablePath.startsWith(rootPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePathFromRoot(path: string, root: string): string | null {
|
||||||
|
if (!isPathInside(path, root)) return null;
|
||||||
|
if (path.length === root.length) return "";
|
||||||
|
return path.slice(root.length + (root.endsWith("/") ? 0 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNormalizedPaths(paths: readonly (string | null | undefined)[]): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
paths.flatMap((path) => {
|
||||||
|
const normalized = normalizeOptionalPath(path);
|
||||||
|
return normalized ? [normalized] : [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds identity-qualified aliases from task-linked repositories and the
|
||||||
|
* worktrees materialized for the rendered session.
|
||||||
|
*/
|
||||||
|
export function buildMarkdownFileRootAliases({
|
||||||
|
workspaceRoot,
|
||||||
|
taskRepositoryIds = [],
|
||||||
|
repositories = [],
|
||||||
|
sessionWorktrees = [],
|
||||||
|
}: BuildMarkdownFileRootAliasesInput): MarkdownFileRootAlias[] {
|
||||||
|
const normalizedWorkspaceRoot = normalizeOptionalPath(workspaceRoot);
|
||||||
|
if (!normalizedWorkspaceRoot) return [];
|
||||||
|
|
||||||
|
const aliases: MarkdownFileRootAlias[] = [];
|
||||||
|
const repositoryIds = new Set(taskRepositoryIds.filter(Boolean));
|
||||||
|
for (const repositoryId of repositoryIds) {
|
||||||
|
const sourceRoots = uniqueNormalizedPaths(
|
||||||
|
repositories
|
||||||
|
.filter((repository) => repository.id === repositoryId)
|
||||||
|
.map((repository) => repository.localPath),
|
||||||
|
);
|
||||||
|
const worktreePaths = uniqueNormalizedPaths(
|
||||||
|
sessionWorktrees
|
||||||
|
.filter((worktree) => worktree.repositoryId === repositoryId)
|
||||||
|
.map((worktree) => worktree.path),
|
||||||
|
);
|
||||||
|
if (sourceRoots.length !== 1 || worktreePaths.length !== 1) continue;
|
||||||
|
|
||||||
|
const workspaceRelativeRoot = relativePathFromRoot(worktreePaths[0], normalizedWorkspaceRoot);
|
||||||
|
if (workspaceRelativeRoot === null) continue;
|
||||||
|
|
||||||
|
aliases.push({
|
||||||
|
repositoryId,
|
||||||
|
sourceRoot: sourceRoots[0],
|
||||||
|
workspaceRelativeRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeFilePath(path: string): boolean {
|
||||||
|
const lastSegment = path.split("/").pop() ?? "";
|
||||||
|
if (!lastSegment.includes(".") || path.endsWith("/")) return false;
|
||||||
|
const extension = lastSegment.split(".").pop() ?? "";
|
||||||
|
if (!/^[a-z0-9]{1,8}$/i.test(extension)) return false;
|
||||||
|
return !WEB_TLD_EXTENSIONS.has(extension.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHref(href: string): boolean {
|
||||||
|
return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteFileSystemPath(path: string): boolean {
|
||||||
|
return path.startsWith("/") || isWindowsDriveAbsolutePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHashAndQuery(href: string): string {
|
||||||
|
return href.split(/[?#]/, 1)[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSourceLocationSuffix(path: string): string {
|
||||||
|
const part = String.raw`\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*|raw|conflicts`;
|
||||||
|
return path.replace(new RegExp(`:(?:${part})(?::(?:${part}))*$`), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHrefPath(href: string): string | null {
|
||||||
|
try {
|
||||||
|
return stripSourceLocationSuffix(decodeURIComponent(stripHashAndQuery(href)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasParentTraversal(path: string): boolean {
|
||||||
|
return path.split("/").includes("..");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvalidFilePath(path: string): boolean {
|
||||||
|
return !path || path.startsWith("~/") || hasParentTraversal(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockedAbsolutePath(path: string): { kind: "blocked" } | null {
|
||||||
|
return isAbsoluteFileSystemPath(path) ? { kind: "blocked" } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHostAbsolutePath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
isWindowsDriveAbsolutePath(path) ||
|
||||||
|
/^\/(?: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 createFileTarget(path: string): MarkdownFileTarget | null {
|
||||||
|
return looksLikeFilePath(path) ? { kind: "file", path } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAliasTarget(
|
||||||
|
path: string,
|
||||||
|
aliases: readonly MarkdownFileRootAlias[],
|
||||||
|
): MarkdownFileTarget | null {
|
||||||
|
const matches = aliases.flatMap((alias) => {
|
||||||
|
const sourceRoot = normalizeOptionalPath(alias.sourceRoot);
|
||||||
|
if (!sourceRoot) return [];
|
||||||
|
const suffix = relativePathFromRoot(path, sourceRoot);
|
||||||
|
return suffix === null ? [] : [{ alias, sourceRoot, suffix }];
|
||||||
|
});
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const longestRootLength = Math.max(...matches.map(({ sourceRoot }) => sourceRoot.length));
|
||||||
|
const mostSpecific = matches.filter(({ sourceRoot }) => sourceRoot.length === longestRootLength);
|
||||||
|
const targetIdentities = new Set(
|
||||||
|
mostSpecific.map(
|
||||||
|
({ alias }) => `${alias.repositoryId}\u0000${normalizePath(alias.workspaceRelativeRoot)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (targetIdentities.size !== 1) return { kind: "blocked" };
|
||||||
|
|
||||||
|
const targetRoot = mostSpecific[0].alias.workspaceRelativeRoot
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^\/+|\/+$/g, "");
|
||||||
|
const suffix = mostSpecific[0].suffix;
|
||||||
|
const target = targetRoot && suffix ? `${targetRoot}/${suffix}` : targetRoot || suffix;
|
||||||
|
return createFileTarget(target) ?? { kind: "blocked" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAbsoluteTarget(
|
||||||
|
path: string,
|
||||||
|
workspaceRoot: string | null,
|
||||||
|
aliases: readonly MarkdownFileRootAlias[],
|
||||||
|
): MarkdownFileTarget | null {
|
||||||
|
const normalizedPath = normalizePath(path);
|
||||||
|
const normalizedWorkspaceRoot = normalizeOptionalPath(workspaceRoot);
|
||||||
|
|
||||||
|
if (normalizedWorkspaceRoot) {
|
||||||
|
const relativePath = relativePathFromRoot(normalizedPath, normalizedWorkspaceRoot);
|
||||||
|
if (relativePath !== null) {
|
||||||
|
return createFileTarget(relativePath) ?? { kind: "blocked" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliasTarget = resolveAliasTarget(normalizedPath, aliases);
|
||||||
|
if (aliasTarget) return aliasTarget;
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedWorkspaceRoot &&
|
||||||
|
firstAbsoluteSegment(normalizedPath) === firstAbsoluteSegment(normalizedWorkspaceRoot)
|
||||||
|
) {
|
||||||
|
return { kind: "blocked" };
|
||||||
|
}
|
||||||
|
if (looksLikeHostAbsolutePath(normalizedPath)) return { kind: "blocked" };
|
||||||
|
|
||||||
|
return createFileTarget(normalizedPath.replace(/^\/+/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedMarkdownFileHref =
|
||||||
|
| { kind: "path"; path: string; isAbsolute: boolean }
|
||||||
|
| { kind: "blocked" }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function parseMarkdownFileHref(href: string): ParsedMarkdownFileHref {
|
||||||
|
const decodedPath = decodeHrefPath(href);
|
||||||
|
if (!decodedPath) return blockedAbsolutePath(href);
|
||||||
|
|
||||||
|
const isWindowsPath = isWindowsDriveAbsolutePath(decodedPath);
|
||||||
|
if (!isWindowsPath && isExternalHref(href)) return null;
|
||||||
|
|
||||||
|
const path = normalizePath(decodedPath);
|
||||||
|
if (isInvalidFilePath(path)) return blockedAbsolutePath(path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "path",
|
||||||
|
path,
|
||||||
|
isAbsolute: isAbsoluteFileSystemPath(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves an href to a task-workspace-relative file target or a blocked host path. */
|
||||||
|
export function resolveMarkdownFileTarget(
|
||||||
|
href: string | undefined,
|
||||||
|
{ workspaceRoot, fileRootAliases = [] }: ResolveMarkdownFileTargetOptions = {},
|
||||||
|
): MarkdownFileTarget | null {
|
||||||
|
if (!href || href.startsWith("#")) return null;
|
||||||
|
|
||||||
|
const parsedHref = parseMarkdownFileHref(href);
|
||||||
|
if (!parsedHref || parsedHref.kind === "blocked") return parsedHref;
|
||||||
|
if (parsedHref.isAbsolute) {
|
||||||
|
return resolveAbsoluteTarget(parsedHref.path, workspaceRoot ?? null, fileRootAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = parsedHref.path.replace(/^\.\//, "");
|
||||||
|
if (normalizedPath.startsWith("../")) return null;
|
||||||
|
return createFileTarget(normalizedPath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-05
|
||||||
|
status: done
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-COMMENT-MARKDOWN-001
|
||||||
|
system_design:
|
||||||
|
- ../../specs/ui/system-design/comment-markdown.md
|
||||||
|
legacy_specs: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Plan: Registered Repository File Links
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Make Markdown links that cite a task-linked repository's registered source checkout open the
|
||||||
|
equivalent file from the active task workspace. The frontend will derive identity-qualified source
|
||||||
|
aliases once at the transcript boundary, resolve links through a pure fail-closed helper, and reuse
|
||||||
|
the existing desktop tab and phone viewer actions. One vertical work order keeps the resolver,
|
||||||
|
context wiring, and desktop/mobile regression evidence reviewable together.
|
||||||
|
|
||||||
|
## Confirmed root cause
|
||||||
|
|
||||||
|
`resolveAbsoluteMarkdownFileHref` in `components/shared/markdown-components.tsx` accepts an absolute
|
||||||
|
path only when it is beneath the supplied session `workspace_path` or legacy `worktree_path`. An
|
||||||
|
agent can instead cite the registered repository's canonical `local_path`, which differs whenever
|
||||||
|
Kandev created an isolated task worktree. The resolver then returns `null`; `MarkdownFileAnchor`
|
||||||
|
installs no click handler but retains the leading-slash `href`, so the browser resolves it against
|
||||||
|
the Kandev origin (for example, `https://kandev.nova/home/...`) instead of opening a file tab.
|
||||||
|
|
||||||
|
A throwaway Vitest case reproduced the report with a task worktree under `.kandev/tasks` and a link
|
||||||
|
to `/home/jcfs/kandev-plugins/.../ui/bundle.js:61`: the expected `openFile("ui/bundle.js")` call had
|
||||||
|
zero invocations. The throwaway test was removed, and the unchanged
|
||||||
|
`components/shared/markdown-components.test.tsx` baseline passes all 28 tests.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Match a task-linked `Repository.local_path` to the active session worktree with the same
|
||||||
|
`repository_id`.
|
||||||
|
- Convert the cited source-checkout suffix to the corresponding task-workspace-relative file path.
|
||||||
|
- Preserve current direct workspace/worktree, root-relative, URL, and supported source-selector
|
||||||
|
behavior.
|
||||||
|
- Keep unmatched host filesystem paths inside the current task without issuing a file request.
|
||||||
|
- Prove the mapped file opens through the existing desktop and phone file viewers.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Reading or editing the registered source checkout itself.
|
||||||
|
- Mapping repositories not linked to the rendered task or absent from the rendered session.
|
||||||
|
- Guessing repository identity from folder names, URL owners, or path suffixes.
|
||||||
|
- Turning unlinked plain text, inline code, directories, or `file://` URLs into file actions.
|
||||||
|
- Changing workspace-source materialization, file-service containment, editor layout, or mobile
|
||||||
|
composition.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
### Fail-closed file-link targets
|
||||||
|
|
||||||
|
- Add `apps/web/lib/markdown/file-link-target.ts` with pure types and functions for:
|
||||||
|
- building source-root aliases from the effective workspace root, task repository links,
|
||||||
|
workspace repository records, and identity-qualified session worktrees;
|
||||||
|
- normalizing Markdown hrefs and supported source-location suffixes;
|
||||||
|
- resolving direct workspace paths first and registered source aliases second; and
|
||||||
|
- classifying unmatched absolute host paths so the anchor can suppress accidental same-origin
|
||||||
|
navigation without treating them as workspace files.
|
||||||
|
- Require path-segment containment, reject traversal and malformed encoding, choose the
|
||||||
|
most-specific eligible source root, and omit ambiguous mappings.
|
||||||
|
- Return only task-workspace-relative targets. Do not pass a registered `local_path` to
|
||||||
|
`requestFileContent` or any editor action.
|
||||||
|
|
||||||
|
### Transcript-scoped repository aliases
|
||||||
|
|
||||||
|
- Add `apps/web/components/shared/task-markdown-file-link-provider.tsx` to derive one stable alias
|
||||||
|
set for a rendered task/session. Reuse `useTask`, `resolveSessionWorktrees` or
|
||||||
|
`useSessionWorktrees`, and `repositories.itemsByWorkspaceId`; correlate every source and target
|
||||||
|
by `repository_id`.
|
||||||
|
- Extend `MarkdownFileLinkContext` in
|
||||||
|
`apps/web/components/shared/markdown-components.tsx` with the derived aliases. Keep external URL
|
||||||
|
behavior unchanged, intercept resolved files, and prevent default navigation for unmatched
|
||||||
|
filesystem-shaped absolute paths.
|
||||||
|
- Update `apps/web/components/shared/memoized-markdown.tsx` to inherit aliases from the enclosing
|
||||||
|
transcript provider while retaining explicit per-renderer workspace-root and open-action
|
||||||
|
overrides. Keep the parsed-message memo boundary intact.
|
||||||
|
- Wrap interactive task transcript hosts with the provider, including
|
||||||
|
`apps/web/components/task/task-chat-panel.tsx` and
|
||||||
|
`apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx`. The provider must use the
|
||||||
|
rendered panel's task/session IDs, not global active-session state, so side-by-side panels cannot
|
||||||
|
cross-route links.
|
||||||
|
|
||||||
|
### Desktop and phone evidence
|
||||||
|
|
||||||
|
- Extend `apps/web/e2e/tests/task/add-workspace-sources.spec.ts` to seed a Markdown link using the
|
||||||
|
attached repository's original fixture path plus a source selector. Assert that the active
|
||||||
|
worktree file content and Dockview tab open and that the `/t/<task>` route does not change.
|
||||||
|
- Change or extend `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts` to tap the same
|
||||||
|
source-path form. Assert the existing `MobileFileViewerPanel` shows the active-worktree-relative
|
||||||
|
path/content and close control with no document horizontal overflow.
|
||||||
|
- No new mobile composition is needed. The existing inline link is the entry point, and the shipped
|
||||||
|
phone file viewer remains the single content/scroll surface.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Acceptance criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| `AC-UI-COMMENT-MARKDOWN-001.5` | Existing and extended direct workspace-link cases in `markdown-components.test.tsx` |
|
||||||
|
| `AC-UI-COMMENT-MARKDOWN-001.9` | New pure alias tests, shared Markdown click test, desktop E2E, and phone E2E |
|
||||||
|
| `AC-UI-COMMENT-MARKDOWN-001.10` | Pure mismatch/traversal tests and inert-anchor component test |
|
||||||
|
|
||||||
|
Unit coverage must include primary and sibling repositories, legacy single-worktree fallback,
|
||||||
|
source selectors, encoded paths, repository-ID mismatch, ambiguous aliases, parent traversal,
|
||||||
|
unmatched host paths, and unchanged external URLs.
|
||||||
|
|
||||||
|
## E2E tests
|
||||||
|
|
||||||
|
- `apps/web/e2e/tests/task/add-workspace-sources.spec.ts`, Chromium: a source-checkout absolute link
|
||||||
|
opens the matching active worktree file in a Dockview tab without route navigation.
|
||||||
|
- `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts`, `mobile-chrome`: tapping the same
|
||||||
|
source-path form opens the existing native phone file viewer with the expected active-worktree
|
||||||
|
content and containment.
|
||||||
|
|
||||||
|
## Work orders
|
||||||
|
|
||||||
|
- [x] [Task 01: Resolve registered repository links](task-01-resolve-registered-repository-links.md)
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
- Unit: four focused Vitest files passed, 50 tests total, including Windows drive paths, strict
|
||||||
|
task-repository membership, and contained non-openable targets.
|
||||||
|
- Static checks: targeted ESLint passed with `--max-warnings=0`; targeted Prettier checks passed;
|
||||||
|
`pnpm run typecheck` passed.
|
||||||
|
- Desktop: the attached-source Chromium scenario passed and proved a registered checkout link
|
||||||
|
opened the active worktree file/tab without changing the task URL.
|
||||||
|
- Mobile: the attached-source `mobile-chrome` scenario passed and proved the registered checkout
|
||||||
|
link opened active-worktree content in the native viewer.
|
||||||
|
- `git diff --check` passed.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- A task or repository cache can be briefly incomplete during hydration. Alias resolution must fail
|
||||||
|
closed and become actionable after authoritative state arrives without mutating message content.
|
||||||
|
- Multi-repository sessions can contain identical filenames and similar directory names. Repository
|
||||||
|
IDs and active worktree paths, never basenames, must select the target.
|
||||||
|
- Transcript messages are aggressively memoized. Context propagation must update links when alias
|
||||||
|
state changes without causing all messages to subscribe independently to repository state.
|
||||||
|
- The existing link resolver also distinguishes internal routes and external URLs. Filesystem-path
|
||||||
|
suppression must not intercept valid `/office/...`, HTTP(S), issue, or application links.
|
||||||
|
- E2E assertions must prove the file came from the active worktree rather than the original fixture
|
||||||
|
checkout.
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
id: "01-resolve-registered-repository-links"
|
||||||
|
title: "Resolve registered repository links"
|
||||||
|
status: done
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-COMMENT-MARKDOWN-001
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-UI-COMMENT-MARKDOWN-001.5
|
||||||
|
- AC-UI-COMMENT-MARKDOWN-001.9
|
||||||
|
- AC-UI-COMMENT-MARKDOWN-001.10
|
||||||
|
system_design:
|
||||||
|
- ../../specs/ui/system-design/comment-markdown.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 01: Resolve Registered Repository Links
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Resolve a task-linked repository's canonical source-checkout paths to the matching active session
|
||||||
|
worktree before opening Markdown file links. Preserve existing URL and direct-workspace behavior,
|
||||||
|
fail closed for unrelated host paths, and prove the shared desktop/phone outcome.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- Add and test the pure repository-identity alias and Markdown href resolver.
|
||||||
|
- Derive aliases once per interactive transcript and carry them through the shared Markdown context.
|
||||||
|
- Route eligible links to the existing active-workspace file action and suppress unmatched host-path
|
||||||
|
navigation.
|
||||||
|
- Extend the existing attached-source desktop and phone E2E scenarios.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Backend, persistence, file-service, workspace materialization, editor-layout, or localization
|
||||||
|
changes.
|
||||||
|
- Opening the original registered checkout or guessing mappings without matching repository IDs.
|
||||||
|
- New Markdown syntax or automatic linking of non-link text.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- A Markdown href beneath a task-linked repository's registered `local_path`, including a supported
|
||||||
|
source selector, resolves to the matching active worktree's task-workspace-relative file target.
|
||||||
|
- Direct workspace paths and external/internal web links keep their current behavior; mismatched,
|
||||||
|
ambiguous, traversing, or unrelated host paths produce no file request or task-route navigation.
|
||||||
|
- The existing desktop and `mobile-chrome` attached-source flows open content from the active
|
||||||
|
worktree through their native file surfaces.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
pnpm exec vitest run \
|
||||||
|
lib/markdown/file-link-target.test.ts \
|
||||||
|
components/shared/markdown-components.test.tsx \
|
||||||
|
components/shared/memoized-markdown.test.tsx \
|
||||||
|
components/shared/task-markdown-file-link-provider.test.tsx
|
||||||
|
pnpm exec eslint \
|
||||||
|
lib/markdown/file-link-target.ts \
|
||||||
|
lib/markdown/file-link-target.test.ts \
|
||||||
|
components/shared/markdown-components.tsx \
|
||||||
|
components/shared/markdown-components.test.tsx \
|
||||||
|
components/shared/memoized-markdown.tsx \
|
||||||
|
components/shared/memoized-markdown.test.tsx \
|
||||||
|
components/shared/task-markdown-file-link-provider.tsx \
|
||||||
|
components/shared/task-markdown-file-link-provider.test.tsx \
|
||||||
|
components/task/task-chat-panel.tsx \
|
||||||
|
'app/office/tasks/[id]/advanced-panels/chat-panel.tsx' \
|
||||||
|
e2e/tests/task/add-workspace-sources.spec.ts \
|
||||||
|
e2e/tests/task/mobile-add-workspace-sources.spec.ts \
|
||||||
|
--max-warnings=0
|
||||||
|
pnpm run typecheck
|
||||||
|
pnpm e2e:run --project chromium tests/task/add-workspace-sources.spec.ts -- --grep "adds a local repository and folder successively"
|
||||||
|
pnpm e2e:run --project mobile-chrome tests/task/mobile-add-workspace-sources.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `pnpm install --frozen-lockfile` from `apps/` first when workspace dependencies are absent.
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `apps/web/lib/markdown/file-link-target.ts`
|
||||||
|
- `apps/web/lib/markdown/file-link-target.test.ts`
|
||||||
|
- `apps/web/components/shared/markdown-components.tsx`
|
||||||
|
- `apps/web/components/shared/markdown-components.test.tsx`
|
||||||
|
- `apps/web/components/shared/memoized-markdown.tsx`
|
||||||
|
- `apps/web/components/shared/memoized-markdown.test.tsx`
|
||||||
|
- `apps/web/components/shared/task-markdown-file-link-provider.tsx`
|
||||||
|
- `apps/web/components/shared/task-markdown-file-link-provider.test.tsx`
|
||||||
|
- `apps/web/components/task/task-chat-panel.tsx`
|
||||||
|
- `apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx`
|
||||||
|
- `apps/web/e2e/tests/task/add-workspace-sources.spec.ts`
|
||||||
|
- `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Incorrect provider scoping can route a link through the globally active session instead of the
|
||||||
|
transcript's own session.
|
||||||
|
- Context changes can bypass the existing Markdown memo boundary or leave rendered links stale.
|
||||||
|
- Broad absolute-path detection can accidentally intercept legitimate internal application routes.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
`sequential`
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- `REQ-UI-COMMENT-MARKDOWN-001`, especially acceptance criteria 1.5, 1.9, and 1.10.
|
||||||
|
- `docs/specs/ui/system-design/comment-markdown.md` trusted-root and failure boundaries.
|
||||||
|
- Existing shared Markdown component tests and attached-source desktop/mobile E2E patterns.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
- Added identity-qualified repository source aliases and a fail-closed Markdown file-target
|
||||||
|
resolver. Direct workspace links, external URLs, source selectors, traversal rejection, and
|
||||||
|
unmatched host paths remain covered by focused unit tests.
|
||||||
|
- Hardened drive-letter normalization, task-membership filtering, and inert handling for contained
|
||||||
|
targets that the resolver does not recognize as openable files.
|
||||||
|
- Added transcript-scoped alias context for Kanban and Office chat hosts, including the active
|
||||||
|
workspace fallback used while task projections hydrate. Existing desktop and phone file-open
|
||||||
|
actions receive only task-workspace-relative paths.
|
||||||
|
- Extended the attached-source desktop and mobile E2E scenarios to cite the registered source
|
||||||
|
checkout and assert active-worktree content in the native file surfaces.
|
||||||
|
- Verification passed: 50 focused Vitest tests, targeted ESLint with zero warnings, Prettier
|
||||||
|
checks, TypeScript typecheck, Chromium desktop E2E, and `mobile-chrome` E2E.
|
||||||
@@ -148,6 +148,7 @@ UI owns responsive behavior; other systems own behavior/state.
|
|||||||
- [Composer Suggestion Overlays](system-design/composer-suggestion-overlays.md)
|
- [Composer Suggestion Overlays](system-design/composer-suggestion-overlays.md)
|
||||||
- [Compact step navigation](system-design/compact-workflow-step-navigation.md)
|
- [Compact step navigation](system-design/compact-workflow-step-navigation.md)
|
||||||
- [Kanban preview navigation](system-design/kanban-preview-workflow-step-navigation.md)
|
- [Kanban preview navigation](system-design/kanban-preview-workflow-step-navigation.md)
|
||||||
|
- [Comment Markdown Rendering](system-design/comment-markdown.md)
|
||||||
- [Workflow column visibility (Part 1)](system-design/board-step-visibility-filter-01.md)
|
- [Workflow column visibility (Part 1)](system-design/board-step-visibility-filter-01.md)
|
||||||
- [Workflow column visibility (Part 2)](system-design/board-step-visibility-filter-02.md)
|
- [Workflow column visibility (Part 2)](system-design/board-step-visibility-filter-02.md)
|
||||||
- [Task PR auto 1](system-design/ci-pr-automation-01.md)
|
- [Task PR auto 1](system-design/ci-pr-automation-01.md)
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r
|
|||||||
- **AC-UI-COMMENT-MARKDOWN-001.2:** `react-markdown` with `remark-gfm` processes comment content. `rehype-sanitize` blocks raw HTML injection (already in dependencies). No `dangerouslySetInnerHTML`.
|
- **AC-UI-COMMENT-MARKDOWN-001.2:** `react-markdown` with `remark-gfm` processes comment content. `rehype-sanitize` blocks raw HTML injection (already in dependencies). No `dangerouslySetInnerHTML`.
|
||||||
- **AC-UI-COMMENT-MARKDOWN-001.3:** Fenced code blocks display syntax highlighting and a one-click copy button.
|
- **AC-UI-COMMENT-MARKDOWN-001.3:** Fenced code blocks display syntax highlighting and a one-click copy button.
|
||||||
- **AC-UI-COMMENT-MARKDOWN-001.4:** Bare URLs in comment text are auto-linked (handled by `remark-gfm` linkify).
|
- **AC-UI-COMMENT-MARKDOWN-001.4:** Bare URLs in comment text are auto-linked (handled by `remark-gfm` linkify).
|
||||||
- **AC-UI-COMMENT-MARKDOWN-001.5:** 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.
|
- **AC-UI-COMMENT-MARKDOWN-001.5:** Markdown links to files in the active task workspace open the in-app file viewer; absolute workspace, worktree, and workspace-root-relative paths may include supported source-location suffixes without navigating the browser away from the task.
|
||||||
- **AC-UI-COMMENT-MARKDOWN-001.6:** Patterns matching `[A-Z]+-\d+` (e.g., `KAN-42`, `QA-7`) are rendered as links to `/office/tasks/<identifier>`. The link uses the raw identifier as the URL segment; resolution to an internal task ID (if needed) is handled by the issue page.
|
- **AC-UI-COMMENT-MARKDOWN-001.6:** Patterns matching `[A-Z]+-\d+` (e.g., `KAN-42`, `QA-7`) are rendered as links to `/office/tasks/<identifier>`. The link uses the raw identifier as the URL segment; resolution to an internal task ID (if needed) is handled by the issue page.
|
||||||
- **AC-UI-COMMENT-MARKDOWN-001.7:** The comment list in `TaskChat` renders the available comments in transcript order.
|
- **AC-UI-COMMENT-MARKDOWN-001.7:** The comment list in `TaskChat` renders the available comments in transcript order.
|
||||||
- **AC-UI-COMMENT-MARKDOWN-001.8:** Scroll position is preserved when new comments arrive if the user has scrolled away from the bottom.
|
- **AC-UI-COMMENT-MARKDOWN-001.8:** Scroll position is preserved when new comments arrive if the user has scrolled away from the bottom.
|
||||||
|
- **AC-UI-COMMENT-MARKDOWN-001.9:** When a task message links to an absolute file path beneath a repository checkout registered to that task, but the active session uses a different worktree path for the same repository, selecting the link opens the corresponding file from the active task workspace on desktop and phone. Kandev does not read or edit the registered source checkout.
|
||||||
|
- **AC-UI-COMMENT-MARKDOWN-001.10:** When an absolute host file path cannot be mapped to the active task workspace or to a registered repository represented by that workspace, selecting it does not navigate the current Kandev tab or issue a workspace file request.
|
||||||
|
|
||||||
## Migrated source detail
|
## Migrated source detail
|
||||||
|
|
||||||
@@ -40,7 +42,8 @@ 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`.
|
- `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.
|
- 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).
|
- 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.
|
- Markdown links to files in the active task workspace open the in-app file viewer; absolute workspace, worktree, and workspace-root-relative paths may include supported source-location suffixes without navigating the browser away from the task.
|
||||||
|
- Absolute paths beneath a task-linked repository's registered source checkout map by repository identity to the corresponding active worktree. The registered checkout itself is never read or edited.
|
||||||
- Patterns matching `[A-Z]+-\d+` (e.g., `KAN-42`, `QA-7`) are rendered as links to `/office/tasks/<identifier>`. The link uses the raw identifier as the URL segment; resolution to an internal task ID (if needed) is handled by the issue page.
|
- Patterns matching `[A-Z]+-\d+` (e.g., `KAN-42`, `QA-7`) are rendered as links to `/office/tasks/<identifier>`. 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` renders the available comments in transcript order.
|
- The comment list in `TaskChat` renders the available comments in transcript order.
|
||||||
- Scroll position is preserved when new comments arrive if the user has scrolled away from the bottom.
|
- Scroll position is preserved when new comments arrive if the user has scrolled away from the bottom.
|
||||||
@@ -53,6 +56,8 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r
|
|||||||
- **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 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 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 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 task uses a worktree for a repository registered at `/home/user/projects/example`, **WHEN** a message links to `/home/user/projects/example/ui/bundle.js:61`, **THEN** Kandev opens the active worktree's `ui/bundle.js` in the desktop file tab or phone file viewer without navigating to a same-origin `/home/user/projects/...` URL.
|
||||||
|
- **GIVEN** a message links to an absolute host path that is neither in the active task workspace nor under a registered repository represented by that workspace, **WHEN** the user selects it, **THEN** Kandev leaves the task visible and does not request that path from the workspace file service.
|
||||||
- **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 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.
|
- **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.
|
||||||
|
|
||||||
@@ -60,6 +65,7 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r
|
|||||||
|
|
||||||
- Editing or previewing markdown in the comment input box (input remains plain text).
|
- Editing or previewing markdown in the comment input box (input remains plain text).
|
||||||
- Server-side markdown storage or transformation — content is stored as raw text and rendered client-side only.
|
- Server-side markdown storage or transformation — content is stored as raw text and rendered client-side only.
|
||||||
|
- Reading or editing a registered repository's source checkout; source-path links always resolve to the active task workspace copy.
|
||||||
- Resolving task identifier links to internal UUIDs before navigation (the issue page handles that).
|
- Resolving task identifier links to internal UUIDs before navigation (the issue page handles that).
|
||||||
- Emoji shortcode rendering (`:+1:` etc.) — `remark-gemoji` is present in dependencies but not required by this spec.
|
- Emoji shortcode rendering (`:+1:` etc.) — `remark-gemoji` is present in dependencies but not required by this spec.
|
||||||
- Notifications or unread indicators for new comments.
|
- Notifications or unread indicators for new comments.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
status: current
|
||||||
|
system: ui
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-COMMENT-MARKDOWN-001
|
||||||
|
---
|
||||||
|
|
||||||
|
# Comment Markdown Rendering System Design
|
||||||
|
|
||||||
|
## Purpose and boundaries
|
||||||
|
|
||||||
|
UI owns the reusable interaction contract that distinguishes web links from file references in
|
||||||
|
rendered task messages and routes authorized file references into the active desktop or phone file
|
||||||
|
viewer. The task system remains authoritative for task-repository membership, session worktrees,
|
||||||
|
and the effective `workspace_path`; the workspace file service remains authoritative for final
|
||||||
|
filesystem containment and file reads.
|
||||||
|
|
||||||
|
An absolute path beneath a registered repository's source checkout is an alias for the matching
|
||||||
|
file in the active task worktree. It is never authority to read or edit the source checkout. This
|
||||||
|
design extends the task-workspace behavior in
|
||||||
|
[Attach Workspace Sources](../../tasks/system-design/attach-workspace-sources.md) without changing
|
||||||
|
its persistence, materialization, or file-service contracts.
|
||||||
|
|
||||||
|
## Requirement mapping
|
||||||
|
|
||||||
|
| Requirement | Design sections |
|
||||||
|
| --- | --- |
|
||||||
|
| `REQ-UI-COMMENT-MARKDOWN-001` | [Trusted file roots](#trusted-file-roots), [Link resolution](#link-resolution), [Responsive behavior](#responsive-behavior), [Failure behavior](#failure-behavior) |
|
||||||
|
|
||||||
|
## Components and responsibilities
|
||||||
|
|
||||||
|
- `TaskChatPanel` is the task/session boundary. It supplies the shared Markdown renderer with the
|
||||||
|
active session's effective workspace root, file-open action, and repository-source aliases.
|
||||||
|
- `resolveSessionWorktrees` combines restored session worktrees with live worktree state. Alias
|
||||||
|
construction uses these repository-qualified worktrees rather than directory-name inference.
|
||||||
|
- `repositories.itemsByWorkspaceId` supplies each registered repository's canonical `local_path`.
|
||||||
|
The active task's repository links restrict which workspace repositories can become aliases.
|
||||||
|
- `MarkdownFileLinkContext` carries one stable file-link context through the transcript so message
|
||||||
|
renderers do not independently subscribe to repository and worktree state.
|
||||||
|
- `MemoizedMarkdown` preserves an inherited task-level file-link context while allowing an explicit
|
||||||
|
message renderer to override the workspace root or open action.
|
||||||
|
- The shared Markdown anchor resolver classifies and normalizes a link, produces a task-workspace-
|
||||||
|
relative file target when authorized, and calls the existing `onOpenFile` action after preventing
|
||||||
|
browser navigation.
|
||||||
|
- Existing desktop `useFileEditors` and phone `MobileFileViewerPanel` flows fetch and display the
|
||||||
|
resolved task-workspace file. They do not receive the registered checkout path.
|
||||||
|
|
||||||
|
## Trusted file roots
|
||||||
|
|
||||||
|
The task chat boundary builds repository-source aliases from authoritative identities already in
|
||||||
|
frontend state. An alias has this logical shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
repository_id
|
||||||
|
source_root canonical Repository.local_path
|
||||||
|
workspace_relative_root matching session worktree path relative to workspace_path
|
||||||
|
```
|
||||||
|
|
||||||
|
An alias is eligible only when all of these conditions hold:
|
||||||
|
|
||||||
|
1. The repository is linked to the rendered message's task.
|
||||||
|
2. A worktree in the rendered message's session has the same `repository_id`.
|
||||||
|
3. The repository has a non-empty canonical `local_path`.
|
||||||
|
4. The matched worktree path equals the effective workspace root or is contained beneath it on a
|
||||||
|
path-segment boundary.
|
||||||
|
|
||||||
|
For a legacy single-worktree session that has no `worktrees` collection, the session's
|
||||||
|
`repository_id` and `worktree_path` provide the same identity-qualified fallback. An empty,
|
||||||
|
ambiguous, stale, or cross-task association produces no alias. Nested source roots are evaluated
|
||||||
|
most-specific-first and must match on a complete path segment.
|
||||||
|
|
||||||
|
The effective workspace root remains `workspace_path`, falling back to `worktree_path` for legacy
|
||||||
|
session projections. Repository aliases supplement that runtime root; they never replace it.
|
||||||
|
|
||||||
|
## Link resolution
|
||||||
|
|
||||||
|
The shared resolver performs these steps in order:
|
||||||
|
|
||||||
|
1. Preserve fragment-only links and links with an explicit web or application URL scheme as normal
|
||||||
|
navigation targets.
|
||||||
|
2. Decode the path once, remove query/fragment data used only by Markdown navigation, normalize
|
||||||
|
supported path separators, strip a supported trailing source-location selector, and reject
|
||||||
|
malformed encoding, home-relative syntax, or parent traversal. Windows drive roots accept an
|
||||||
|
optional leading slash and use case-insensitive containment after normalization.
|
||||||
|
3. Resolve an absolute path already beneath the effective workspace root directly to a
|
||||||
|
workspace-relative target. This remains the preferred path for single- and multi-repository
|
||||||
|
tasks.
|
||||||
|
4. Otherwise, match the absolute path against an eligible registered source root. Replace only
|
||||||
|
that root with its `workspace_relative_root`, append the file suffix, and return the resulting
|
||||||
|
workspace-relative target.
|
||||||
|
5. Apply the existing file-shape check and invoke the current file-open action. The source checkout
|
||||||
|
path never crosses the file-open boundary.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source_root: /home/user/projects/example
|
||||||
|
active workspace root: /home/user/.kandev/tasks/task-a
|
||||||
|
active worktree: /home/user/.kandev/tasks/task-a/example
|
||||||
|
message href: /home/user/projects/example/ui/bundle.js:61
|
||||||
|
file-open target: example/ui/bundle.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The mapping is based on `repository_id`, not a shared basename such as `example`. This prevents two
|
||||||
|
same-named repositories or an unrelated sibling directory from becoming accidental authority.
|
||||||
|
|
||||||
|
## Failure behavior
|
||||||
|
|
||||||
|
- An absolute host path that matches neither the active workspace root nor an eligible repository
|
||||||
|
alias is non-actionable. Its anchor prevents same-origin `/home/...`, `/Users/...`, drive-path,
|
||||||
|
or equivalent filesystem navigation and does not call the workspace file service.
|
||||||
|
- An absolute path contained by a trusted root but not recognized as an openable file is also
|
||||||
|
non-actionable, so a directory or extensionless target cannot fall through to browser navigation.
|
||||||
|
- A missing repository cache, worktree projection, task link, or identity match fails closed. A
|
||||||
|
later state update can make the link actionable without rewriting stored message content.
|
||||||
|
- External HTTP, HTTPS, mail, issue, and application links keep their existing navigation behavior.
|
||||||
|
- If a resolved task-workspace file no longer exists, the existing desktop or phone file-open flow
|
||||||
|
surfaces its localized failure feedback. Resolution does not fall back to the registered source
|
||||||
|
checkout.
|
||||||
|
- Backend path containment and symlink checks remain the final authorization boundary even after a
|
||||||
|
frontend alias has produced a workspace-relative target.
|
||||||
|
|
||||||
|
## Responsive behavior
|
||||||
|
|
||||||
|
This change does not alter chat composition, link styling, scroll ownership, navigation, or touch
|
||||||
|
geometry. The inline Markdown link remains the entry point at every viewport.
|
||||||
|
|
||||||
|
On desktop and compact desktop, selecting a resolved link opens or focuses the existing Dockview
|
||||||
|
file tab. On phone, the same semantic action switches to the existing Files surface and renders the
|
||||||
|
file in `MobileFileViewerPanel`; back/close behavior, the single viewer scroll owner, dynamic
|
||||||
|
viewport handling, and safe-area behavior remain unchanged. The nearest shipped phone exemplar is
|
||||||
|
the absolute attached-worktree link flow in `mobile-add-workspace-sources.spec.ts`, which already
|
||||||
|
proves the native file viewer rather than a compressed desktop editor.
|
||||||
|
|
||||||
|
Because the repair changes target normalization rather than layout or controls, no new mobile
|
||||||
|
surface is introduced. Desktop and phone end-to-end scenarios use the same repository identity
|
||||||
|
mapping and prove the same file content is opened from the active workspace.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Pure unit coverage builds eligible aliases for single- and multi-repository sessions and rejects
|
||||||
|
missing task membership, mismatched repository IDs, paths outside the active workspace, ambiguous
|
||||||
|
roots, stale worktrees, traversal, unsupported contained targets, and unmatched Windows paths.
|
||||||
|
- Shared Markdown component coverage reproduces a registered source checkout link with a trailing
|
||||||
|
line selector, asserts the workspace-relative open target, and asserts an unrelated host path is
|
||||||
|
inert.
|
||||||
|
- Memoization/context coverage proves a task-level alias context reaches nested agent and user
|
||||||
|
message Markdown without replacing explicit open actions.
|
||||||
|
- The existing desktop attach-workspace-sources scenario cites the attached repository through its
|
||||||
|
original `local_path` and asserts the active worktree file content and tab while the task URL
|
||||||
|
remains unchanged.
|
||||||
|
- The existing phone scenario taps the same form of source-path link and asserts the native viewer,
|
||||||
|
expected active-worktree path/content, close control, and absence of document horizontal
|
||||||
|
overflow.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Resolution is deterministic frontend behavior and adds no log or metric. Existing file-open errors
|
||||||
|
remain observable through the localized failure notification and browser diagnostic history. Tests
|
||||||
|
cover the fail-closed paths directly.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
|
||||||
|
- [ADR-2026-07-22: Runtime-Mutable Task Workspace Sources](../../../decisions/2026-07-22-runtime-mutable-task-workspace-sources.md)
|
||||||
|
- [ADR-2026-07-23: Workspace Source Root Move Boundary](../../../decisions/2026-07-23-workspace-source-root-move-boundary.md)
|
||||||
Reference in New Issue
Block a user