diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index bb65c79f1..42a669e1d 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -20,6 +20,7 @@ import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { ScrollArea } from "@liveagent/ui/components/ui/scroll-area"; import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/WorkspaceOverlayHost"; import { LocaleContext, t as translate } from "@liveagent/ui/i18n/index"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { normalizeLogicalLineEndings } from "@liveagent/ui/lib/chat/composerText"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; import { queuedChatTurnHasContent } from "@liveagent/ui/lib/chat/queuedChatTurn"; @@ -52,7 +53,6 @@ import { } from "react"; import { ChevronDown, PanelRightClose, PanelRightOpen, Terminal } from "@/components/icons"; import { registerAskUserQuestionAnswerHandler } from "@/lib/chat/askUserQuestionBridge"; -import type { ChatFileLink } from "@/lib/chat/chatFileLinks"; import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; import { buildModelOptions } from "@/lib/chat/chatPageHelpers"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index f0bd2a45e..3232e8795 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -15,6 +15,7 @@ import { } from "@liveagent/ui/components/chat/TranscriptMessageActions"; import { Markdown } from "@liveagent/ui/components/Markdown"; import { useLocale } from "@liveagent/ui/i18n/LocaleContext"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { getUploadedImagePreviewCacheKey, loadUploadedImagePreview, @@ -47,7 +48,6 @@ import { useRef, useState, } from "react"; -import type { ChatFileLink } from "@/lib/chat/chatFileLinks"; import { normalizeLiveToolStatus, VIBING_STATUS } from "@/lib/chat/chatPageHelpers"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import { getRoundText } from "@/lib/chat/uiMessages"; diff --git a/crates/agent-gateway/web/test/chat-file-links.test.mjs b/crates/agent-gateway/web/test/chat-file-links.test.mjs index 124ce0f5d..e03864f2a 100644 --- a/crates/agent-gateway/web/test/chat-file-links.test.mjs +++ b/crates/agent-gateway/web/test/chat-file-links.test.mjs @@ -34,13 +34,17 @@ const { decodeChatFileLinkPayload, encodeChatFileLink, parseChatFileLink, -} = loader.loadModule("src/lib/chat/chatFileLinks.ts"); +} = loader.loadModule("@liveagent/ui/lib/chat/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" }], + ["/D:/workspace/release/a.zip", { path: "D:/workspace/release/a.zip", source: "absolute" }], + ["/d:/workspace/release/a.zip", { path: "d:/workspace/release/a.zip", source: "absolute" }], + ["~/release/a.zip", { path: "~/release/a.zip", source: "absolute" }], + ["~/work/a.ts:12", { path: "~/work/a.ts", line: 12, 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", diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs b/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs index fbea8edca..512ee2b40 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs @@ -167,6 +167,14 @@ const EXECUTABLE_EXTENSIONS: &[&str] = &[ "xpi", ]; +// Build artifacts the chat frequently links to (release archives, exports). +// They are neither editable text nor previewable, so reveal them in the host +// file manager instead of failing closed. +const ARCHIVE_EXTENSIONS: &[&str] = &[ + "7z", "bz2", "cab", "gz", "iso", "lz4", "lzma", "rar", "tar", "tbz2", "tgz", "txz", "xz", + "zip", "zst", +]; + #[derive(Debug, Clone, Copy, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ChatFileLinkErrorCode { @@ -382,7 +390,14 @@ fn build_chat_file_link_plan( )); } - let raw_target = PathBuf::from(path.trim()); + // Home-anchored link paths ("~/release/a.zip") expand against the host + // home directory, mirroring the frontend's absolute classification. + // Relative paths keep their literal form so a workspace entry named "~" + // still joins against the conversation workdir. + let raw_target = match source { + "absolute" | "file-url" => expand_tilde_path(path.trim()), + _ => PathBuf::from(path.trim()), + }; let candidate = match source { "relative" if !raw_target.is_absolute() => conversation_workdir.join(raw_target), "absolute" | "file-url" if raw_target.is_absolute() => raw_target, @@ -475,7 +490,7 @@ fn build_chat_file_link_plan( "editor" } else if has_extension(&target, PREVIEW_EXTENSIONS) { "preview" - } else if executable { + } else if executable || has_extension(&target, ARCHIVE_EXTENSIONS) { "revealed" } else if is_probably_text(&target) { "editor" @@ -741,6 +756,60 @@ mod tests { fs::remove_dir_all(root).expect("remove temp workspace"); } + #[test] + fn archive_files_reveal_in_the_file_manager_instead_of_failing_closed() { + let root = temp_workspace(); + for name in ["release-1.0.4.zip", "backup.tar", "export.7z", "logs.tgz"] { + fs::write(root.join(name), [0x50_u8, 0x4b, 0x03, 0x04]).expect("write archive"); + let planned = plan(&root, name, "relative"); + assert_eq!(planned.response.action, "revealed", "{name}"); + assert_eq!(planned.system_mode, Some("reveal"), "{name}"); + } + fs::remove_dir_all(root).expect("remove temp workspace"); + } + + #[test] + fn home_anchored_absolute_links_expand_against_the_host_home() { + let Some(home) = dirs::home_dir() else { + return; + }; + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let home_dir_name = format!("liveagent-chat-file-links-home-{suffix}"); + let home_target_dir = home.join(&home_dir_name); + if fs::create_dir_all(&home_target_dir).is_err() { + return; + } + fs::write(home_target_dir.join("notes.md"), "# home\n").expect("write home file"); + let root = temp_workspace(); + + let planned = build_chat_file_link_plan( + "conversation-test", + &root.to_string_lossy(), + &format!("~/{home_dir_name}/notes.md"), + "absolute", + None, + None, + None, + false, + ) + .expect("home-anchored link must resolve"); + assert_eq!(planned.response.action, "editor"); + assert!(planned.response.outside_workspace); + + // A workspace entry literally named "~" keeps joining relatively. + fs::create_dir(root.join("~")).expect("create literal tilde dir"); + fs::write(root.join("~/inner.md"), "# inner\n").expect("write literal tilde file"); + let relative = plan(&root, "~/inner.md", "relative"); + assert_eq!(relative.response.action, "editor"); + assert!(!relative.response.outside_workspace); + + fs::remove_dir_all(root).expect("remove temp workspace"); + fs::remove_dir_all(home_target_dir).expect("remove home dir"); + } + #[test] fn workspace_directories_use_file_tree_first_and_support_a_safe_manager_fallback() { let root = temp_workspace(); diff --git a/crates/agent-gui/src/lib/chat/chatFileLinks.ts b/crates/agent-gui/src/lib/chat/chatFileLinks.ts deleted file mode 100644 index fd3608ebe..000000000 --- a/crates/agent-gui/src/lib/chat/chatFileLinks.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 7cf63d3b9..000000000 --- a/crates/agent-gui/src/lib/chat/messages/chatFileLinks.ts +++ /dev/null @@ -1,201 +0,0 @@ -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/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 7958872f6..2bc69452a 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -25,6 +25,7 @@ import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/WorkspaceOverlayHost"; import { useLocale } from "@liveagent/ui/i18n/index"; import { getAutomationState, useAutomation } from "@liveagent/ui/lib/automation/index"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress"; import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; @@ -58,7 +59,6 @@ import { WorkspaceCloneTaskOverlayAdapter } from "../agent-ui-adapters/workspace import { PanelRightClose, PanelRightOpen } from "../components/icons"; import { MacOsTitleBarToggle } from "../components/MacOsTitleBarSpacer"; import type { AppUpdateController } from "../lib/appUpdates"; -import type { ChatFileLink } from "../lib/chat/chatFileLinks"; import type { CompactionStatus } from "../lib/chat/compaction/types"; import { buildRequestContext, diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx index f1ef3c795..ffbe9d096 100644 --- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx +++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx @@ -9,8 +9,8 @@ import { RoundBlockContent, } from "@liveagent/ui/components/chat/assistant-bubble/RoundContent"; import { UsagePanel } from "@liveagent/ui/components/chat/UsagePanel"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; 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"; diff --git a/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx b/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx index c8c00483a..fc082170e 100644 --- a/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx @@ -1,6 +1,5 @@ +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { memo } from "react"; - -import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState"; import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore"; import type { PendingUploadedFile } from "../../../lib/chat/messages/uploadedFiles"; diff --git a/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx b/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx index e994a6f46..8a1c805fa 100644 --- a/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx @@ -1,6 +1,6 @@ import { ChangedFilesCard } from "@liveagent/ui/components/chat/ChangedFilesCard"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { memo, useMemo } from "react"; -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"; diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index 70b628411..0f7a201b8 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -1,5 +1,6 @@ import { Markdown } from "@liveagent/ui/components/Markdown"; import { useLocale } from "@liveagent/ui/i18n/index"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import type { GitClient } from "@liveagent/ui/lib/git/types"; import { createEntranceRegistry } from "@liveagent/ui/lib/transcript-virtual/entranceOnce"; import { createLiveRowScrollAdjustPolicy } from "@liveagent/ui/lib/transcript-virtual/liveScrollAdjustPolicy"; @@ -21,7 +22,6 @@ import { useSyncExternalStore, } from "react"; import { CheckCircle2, ChevronDown } from "../../../components/icons"; -import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; import type { HistoryMessageRef, RenderSummaryCard, diff --git a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts index 3f462b171..2f8b4e332 100644 --- a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts +++ b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts @@ -1,7 +1,7 @@ +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; import type { GitClient } from "@liveagent/ui/lib/git/types"; import type { MutableRefObject } from "react"; -import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; import type { HistoryMessageRef, RenderTimelineItem, diff --git a/crates/agent-gui/test/chat/chat-file-links.test.mjs b/crates/agent-gui/test/chat/chat-file-links.test.mjs index 2ba3a2a57..b5aa0860e 100644 --- a/crates/agent-gui/test/chat/chat-file-links.test.mjs +++ b/crates/agent-gui/test/chat/chat-file-links.test.mjs @@ -10,13 +10,17 @@ const { decodeChatFileLinkPayload, encodeChatFileLink, parseChatFileLink, -} = loader.loadModule("src/lib/chat/messages/chatFileLinks.ts"); +} = loader.loadModule("@liveagent/ui/lib/chat/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" }], + ["/D:/workspace/release/a.zip", { path: "D:/workspace/release/a.zip", source: "absolute" }], + ["/d:/workspace/release/a.zip", { path: "d:/workspace/release/a.zip", source: "absolute" }], + ["~/release/a.zip", { path: "~/release/a.zip", source: "absolute" }], + ["~/work/a.ts:12", { path: "~/work/a.ts", line: 12, 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", diff --git a/crates/agent-ui/src/components/Markdown.tsx b/crates/agent-ui/src/components/Markdown.tsx index 194ee5671..1315f53c8 100644 --- a/crates/agent-ui/src/components/Markdown.tsx +++ b/crates/agent-ui/src/components/Markdown.tsx @@ -11,7 +11,7 @@ import { decodeChatFileLinkPayload, encodeChatFileLink, parseChatFileLink, -} from "@liveagent/app/lib/chat/chatFileLinks"; +} from "../lib/chat/chatFileLinks"; import { openUrl } from "@liveagent/app/shims/tauriOpener"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cjk } from "@streamdown/cjk"; diff --git a/crates/agent-ui/src/components/chat/AssistantBubble.tsx b/crates/agent-ui/src/components/chat/AssistantBubble.tsx index 498ebb95f..105a9741c 100644 --- a/crates/agent-ui/src/components/chat/AssistantBubble.tsx +++ b/crates/agent-ui/src/components/chat/AssistantBubble.tsx @@ -1,6 +1,6 @@ import type { UiRound } from "@liveagent/app/lib/chat/assistantBubbleAdapter"; import { collectChangedFiles } from "@liveagent/app/lib/chat/changedFilesAdapter"; -import type { ChatFileLink } from "@liveagent/app/lib/chat/chatFileLinks"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { memo, useMemo } from "react"; import { AssistantAvatar } from "./AssistantAvatar"; import { RoundContent } from "./assistant-bubble/RoundContent"; diff --git a/crates/agent-ui/src/components/chat/ThinkingActivity.tsx b/crates/agent-ui/src/components/chat/ThinkingActivity.tsx index bcc313fb2..4466ebdf7 100644 --- a/crates/agent-ui/src/components/chat/ThinkingActivity.tsx +++ b/crates/agent-ui/src/components/chat/ThinkingActivity.tsx @@ -1,5 +1,5 @@ import { ChevronRight, Lightbulb } from "@liveagent/app/components/icons"; -import type { ChatFileLink } from "@liveagent/app/lib/chat/chatFileLinks"; +import type { ChatFileLink } from "../../lib/chat/chatFileLinks"; import { useLocale } from "@liveagent/ui/i18n/index"; import { useEffect, useRef, useState } from "react"; import { Markdown } from "../Markdown"; diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx index 5c5baba83..12bee8b0f 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx @@ -4,7 +4,7 @@ import { type UiRound, VIBING_STATUS, } from "@liveagent/app/lib/chat/assistantBubbleAdapter"; -import type { ChatFileLink } from "@liveagent/app/lib/chat/chatFileLinks"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { AssistantStatus, CompactingText, diff --git a/crates/agent-gateway/web/src/lib/chat/chatFileLinks.ts b/crates/agent-ui/src/lib/chat/chatFileLinks.ts similarity index 91% rename from crates/agent-gateway/web/src/lib/chat/chatFileLinks.ts rename to crates/agent-ui/src/lib/chat/chatFileLinks.ts index 7cf63d3b9..ce46a77de 100644 --- a/crates/agent-gateway/web/src/lib/chat/chatFileLinks.ts +++ b/crates/agent-ui/src/lib/chat/chatFileLinks.ts @@ -10,6 +10,12 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^(?:\\\\|\/\/)/; const FILE_URL_PATTERN = /^file:\/\//i; const ABSOLUTE_POSIX_PATH_PATTERN = /^\//; +// Home-anchored paths ("~" or "~/...") resolve on the conversation's host +// device, so they classify as absolute; the host expands the tilde. +const HOME_ANCHORED_PATH_PATTERN = /^~(?:\/|$)/; +// URL-style Windows drive paths ("/D:/work/a.ts") keep a leading slash that +// no filesystem accepts; strip it exactly like the file-url branch does. +const URL_STYLE_DRIVE_PATH_PATTERN = /^\/([a-zA-Z]:\/)/; 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*))?$/; @@ -40,7 +46,8 @@ function isAbsolutePath(path: string) { return ( WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path) || - ABSOLUTE_POSIX_PATH_PATTERN.test(path) + ABSOLUTE_POSIX_PATH_PATTERN.test(path) || + HOME_ANCHORED_PATH_PATTERN.test(path) ); } @@ -137,7 +144,7 @@ export function parseChatFileLink(raw: string): ChatFileLink | null { } const { path, line, endLine, column } = parseTrailingLocation(input); - const normalized = normalizePath(path); + const normalized = normalizePath(path).replace(URL_STYLE_DRIVE_PATH_PATTERN, "$1"); if (isAbsolutePath(normalized)) { return createChatFileLink(normalized, "absolute", { line, endLine, column }); @@ -177,7 +184,7 @@ export function decodeChatFileLinkPayload(payload: string): ChatFileLink | null if (!path || (source !== "absolute" && source !== "relative" && source !== "file-url")) return null; - const normalized = normalizePath(path); + const normalized = normalizePath(path).replace(URL_STYLE_DRIVE_PATH_PATTERN, "$1"); const sourceMatches = source === "relative" ? isSafeRelativePath(normalized) && !isAbsolutePath(normalized) diff --git a/crates/agent-ui/src/lib/chat/openChatFileLink.ts b/crates/agent-ui/src/lib/chat/openChatFileLink.ts index 200d2d202..fa751eb36 100644 --- a/crates/agent-ui/src/lib/chat/openChatFileLink.ts +++ b/crates/agent-ui/src/lib/chat/openChatFileLink.ts @@ -1,4 +1,4 @@ -import type { ChatFileLink } from "@liveagent/app/lib/chat/chatFileLinks"; +import type { ChatFileLink } from "./chatFileLinks"; import { invoke } from "@liveagent/app/shims/tauriCore"; export type OpenChatFileLinkParams = ChatFileLink & {