setModalOpen(false)}
+ onConfirm={() => window.open(href, "_blank", "noreferrer")}
+ />
+ >
+ );
+}
+
+export function MarkdownLink(props: MarkdownFileLinkProps) {
+ if (readRewrittenChatFileLink(props)) return ;
+ if (typeof props.href === "string" && props.href.startsWith(LIVEAGENT_FILE_PROTOCOL)) {
+ return ;
+ }
+ return ;
+}
+
async function copyCodeBlockText(text: string) {
try {
await navigator.clipboard.writeText(text);
@@ -435,17 +688,33 @@ export const Markdown = memo(function Markdown(props: MarkdownProps) {
readOnly = false,
componentOverrides,
preserveRelativeUrls = false,
+ workdir,
+ onOpenFileLink,
} = props;
const streaming = renderMode === "streaming";
const normalizedContent = useMemo(
() => normalizeLatexDelimiters(content, streaming && showCaret),
[content, showCaret, streaming],
);
- const baseComponents = readOnly ? markdownReadOnlyComponents : markdownComponents;
- const components = useMemo(
- () => (componentOverrides ? { ...baseComponents, ...componentOverrides } : baseComponents),
- [baseComponents, componentOverrides],
- );
+ const components = useMemo(() => {
+ const baseComponents = readOnly ? markdownReadOnlyComponents : markdownComponents;
+ const fileLinkComponents: Components =
+ !readOnly && onOpenFileLink
+ ? {
+ a: (linkProps) => (
+
+ ),
+ }
+ : {};
+ return { ...baseComponents, ...fileLinkComponents, ...componentOverrides };
+ }, [componentOverrides, onOpenFileLink, readOnly, workdir]);
+ const rehypePlugins = onOpenFileLink
+ ? preserveRelativeUrls
+ ? relativeChatFileRehypePlugins
+ : chatFileRehypePlugins
+ : preserveRelativeUrls
+ ? relativeUrlRehypePlugins
+ : undefined;
return (
@@ -454,16 +723,12 @@ export const Markdown = memo(function Markdown(props: MarkdownProps) {
"chat-markdown max-w-none break-words",
MARKDOWN_EMBED_CLASSNAME,
streaming ? "chat-markdown--streaming" : "chat-markdown--static",
- // Streamdown's memo equality does not include `caret` in its check,
- // so toggling the caret prop alone does not invalidate the render.
- // Mirror the visibility into a className modifier to force a re-render
- // that recomputes the inline `--streamdown-caret` style.
showCaret ? "chat-markdown--caret-on" : "chat-markdown--caret-off",
className,
)}
plugins={streamdownPlugins}
- remarkPlugins={remarkPlugins}
- {...(preserveRelativeUrls ? { rehypePlugins: relativeUrlRehypePlugins } : {})}
+ remarkPlugins={onOpenFileLink ? chatRemarkPlugins : remarkPlugins}
+ {...(rehypePlugins ? { rehypePlugins } : {})}
components={components}
mode={streaming ? "streaming" : "static"}
dir="auto"
diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx
index 5f592b046..cd860ded6 100644
--- a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx
+++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx
@@ -70,6 +70,9 @@ export type WorkspaceCodeEditorOpenRequest = {
projectPathKey: string;
workdir: string;
path: string;
+ line?: number;
+ endLine?: number;
+ column?: number;
};
type ReadEditableTextResponse = {
@@ -302,6 +305,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp
const modelsRef = useRef(new Map());
const viewStatesRef = useRef(new Map());
const editorModelKeyRef = useRef("");
+ const linkedLocationKeyRef = useRef("");
const activeKeyRef = useRef("");
const openRequestIdRef = useRef(null);
const closeRequestIdRef = useRef(null);
@@ -822,6 +826,34 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp
editorModelKeyRef.current = activeTab.key;
}, [activeTab]);
+ const activeTabKey = activeTab?.key;
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor || !activeTabKey || !openRequest?.line) return;
+ if (activeTabKey !== editorTabKey(openRequest.projectPathKey, openRequest.path)) return;
+ const locationKey = `${openRequest.id}\u0000${activeTabKey}`;
+ if (linkedLocationKeyRef.current === locationKey) return;
+ const model = editor.getModel();
+ if (!model) return;
+ const line = Math.min(Math.max(1, openRequest.line), model.getLineCount());
+ const endLine = Math.min(Math.max(line, openRequest.endLine ?? line), model.getLineCount());
+ const column = Math.min(Math.max(1, openRequest.column ?? 1), model.getLineMaxColumn(line));
+ const endColumn = openRequest.endLine ? model.getLineMaxColumn(endLine) : column;
+ const range = new monaco.Range(line, column, endLine, endColumn);
+ editor.setSelection(range);
+ editor.revealRangeInCenter(range);
+ editor.focus();
+ linkedLocationKeyRef.current = locationKey;
+ }, [
+ activeTabKey,
+ openRequest?.column,
+ openRequest?.endLine,
+ openRequest?.id,
+ openRequest?.line,
+ openRequest?.path,
+ openRequest?.projectPathKey,
+ ]);
+
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
diff --git a/crates/agent-gui/src/lib/chat/chatFileLinks.ts b/crates/agent-gui/src/lib/chat/chatFileLinks.ts
new file mode 100644
index 000000000..fd3608ebe
--- /dev/null
+++ b/crates/agent-gui/src/lib/chat/chatFileLinks.ts
@@ -0,0 +1 @@
+export * from "./messages/chatFileLinks";
diff --git a/crates/agent-gui/src/lib/chat/messages/chatFileLinks.ts b/crates/agent-gui/src/lib/chat/messages/chatFileLinks.ts
new file mode 100644
index 000000000..7cf63d3b9
--- /dev/null
+++ b/crates/agent-gui/src/lib/chat/messages/chatFileLinks.ts
@@ -0,0 +1,201 @@
+export type ChatFileLink = {
+ path: string;
+ line?: number;
+ endLine?: number;
+ column?: number;
+ source: "absolute" | "relative" | "file-url";
+};
+
+const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[\\/]/;
+const WINDOWS_UNC_PATH_PATTERN = /^(?:\\\\|\/\/)/;
+const FILE_URL_PATTERN = /^file:\/\//i;
+const ABSOLUTE_POSIX_PATH_PATTERN = /^\//;
+const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
+const LOCATION_FRAGMENT_PATTERN = /^#L([1-9]\d*)(?:-L?([1-9]\d*))?$/i;
+const LOCATION_SUFFIX_PATTERN = /:([1-9]\d*)(?::([1-9]\d*))?$/;
+const INTERNAL_PAYLOAD_VERSION = "1";
+const MAX_LOCATION_VALUE = 0xffff_ffff;
+
+function parseLocationNumber(value: string) {
+ const parsed = Number(value);
+ return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_LOCATION_VALUE ? parsed : null;
+}
+
+function safeDecode(value: string) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function normalizePath(path: string) {
+ const decoded = safeDecode(path);
+ const isUnc = /^(?:\\\\|\/\/[^/])/.test(decoded);
+ const collapsed = decoded.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
+ return isUnc ? `//${collapsed.replace(/^\/+/, "")}` : collapsed;
+}
+
+function isAbsolutePath(path: string) {
+ return (
+ WINDOWS_DRIVE_PATH_PATTERN.test(path) ||
+ WINDOWS_UNC_PATH_PATTERN.test(path) ||
+ ABSOLUTE_POSIX_PATH_PATTERN.test(path)
+ );
+}
+
+function parseTrailingLocation(value: string) {
+ let path = value.trim();
+ let line: number | undefined;
+ let endLine: number | undefined;
+ let column: number | undefined;
+
+ const hashIndex = path.lastIndexOf("#");
+ const hashMatch = hashIndex >= 0 ? path.slice(hashIndex).match(LOCATION_FRAGMENT_PATTERN) : null;
+ if (hashMatch) {
+ line = parseLocationNumber(hashMatch[1]) ?? undefined;
+ if (line === undefined) return { path: "", line, endLine, column };
+ if (hashMatch[2]) {
+ endLine = parseLocationNumber(hashMatch[2]) ?? undefined;
+ if (endLine === undefined) return { path: "", line, endLine, column };
+ }
+ path = path.slice(0, hashIndex);
+ }
+
+ const lineMatch = line === undefined ? path.match(LOCATION_SUFFIX_PATTERN) : null;
+ if (lineMatch?.index !== undefined) {
+ line = parseLocationNumber(lineMatch[1]) ?? undefined;
+ if (line === undefined) return { path: "", line, endLine, column };
+ if (lineMatch[2]) {
+ column = parseLocationNumber(lineMatch[2]) ?? undefined;
+ if (column === undefined) return { path: "", line, endLine, column };
+ }
+ path = path.slice(0, lineMatch.index);
+ }
+
+ return { path, line, endLine, column };
+}
+
+function createChatFileLink(
+ path: string,
+ source: ChatFileLink["source"],
+ location: Omit,
+): ChatFileLink {
+ return {
+ path,
+ ...(location.line === undefined ? {} : { line: location.line }),
+ ...(location.endLine === undefined ? {} : { endLine: location.endLine }),
+ ...(location.column === undefined ? {} : { column: location.column }),
+ source,
+ };
+}
+
+function isSafeRelativePath(path: string) {
+ return (
+ Boolean(path) &&
+ path !== "." &&
+ path !== ".." &&
+ !path.startsWith("#") &&
+ !path.includes("\0") &&
+ !path.includes("\n") &&
+ !path.includes("\r") &&
+ !URI_SCHEME_PATTERN.test(path)
+ );
+}
+
+export function parseChatFileLink(raw: string): ChatFileLink | null {
+ const input = raw.trim();
+ if (!input) return null;
+
+ if (FILE_URL_PATTERN.test(input)) {
+ try {
+ const url = new URL(input);
+ if (url.protocol !== "file:") return null;
+ const hashLocation = url.hash.match(LOCATION_FRAGMENT_PATTERN);
+ const hashLine = hashLocation ? parseLocationNumber(hashLocation[1]) : null;
+ const hashEndLine = hashLocation?.[2] ? parseLocationNumber(hashLocation[2]) : null;
+ if (hashLocation && (hashLine === null || (hashLocation[2] && hashEndLine === null))) {
+ return null;
+ }
+ const locationFromHash = hashLocation
+ ? {
+ line: hashLine ?? undefined,
+ ...(hashEndLine === null ? {} : { endLine: hashEndLine }),
+ }
+ : {};
+ const pathWithHost = url.host ? `//${safeDecode(url.host)}${url.pathname}` : url.pathname;
+ const trailing = parseTrailingLocation(pathWithHost);
+ const normalized = normalizePath(trailing.path).replace(/^\/([a-zA-Z]:)/, "$1");
+ if (!normalized || !isAbsolutePath(normalized)) return null;
+ return createChatFileLink(normalized, "file-url", {
+ ...trailing,
+ ...locationFromHash,
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ const { path, line, endLine, column } = parseTrailingLocation(input);
+ const normalized = normalizePath(path);
+
+ if (isAbsolutePath(normalized)) {
+ return createChatFileLink(normalized, "absolute", { line, endLine, column });
+ }
+
+ if (isSafeRelativePath(normalized)) {
+ return createChatFileLink(normalized, "relative", { line, endLine, column });
+ }
+
+ return null;
+}
+
+export function isChatFileLinkTarget(raw: string) {
+ return Boolean(parseChatFileLink(raw));
+}
+
+export function encodeChatFileLink(link: ChatFileLink) {
+ const params = new URLSearchParams();
+ params.set("v", INTERNAL_PAYLOAD_VERSION);
+ params.set("path", link.path);
+ if (link.line !== undefined) params.set("line", String(link.line));
+ if (link.endLine !== undefined) params.set("endLine", String(link.endLine));
+ if (link.column !== undefined) params.set("column", String(link.column));
+ params.set("source", link.source);
+ return `liveagent-file:${params.toString()}`;
+}
+
+export function decodeChatFileLinkPayload(payload: string): ChatFileLink | null {
+ const params = new URLSearchParams(payload);
+ const allowedKeys = new Set(["v", "path", "line", "endLine", "column", "source"]);
+ for (const key of params.keys()) {
+ if (!allowedKeys.has(key) || params.getAll(key).length !== 1) return null;
+ }
+ if (params.get("v") !== INTERNAL_PAYLOAD_VERSION) return null;
+ const path = params.get("path") ?? "";
+ const source = params.get("source");
+ if (!path || (source !== "absolute" && source !== "relative" && source !== "file-url"))
+ return null;
+
+ const normalized = normalizePath(path);
+ const sourceMatches =
+ source === "relative"
+ ? isSafeRelativePath(normalized) && !isAbsolutePath(normalized)
+ : isAbsolutePath(normalized);
+ if (!sourceMatches) return null;
+
+ const parseLocationValue = (key: "line" | "endLine" | "column") => {
+ const value = params.get(key);
+ if (value === null) return undefined;
+ if (!/^[1-9]\d*$/.test(value)) return null;
+ return parseLocationNumber(value);
+ };
+ const line = parseLocationValue("line");
+ const endLine = parseLocationValue("endLine");
+ const column = parseLocationValue("column");
+ if (line === null || endLine === null || column === null) return null;
+ if ((endLine !== undefined || column !== undefined) && line === undefined) return null;
+ if (line !== undefined && endLine !== undefined && endLine < line) return null;
+
+ return createChatFileLink(normalized, source, { line, endLine, column });
+}
diff --git a/crates/agent-gui/src/lib/chat/openChatFileLink.ts b/crates/agent-gui/src/lib/chat/openChatFileLink.ts
new file mode 100644
index 000000000..1071b913a
--- /dev/null
+++ b/crates/agent-gui/src/lib/chat/openChatFileLink.ts
@@ -0,0 +1,33 @@
+import { invoke } from "@tauri-apps/api/core";
+
+import type { ChatFileLink } from "./chatFileLinks";
+
+export type OpenChatFileLinkParams = ChatFileLink & {
+ conversationId: string;
+ workdir: string;
+ openInFileManager?: boolean;
+};
+
+export type OpenChatFileLinkResult = {
+ action: "directory" | "editor" | "opened" | "preview" | "revealed";
+ kind: "directory" | "file";
+ workdir?: string;
+ path?: string;
+ line?: number;
+ endLine?: number;
+ column?: number;
+ outsideWorkspace: boolean;
+};
+
+export function openChatFileLink(params: OpenChatFileLinkParams) {
+ return invoke("open_chat_file_link", {
+ conversation_id: params.conversationId,
+ workdir: params.workdir,
+ path: params.path,
+ source: params.source,
+ line: params.line,
+ end_line: params.endLine,
+ column: params.column,
+ open_in_file_manager: params.openInFileManager,
+ });
+}
diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx
index 39f76edb0..8da88b5b4 100644
--- a/crates/agent-gui/src/pages/ChatPage.tsx
+++ b/crates/agent-gui/src/pages/ChatPage.tsx
@@ -31,6 +31,7 @@ import { useConfirmDialog } from "../components/ui/confirm-dialog";
import { useLocale } from "../i18n";
import type { AppUpdateController } from "../lib/appUpdates";
import { getAutomationState, useAutomation } from "../lib/automation";
+import type { ChatFileLink } from "../lib/chat/chatFileLinks";
import type { CompactionStatus } from "../lib/chat/compaction/types";
import {
buildRequestContext,
@@ -41,6 +42,7 @@ import {
import type { ChatHistorySummary } from "../lib/chat/history/chatHistory";
import { memoryExtraction } from "../lib/chat/memory/extractionController";
import type { CodeMentionReference } from "../lib/chat/messages/mentionReferences";
+import { openChatFileLink } from "../lib/chat/openChatFileLink";
import {
buildFallbackConversationTitle,
createConversationIdentity,
@@ -667,7 +669,12 @@ export function ChatPage(props: ChatPageProps) {
terminalProjectPathKey,
rightDockFileTreeOpen,
});
- const { handleOpenWorkspaceFile, handleOpenSshTerminal } = workspaceOverlays;
+ const {
+ handleOpenWorkspaceFile,
+ handleOpenSshTerminal,
+ openWorkspaceEditorFile,
+ openWorkspaceFilePreview,
+ } = workspaceOverlays;
// ── 回复末尾「已编辑文件」卡的三个动作 ────────────────────────────────
const gitReviewFocusNonceRef = useRef(0);
const [gitReviewFocusRequest, setGitReviewFocusRequest] = useState(
@@ -750,6 +757,84 @@ export function ChatPage(props: ChatPageProps) {
compactionStatus,
});
+ const handleOpenChatFileLink = useCallback(
+ (link: ChatFileLink) => {
+ const conversationId = currentConversationId;
+ const conversationWorkdir = displayedConversationWorkdir.trim();
+ if (!conversationWorkdir) {
+ addNotify("error", "The conversation working directory is unavailable.");
+ return;
+ }
+ const request = { ...link, conversationId, workdir: conversationWorkdir };
+ void openChatFileLink(request)
+ .then(async (result) => {
+ if (result.action === "opened" || result.action === "revealed") return;
+ const resultWorkdir = result.workdir?.trim() ?? "";
+ const resultPath = result.path?.trim() ?? "";
+ if (!resultWorkdir || !resultPath) {
+ addNotify("error", "The linked file could not be opened.");
+ return;
+ }
+ if (result.action === "directory") {
+ if (workspaceProjectPathKey(resultWorkdir) === terminalProjectPathKey) {
+ handleChangedFileReveal(resultPath);
+ return;
+ }
+ const fallback = await openChatFileLink({ ...request, openInFileManager: true });
+ if (fallback.action !== "opened") {
+ addNotify("error", "The linked directory could not be opened.");
+ }
+ return;
+ }
+ const workspaceRequest = {
+ projectPathKey: workspaceProjectPathKey(resultWorkdir),
+ workdir: resultWorkdir,
+ path: resultPath,
+ };
+ if (
+ !result.outsideWorkspace &&
+ workspaceRequest.projectPathKey === terminalProjectPathKey
+ ) {
+ handleChangedFileReveal(resultPath);
+ }
+ if (result.action === "preview") {
+ openWorkspaceFilePreview(workspaceRequest);
+ return;
+ }
+ openWorkspaceEditorFile({
+ ...workspaceRequest,
+ line: result.line,
+ endLine: result.endLine,
+ column: result.column,
+ });
+ })
+ .catch((error: unknown) => {
+ const message =
+ error && typeof error === "object" && "message" in error
+ ? String((error as { message?: unknown }).message ?? "")
+ : String(error ?? "");
+ const normalized = message.toLowerCase();
+ addNotify(
+ "error",
+ normalized.includes("timed out") ||
+ normalized.includes("offline") ||
+ normalized.includes("not connected")
+ ? "The device that owns this conversation is offline or did not respond."
+ : message || "The linked file could not be opened.",
+ );
+ });
+ },
+ [
+ addNotify,
+ currentConversationId,
+ displayedConversationWorkdir,
+ handleChangedFileReveal,
+ openWorkspaceEditorFile,
+ openWorkspaceFilePreview,
+ terminalProjectPathKey,
+ ],
+ );
+
const {
isUploadingFiles,
pendingUploadedFiles,
@@ -1888,6 +1973,7 @@ export function ChatPage(props: ChatPageProps) {
bottomReservePx={composerOverlayHeight}
contentWidth={settings.customSettings.chatTranscript.width}
onContentWidthChange={handleChatTranscriptWidthChange}
+ onOpenFileLink={handleOpenChatFileLink}
onResendFromEdit={handleResendFromEdit}
onBranchConversation={
// 会话加载中或加载失败时直接不传操作,展示明确的禁用态。
diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx
index ae9220295..85236e056 100644
--- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx
+++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx
@@ -1,5 +1,6 @@
import { memo, type ReactNode } from "react";
+import type { ChatFileLink } from "../../../lib/chat/chatFileLinks";
import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore";
import { VIBING_STATUS } from "../../../lib/chat/page/chatPageHelpers";
import type { AssistantUnitRow } from "../transcript/rowModel";
@@ -18,6 +19,8 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: {
isCompactionRunning: boolean;
toolStatus: string | null;
retryAttempts?: RetryAttemptRecord[];
+ workdir?: string;
+ onOpenFileLink?: (link: ChatFileLink) => void;
}) {
const {
row,
@@ -27,6 +30,8 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: {
isCompactionRunning,
toolStatus,
retryAttempts,
+ workdir,
+ onOpenFileLink,
} = props;
const { unit } = row;
if (unit.kind === "footer") return null;
@@ -98,6 +103,8 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: {
thinkingOpen={unit.thinkingOpen}
isLatestThinking={unit.isLatestThinking}
isAborted={row.isAborted}
+ workdir={workdir}
+ onOpenFileLink={onOpenFileLink}
/>
) : null}
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx
index 8734139dd..4e1d84ef9 100644
--- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx
+++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx
@@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useRef, useState } from "react";
import { ChevronRight, Lightbulb, RefreshCw } from "../../../../components/icons";
import { Markdown } from "../../../../components/Markdown";
import { useLocale } from "../../../../i18n";
+import type { ChatFileLink } from "../../../../lib/chat/chatFileLinks";
import type { RetryAttemptRecord } from "../../../../lib/chat/conversation/liveTranscriptStore";
import type { GroupedRoundBlock } from "./assistantBubbleUtils";
import { HostedSearchGroupView } from "./HostedSearchGroupView";
@@ -17,11 +18,15 @@ const ThinkingBlock = memo(function ThinkingBlock({
open,
isRunning,
renderMode,
+ workdir,
+ onOpenFileLink,
}: {
text: string;
open?: boolean;
isRunning?: boolean;
renderMode: "streaming" | "static";
+ workdir?: string;
+ onOpenFileLink?: (link: ChatFileLink) => void;
}) {
const hasText = /\S/.test(text || "");
const { t } = useLocale();
@@ -66,6 +71,8 @@ const ThinkingBlock = memo(function ThinkingBlock({
className="thinking-markdown space-y-1.5"
renderMode={renderMode}
showCaret={false}
+ workdir={workdir}
+ onOpenFileLink={onOpenFileLink}
/>
)}
@@ -131,6 +138,8 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
thinkingOpen: boolean;
isLatestThinking: boolean;
isAborted: boolean;
+ workdir?: string;
+ onOpenFileLink?: (link: ChatFileLink) => void;
}) {
const {
block,
@@ -141,6 +150,8 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
thinkingOpen,
isLatestThinking,
isAborted,
+ workdir,
+ onOpenFileLink,
} = props;
let content: ReactNode;
@@ -152,6 +163,8 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
open={isRunning}
isRunning={isRunning}
renderMode={renderMode}
+ workdir={workdir}
+ onOpenFileLink={onOpenFileLink}
/>
);
} else if (block.kind === "tool") {
@@ -193,6 +206,8 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: {
className="font-chat"
renderMode={renderMode}
showCaret={Boolean(isLive && isMutable)}
+ workdir={workdir}
+ onOpenFileLink={onOpenFileLink}
/>
);
} else {
diff --git a/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx b/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx
index 16f6924b8..3b775eb27 100644
--- a/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx
+++ b/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx
@@ -1,6 +1,7 @@
import { memo, useMemo } from "react";
import { ChangedFilesCard } from "../../../components/chat/ChangedFilesCard";
+import type { ChatFileLink } from "../../../lib/chat/chatFileLinks";
import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState";
import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore";
import { collectChangedFiles } from "../../../lib/chat/messages/changedFiles";
@@ -17,6 +18,8 @@ export type AssistantRenderUnitProps = {
isCompactionRunning: boolean;
toolStatus: string | null;
retryAttempts?: RetryAttemptRecord[];
+ workdir?: string;
+ onOpenFileLink?: (link: ChatFileLink) => void;
onResendFromEdit: (
messageRef: HistoryMessageRef,
text: string,
@@ -79,6 +82,8 @@ export const AssistantRenderUnit = memo(function AssistantRenderUnit(
isCompactionRunning,
toolStatus,
retryAttempts,
+ workdir,
+ onOpenFileLink,
onResendFromEdit,
onBranchConversation,
} = props;
@@ -106,6 +111,8 @@ export const AssistantRenderUnit = memo(function AssistantRenderUnit(
isCompactionRunning={isCompactionRunning}
toolStatus={toolStatus}
retryAttempts={retryAttempts}
+ workdir={workdir}
+ onOpenFileLink={onOpenFileLink}
/>
);
diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx
index 2aa6dd2ba..bf3fb1335 100644
--- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx
+++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx
@@ -54,6 +54,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript
bottomReservePx = 0,
contentWidth,
onContentWidthChange,
+ onOpenFileLink,
onResendFromEdit,
onBranchConversation,
branchPendingMessageId,
@@ -322,6 +323,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript
showUsage={showUsage}
usageContextWindow={usageContextWindow}
workspaceRoot={workspaceRoot}
+ onOpenFileLink={onOpenFileLink}
gitClient={gitClient}
navRef={transcriptNavRef}
onAnchorUserRowChange={setActiveFloorKey}
diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx
index 5d1d07541..6f775dc14 100644
--- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx
+++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx
@@ -15,6 +15,7 @@ import {
import { CheckCircle2, ChevronDown } from "../../../components/icons";
import { Markdown } from "../../../components/Markdown";
import { useLocale } from "../../../i18n";
+import type { ChatFileLink } from "../../../lib/chat/chatFileLinks";
import type {
HistoryMessageRef,
RenderSummaryCard,
@@ -117,6 +118,7 @@ export type TranscriptListProps = {
usageContextWindow?: number;
workspaceRoot?: string;
gitClient?: GitClient | null;
+ onOpenFileLink?: (link: ChatFileLink) => void;
// 楼层导航:跳转句柄挂载点(与 followRef 同一模式),以及「视口顶部
// 当前处于哪条用户消息行」变化时的上报回调。
navRef?: MutableRefObject;
@@ -152,6 +154,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList
usageContextWindow,
workspaceRoot,
gitClient,
+ onOpenFileLink,
navRef,
onAnchorUserRowChange,
onResendFromEdit,
@@ -522,6 +525,8 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList
isCompactionRunning={row.mutable ? isCompactionRunning : false}
toolStatus={row.mutable ? displayedToolStatus : null}
retryAttempts={row.mutable ? liveState.retryAttempts : undefined}
+ workdir={workspaceRoot}
+ onOpenFileLink={onOpenFileLink}
onResendFromEdit={onResendFromEdit}
onBranchConversation={onBranchConversation}
/>
diff --git a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts
index ef640313b..5640481cd 100644
--- a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts
+++ b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts
@@ -1,5 +1,5 @@
import type { MutableRefObject } from "react";
-
+import type { ChatFileLink } from "../../../lib/chat/chatFileLinks";
import type {
HistoryMessageRef,
RenderTimelineItem,
@@ -31,6 +31,7 @@ export type ChatTranscriptProps = {
bottomReservePx?: number;
contentWidth: number;
onContentWidthChange: (width: number) => void;
+ onOpenFileLink?: (link: ChatFileLink) => void;
onResendFromEdit: (
messageRef: HistoryMessageRef,
text: string,
diff --git a/crates/agent-gui/test/chat/chat-file-links.test.mjs b/crates/agent-gui/test/chat/chat-file-links.test.mjs
new file mode 100644
index 000000000..2ba3a2a57
--- /dev/null
+++ b/crates/agent-gui/test/chat/chat-file-links.test.mjs
@@ -0,0 +1,153 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";
+
+const loader = createTsModuleLoader();
+const {
+ decodeChatFileLinkPayload,
+ encodeChatFileLink,
+ parseChatFileLink,
+} = loader.loadModule("src/lib/chat/messages/chatFileLinks.ts");
+
+const validCases = [
+ ["C:/work/src/a.ts", { path: "C:/work/src/a.ts", source: "absolute" }],
+ [String.raw`C:\work\src\a.ts`, { path: "C:/work/src/a.ts", source: "absolute" }],
+ [String.raw`C:\\project\\file.ts`, { path: "C:/project/file.ts", source: "absolute" }],
+ ["D:/other/a.ts", { path: "D:/other/a.ts", source: "absolute" }],
+ ["C:/work/src/a.ts:12", { path: "C:/work/src/a.ts", line: 12, source: "absolute" }],
+ [
+ "C:/work/src/a.ts:12:4",
+ { path: "C:/work/src/a.ts", line: 12, column: 4, source: "absolute" },
+ ],
+ ["C:/work/src/a.ts#L12", { path: "C:/work/src/a.ts", line: 12, source: "absolute" }],
+ [
+ "C:/work/src/a.ts#L12-L20",
+ { path: "C:/work/src/a.ts", line: 12, endLine: 20, source: "absolute" },
+ ],
+ ["C:/path with spaces/a.ts", { path: "C:/path with spaces/a.ts", source: "absolute" }],
+ ["C:/path%20with%20spaces/a.ts", { path: "C:/path with spaces/a.ts", source: "absolute" }],
+ ["C:/路径/文件.ts", { path: "C:/路径/文件.ts", source: "absolute" }],
+ [
+ "file:///C:/work/a.ts",
+ { path: "C:/work/a.ts", source: "file-url" },
+ ],
+ [
+ "file:///C:/path%20with%20spaces/%E6%96%87%E4%BB%B6.ts#L7-L9",
+ {
+ path: "C:/path with spaces/文件.ts",
+ line: 7,
+ endLine: 9,
+ source: "file-url",
+ },
+ ],
+ ["/home/user/work/a.ts", { path: "/home/user/work/a.ts", source: "absolute" }],
+ ["./src/a.ts", { path: "./src/a.ts", source: "relative" }],
+ ["../src/a.ts:3:2", { path: "../src/a.ts", line: 3, column: 2, source: "relative" }],
+ ["src/a.ts", { path: "src/a.ts", source: "relative" }],
+ ["README.md", { path: "README.md", source: "relative" }],
+ [
+ String.raw`\\server\share\folder\a.ts:8`,
+ { path: "//server/share/folder/a.ts", line: 8, source: "absolute" },
+ ],
+ [
+ "file://server/share/folder/a.ts#L4",
+ { path: "//server/share/folder/a.ts", line: 4, source: "file-url" },
+ ],
+];
+
+test("parseChatFileLink supports the cross-platform chat path matrix", () => {
+ for (const [input, expected] of validCases) {
+ assert.deepEqual(parseChatFileLink(input), expected, input);
+ }
+});
+
+test("parseChatFileLink rejects external, dangerous, internal, and malformed targets", () => {
+ for (const input of [
+ "javascript:alert(1)",
+ "data:text/html,",
+ "vbscript:msgbox(1)",
+ "https://example.com/a.ts",
+ "http://example.com/a.ts",
+ "mailto:user@example.com",
+ "liveagent-file:path=C%3A%2Fa.ts&source=absolute",
+ "C:drive-relative.txt",
+ "C:/work/a.ts:4294967296",
+ "file:///C:/work/a.ts#L999999999999999999999999",
+ "#L12",
+ "",
+ ]) {
+ assert.equal(parseChatFileLink(input), null, input);
+ }
+
+ assert.doesNotThrow(() => parseChatFileLink("file:///%E0%A4%A"));
+});
+
+test("Gateway chat file opens run off-loop with bounded host concurrency", () => {
+ const envelopeHandler = fs.readFileSync(
+ fileURLToPath(
+ new URL(
+ "../../src-tauri/src/services/gateway/envelope_handler.rs",
+ import.meta.url,
+ ),
+ ),
+ "utf8",
+ );
+ const chatFileLinks = fs.readFileSync(
+ fileURLToPath(
+ new URL(
+ "../../src-tauri/src/commands/workspace/chat_file_links.rs",
+ import.meta.url,
+ ),
+ ),
+ "utf8",
+ );
+
+ const branch = envelopeHandler.slice(
+ envelopeHandler.indexOf("Payload::ChatFileOpen"),
+ envelopeHandler.indexOf("Payload::FsWriteText"),
+ );
+ assert.match(branch, /tauri::async_runtime::spawn/);
+ assert.match(branch, /let sender = self\.current_outbound_sender\(\)\?/);
+ assert.match(branch, /send_agent_envelope_to\(sender, envelope\)/);
+ assert.ok(
+ branch.indexOf("tauri::async_runtime::spawn") <
+ branch.indexOf("handle_chat_file_open(request).await"),
+ );
+ assert.match(chatFileLinks, /tokio::time::timeout\(CHAT_FILE_OPEN_TIMEOUT/);
+ assert.match(chatFileLinks, /CHAT_FILE_OPEN_SEMAPHORE/);
+ assert.match(chatFileLinks, /let _permit = permit/);
+});
+
+test("the internal payload codec preserves locations and rejects malformed payloads", () => {
+ const original = {
+ path: "C:/路径/a file.ts",
+ line: 12,
+ endLine: 20,
+ column: 4,
+ source: "absolute",
+ };
+ const encoded = encodeChatFileLink(original);
+ assert.match(encoded, /^liveagent-file:/);
+ assert.deepEqual(
+ decodeChatFileLinkPayload(encoded.slice("liveagent-file:".length)),
+ original,
+ );
+
+ for (const payload of [
+ "path=&source=absolute",
+ "v=1&path=&source=absolute",
+ "v=1&path=C%3A%2Fa.ts&source=unknown",
+ "v=1&path=C%3A%2Fa.ts&source=absolute&line=0",
+ "v=1&path=C%3A%2Fa.ts&source=absolute&line=-1",
+ "v=1&path=C%3A%2Fa.ts&source=absolute&line=abc",
+ "v=1&path=C%3A%2Fa.ts&source=absolute&line=4294967296",
+ "v=1&path=C%3A%2Fa.ts&source=absolute&line=20&endLine=12",
+ "v=1&path=C%3A%2Fa.ts&path=D%3A%2Fb.ts&source=absolute",
+ "v=1&path=C%3A%2Fa.ts&source=absolute&extra=true",
+ ]) {
+ assert.equal(decodeChatFileLinkPayload(payload), null, payload);
+ }
+});
diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs
index 937cc73f2..606ee1375 100644
--- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs
+++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs
@@ -5,6 +5,10 @@ import { fileURLToPath } from "node:url";
import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";
+const rawPlugin = () => {};
+const sanitizePlugin = () => {};
+const hardenPlugin = () => {};
+
const loader = createTsModuleLoader({
mocks: {
"@streamdown/cjk": {
@@ -24,7 +28,19 @@ const loader = createTsModuleLoader({
return { type: "Streamdown", props };
},
defaultRemarkPlugins: {},
- defaultRehypePlugins: {},
+ defaultRehypePlugins: {
+ raw: rawPlugin,
+ sanitize: [
+ sanitizePlugin,
+ {
+ protocols: {
+ href: ["http", "https", "mailto"],
+ src: ["http", "https"],
+ },
+ },
+ ],
+ harden: hardenPlugin,
+ },
},
"@tauri-apps/plugin-opener": {
openUrl() {
@@ -129,6 +145,258 @@ test("markdown image syntax falls back to alt text instead of rendering a real i
assert.equal(empty, null);
});
+test("chat file links are rewritten before sanitize and harden without widening unsafe schemes", () => {
+ const fileNode = {
+ type: "element",
+ tagName: "a",
+ properties: { href: "C:/Users/AlphaCat/claude-code-request.curl.ps1" },
+ children: [{ type: "text", value: "claude-code-request.curl.ps1" }],
+ };
+ const httpsNode = {
+ type: "element",
+ tagName: "a",
+ properties: { href: "https://example.com" },
+ children: [],
+ };
+ const dangerousNode = {
+ type: "element",
+ tagName: "a",
+ properties: { href: "javascript:alert(1)" },
+ children: [],
+ };
+ const rawHtmlNode = {
+ type: "element",
+ tagName: "a",
+ properties: { href: "C:/Users/AlphaCat/disguised.ps1" },
+ position: { start: { offset: 0 } },
+ children: [{ type: "text", value: "https://example.com" }],
+ };
+ markdownModule.rewriteChatFileLinks()({
+ type: "root",
+ children: [fileNode, httpsNode, dangerousNode],
+ });
+ markdownModule.rewriteChatFileLinks()(
+ { type: "root", children: [rawHtmlNode] },
+ { value: 'https://example.com' },
+ );
+
+ assert.match(fileNode.properties.href, /^liveagent-file:/);
+ assert.equal(fileNode.data.liveagentChatFileLink, fileNode.properties.href);
+ assert.equal(httpsNode.properties.href, "https://example.com");
+ assert.equal(dangerousNode.properties.href, "javascript:alert(1)");
+ assert.equal(rawHtmlNode.properties.href, "C:/Users/AlphaCat/disguised.ps1");
+ assert.equal(rawHtmlNode.data, undefined);
+
+ const plugins = markdownModule.chatFileRehypePlugins;
+ assert.equal(plugins[0], rawPlugin);
+ assert.equal(plugins[1], markdownModule.rewriteChatFileLinks);
+ assert.equal(plugins[2][0], sanitizePlugin);
+ assert.equal(plugins.at(-1), hardenPlugin);
+ const hrefProtocols = plugins[2][1].protocols.href;
+ assert.ok(hrefProtocols.includes("liveagent-file"));
+ for (const scheme of ["file", "c", "d", "javascript", "data", "vbscript"]) {
+ assert.equal(hrefProtocols.includes(scheme), false, scheme);
+ }
+});
+
+test("raw spaces and backslashes become Markdown file links without touching code", () => {
+ const source = "Open [space](C:/path with spaces/a.ts) and [windows](C:\\work\\src\\a.ts).";
+ const tree = {
+ type: "root",
+ children: [
+ {
+ type: "paragraph",
+ children: [
+ {
+ type: "text",
+ value: source,
+ position: { start: { offset: 0 }, end: { offset: source.length } },
+ },
+ ],
+ },
+ {
+ type: "code",
+ value: "[literal](C:/path with spaces/a.ts)",
+ },
+ {
+ type: "image",
+ url: "C:/path with spaces/image.png",
+ value: "image",
+ },
+ {
+ type: "math",
+ value: String.raw`[x](C:\math\formula.ts)`,
+ },
+ {
+ type: "table",
+ children: [{ type: "tableRow", children: [{ type: "text", value: "plain cell" }] }],
+ },
+ ],
+ };
+ markdownModule.remarkChatFileLinks()(tree, { value: source });
+
+ const paragraph = tree.children[0].children;
+ assert.equal(paragraph.filter((node) => node.type === "link").length, 2);
+ assert.equal(paragraph[1].url, "C:/path with spaces/a.ts");
+ assert.equal(paragraph[3].url, "C:\\work\\src\\a.ts");
+ assert.equal(tree.children[1].value, "[literal](C:/path with spaces/a.ts)");
+ assert.equal(tree.children[2].url, "C:/path with spaces/image.png");
+ assert.equal(tree.children[3].value, String.raw`[x](C:\math\formula.ts)`);
+ assert.equal(tree.children[4].type, "table");
+ assert.equal(tree.children[4].children[0].children[0].value, "plain cell");
+});
+
+test("escaped Markdown file links stay literal while a following link remains clickable", () => {
+ const source = String.raw`\[foo\*](README.md) and [foo*](README.md)`;
+ const tree = {
+ type: "root",
+ children: [
+ {
+ type: "paragraph",
+ children: [
+ {
+ type: "text",
+ value: "[foo*](README.md) and [foo*](README.md)",
+ position: { start: { offset: 0 }, end: { offset: source.length } },
+ },
+ ],
+ },
+ ],
+ };
+
+ markdownModule.remarkChatFileLinks()(tree, { value: source });
+
+ assert.deepEqual(tree.children[0].children, [
+ { type: "text", value: "[foo*](README.md) and " },
+ {
+ type: "link",
+ url: "README.md",
+ children: [{ type: "text", value: "foo*" }],
+ },
+ ]);
+});
+
+test("linked editor locations are applied once per request and tab in both frontends", () => {
+ const files = [
+ "../../src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx",
+ "../../../agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx",
+ ];
+ for (const relativePath of files) {
+ const source = fs.readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
+ assert.match(source, /linkedLocationKeyRef/);
+ assert.match(source, /const locationKey = `\$\{openRequest\.id\}\\u0000\$\{activeTabKey\}`/);
+ assert.match(source, /if \(linkedLocationKeyRef\.current === locationKey\) return/);
+ assert.doesNotMatch(source, /\}, \[activeTab, openRequest\]\);/);
+ }
+});
+
+test("the reported ps1 link renders as one accessible click target and never executes itself", () => {
+ const fileNode = {
+ type: "element",
+ tagName: "a",
+ properties: { href: "C:/Users/AlphaCat/claude-code-request.curl.ps1" },
+ children: [],
+ };
+ markdownModule.rewriteChatFileLinks()({ type: "root", children: [fileNode] });
+ const opened = [];
+ const button = markdownModule.MarkdownFileLink({
+ children: "claude-code-request.curl.ps1",
+ href: fileNode.properties.href,
+ node: fileNode,
+ workdir: "C:/Users/AlphaCat",
+ onOpenFileLink(link) {
+ opened.push(link);
+ },
+ });
+
+ assert.equal(button.type, "button");
+ assert.equal(button.props.type, "button");
+ assert.equal(button.props["data-liveagent-file-link"], "true");
+ assert.equal(button.props.children, "claude-code-request.curl.ps1");
+ assert.doesNotMatch(String(button.props.children), /\[blocked\]/);
+ assert.match(button.props.className, /overflow-wrap:anywhere/);
+ assert.match(button.props.className, /max-w-full/);
+ assert.match(button.props.className, /whitespace-normal/);
+ button.props.onClick();
+ assert.deepEqual(opened, [
+ {
+ path: "C:/Users/AlphaCat/claude-code-request.curl.ps1",
+ source: "absolute",
+ },
+ ]);
+});
+
+test("historical and streaming assistant rows share the explicit file-open prop chain", () => {
+ const files = [
+ "../../src/pages/chat/transcript/ChatTranscript.tsx",
+ "../../src/pages/chat/transcript/TranscriptList.tsx",
+ "../../src/pages/chat/transcript/AssistantRenderUnit.tsx",
+ "../../src/pages/chat/components/AssistantBubble.tsx",
+ "../../src/pages/chat/components/assistant-bubble/RoundContent.tsx",
+ ];
+ for (const relativePath of files) {
+ const source = fs.readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
+ assert.match(source, /onOpenFileLink/, relativePath);
+ }
+
+ const roundContent = fs.readFileSync(
+ fileURLToPath(
+ new URL(
+ "../../src/pages/chat/components/assistant-bubble/RoundContent.tsx",
+ import.meta.url,
+ ),
+ ),
+ "utf8",
+ );
+ assert.match(roundContent, /export const RoundBlockContent/);
+ assert.match(roundContent, /renderMode=\{renderMode\}/);
+ assert.ok((roundContent.match(/onOpenFileLink=\{onOpenFileLink\}/g) ?? []).length >= 2);
+ assert.ok((roundContent.match(/workdir=\{workdir\}/g) ?? []).length >= 2);
+
+ const transcriptList = fs.readFileSync(
+ fileURLToPath(new URL("../../src/pages/chat/transcript/TranscriptList.tsx", import.meta.url)),
+ "utf8",
+ );
+ assert.match(transcriptList, /isCompactionRunning=\{row\.mutable/);
+ assert.match(transcriptList, /workdir=\{workspaceRoot\}/);
+ assert.match(transcriptList, /onOpenFileLink=\{onOpenFileLink\}/);
+
+ const chatPage = fs.readFileSync(
+ fileURLToPath(new URL("../../src/pages/ChatPage.tsx", import.meta.url)),
+ "utf8",
+ );
+ assert.match(chatPage, /openInFileManager: true/);
+ assert.match(chatPage, /!result\.outsideWorkspace/);
+});
+
+test("forged internal payloads cannot become clickable file links", () => {
+ let opened = 0;
+ const forged = markdownModule.MarkdownFileLink({
+ children: "not-a-real-marker.ps1",
+ href: "liveagent-file:v=1&path=C%3A%2Fnot-a-real-marker.ps1&source=absolute",
+ node: { type: "element", tagName: "a", properties: {} },
+ onOpenFileLink() {
+ opened += 1;
+ },
+ });
+ const rendered = forged.type(forged.props);
+ assert.equal(rendered.type, "span");
+ assert.equal(rendered.props["data-liveagent-file-link"], undefined);
+ assert.equal(opened, 0);
+});
+
+test("ordinary https links stay on the external-link path", () => {
+ const routed = markdownModule.MarkdownLink({
+ children: "example",
+ href: "https://example.com",
+ node: { type: "element", tagName: "a", properties: { href: "https://example.com" } },
+ onOpenFileLink() {
+ throw new Error("https link must not open as a chat file");
+ },
+ });
+ assert.equal(routed.type.name, "MarkdownExternalLink");
+});
+
test("external link safety modal renders through document body portal", () => {
const previousDocument = globalThis.document;
const body = { nodeType: 1 };
diff --git a/docs/worklog/chat-file-links.md b/docs/worklog/chat-file-links.md
new file mode 100644
index 000000000..4dc30d57b
--- /dev/null
+++ b/docs/worklog/chat-file-links.md
@@ -0,0 +1,90 @@
+# Chat file links
+
+## Goal
+
+Provide one safe, cross-platform chat file-link path for desktop and Gateway Web. Historical and streaming assistant Markdown must recognize local paths without weakening URL sanitization, and user clicks must resolve/open the path on the conversation's owning host.
+
+## Baseline
+
+- Branch: `fix/chat-file-links`
+- Base: `upstream/main` at `410eef1d`; fetched and verified on 2026-07-29.
+- Existing unrelated worktree changes are present and must be preserved, notably `Cargo.lock` and local/untracked project files.
+- Pre-existing draft files for `chatFileLinks.ts` and GUI `Markdown.tsx` were found. Audit identified duplicate plugin declarations, sanitize/harden bypass, external-link regression, unsafe scheme classification, and missing click plumbing. Treat the draft as unverified input, not completed work.
+- `.trellis/` is absent, so `trellis-before-dev` could not load package specs. Repository CodeGraph was synced and used for discovery.
+
+## Execution plan
+
+1. Add failing parser and Markdown rewrite tests for the reported Windows path and the complete path/security matrix.
+2. Implement byte-identical GUI/Web parser and safe pre-sanitize rewrite using only `liveagent-file:`.
+3. Add accessible file-link rendering while preserving Streamdown external-link confirmation and dangerous-protocol blocking.
+4. Carry conversation id/workdir and the click callback through Transcript → AssistantRow → AssistantBubble → RoundContent → Markdown.
+5. Add a Tauri command that resolves relative paths against the conversation workdir, canonicalizes, classifies, and returns a safe action. Never execute scripts/executables.
+6. Extend the Gateway protobuf relay and Web shim so requests are sent only to the selected/owning agent, with offline/mismatch/timeout errors.
+7. Reuse/extend workspace editor and preview requests for in-app text/preview handling and line/column location.
+8. Run targeted tests, Rust/Go checks, GUI/Web build+lint+tests, mirror check, diff check, and responsive UI verification.
+
+## Recovery checkpoint
+
+Implementation is complete across the parser, Markdown pipeline, explicit GUI/Web prop chains,
+workspace editor location handling, controlled Rust open policy, and Gateway protobuf relay.
+
+Key invariants now enforced:
+
+- `raw → rewriteChatFileLinks → sanitize → harden`; only `liveagent-file:` is added.
+- Internal payloads require an AST marker created before sanitize plus a canonical codec round trip.
+- The target agent reloads `conversation_id` from its own history database and uses the stored
+ `cwd`; the request-supplied workdir is never the relative-path base.
+- Paths are canonicalized after click. Missing targets fail before any system process is spawned.
+- Scripts open in the editor; executable/active-content files and bundles are revealed, never run.
+- Workspace directories use the file tree first, then a validated directory-only file-manager
+ fallback. Workspace-external previewable files reuse existing editor/preview readers.
+
+Completed checks:
+
+- GUI parser/Markdown/security tests: 20 passed.
+- Gateway parser/adapter/prop-chain tests: 11 passed; complete Web suite: 483 passed.
+- Go `internal/protocol/pbws`: passed. Full `go test ./...` passed every other package but is
+ blocked on Windows by the pre-existing `agenttoken` permission assertion (0666 vs 0600).
+- Rust `chat_file_links` tests: 5 passed using temporary `libclang` tooling;
+ `cargo check --tests` passed with five unrelated existing warnings.
+- GUI and Gateway Web production builds and `tsc` passed.
+- Full GUI frontend suite ran 1,353 tests: 1,348 passed and five unrelated static-source/preset
+ sync tests failed (`mention-composer-selection`, `mention-refetch`, provider usage preset sync).
+- Full GUI/Web Biome checks were attempted and are blocked by hundreds of existing repository
+ diagnostics/CRLF formatting differences. Task-core targeted Biome lint passed; large touched
+ host files reported only existing warnings and no diagnostics on the new handlers.
+- Mirror check passed for all 116 mirrored files; `git diff --check` passed.
+- Playwright narrow-width check: 390px and 412px had no horizontal overflow; mouse, Enter,
+ and Space each generated one click; tooltip and focus outline were present.
+
+## Hardening follow-up — 2026-07-29
+
+The follow-up closes the remaining review findings without changing the public file-link contract:
+
+- Escaped Markdown links remain literal, including candidates whose label contains escaped punctuation
+- Linked editor locations apply once per request and tab, so typing no longer resets the caret
+- Directories are classified before file extensions; macOS active directory packages fail closed
+- Unknown binary and active targets never reach the host opener
+- Gateway file-open work runs outside the serial envelope loop, with a 25-second timeout and a four-request concurrency limit
+- Detached Gateway responses stay bound to the outbound connection that received the request
+
+Verification for the follow-up snapshot:
+
+- GUI targeted chat/Markdown tests: 23 passed
+- Gateway targeted chat-file-link tests: 5 passed
+- Rust `chat_file_links` tests: 5 passed; `cargo check --tests` passed with five unrelated existing warnings
+- GUI and Gateway Web `pnpm build`: passed
+- Focused Biome checks for the changed Markdown/editor logic: passed with only existing editor accessibility warnings
+- Full package Biome checks remain unusable on this Windows checkout because untouched files produce hundreds of existing CRLF-format and lint diagnostics; no broad formatting rewrite was applied
+- `cargo fmt --check`, mirror check for 116 files, and `git diff --check`: passed
+- `Cargo.lock`, `Cargo.toml`, generated build output, and unrelated local files are excluded from the follow-up
+
+The hardening implementation and local verification are complete. Commit, push, PR creation, and remote CI follow this checkpoint.
+
+## Maintainer safety follow-up — 2026-07-29
+
+Host file-manager fallbacks now reveal or select directory targets instead of opening the targets
+through platform file associations. This keeps unrecognized macOS bundles such as `.prefPane`,
+`.saver`, `.bundle`, and `.plugin` directories from being launched after a chat-file-link click.
+Platform command construction and the directory-package plan are covered by focused Rust regression
+tests.
diff --git a/scripts/mirror-manifest.json b/scripts/mirror-manifest.json
index 37a3df542..8f063e537 100644
--- a/scripts/mirror-manifest.json
+++ b/scripts/mirror-manifest.json
@@ -36,6 +36,7 @@
"components/chat/AskUserQuestionCard.tsx",
"components/CliIdentityUpdateHost.tsx",
"lib/chat/askUserQuestion.ts",
+ "lib/chat/openChatFileLink.ts",
"components/Markdown.tsx",
"lib/markdownCodeBlockPolicy.ts",
"lib/normalizeLatexDelimiters.ts",