From 404a12032461fa381e93fb65cca6c591b533c16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Ingi?= Date: Thu, 6 Aug 2026 18:30:14 +0000 Subject: [PATCH 1/6] feat(web): insert path mentions for non-image file drops and pastes Dropping or pasting a non-image file into the composer previously failed with "Unsupported file type ... Please attach image files only." Referencing a file by dragging it into the prompt is a standard workflow in comparable tools, so non-image files now become file mention chips instead: - Drop: non-image files are partitioned away from the image-attachment flow and inserted as mentions at the end of the prompt, workspace-relative when the file lives inside the repo, absolute otherwise. Directories (empty MIME type) work the same way. Mixed drops attach the images and mention the rest. - Paste: the editor's paste command inserts mention chips at the cursor for non-image clipboard files, leaving images to the existing attachment path. - Desktop bridge: expose webUtils.getPathForFile as an optional DesktopBridge.getPathForFile, since Electron >= 32 removed File.path and the renderer cannot learn a dropped file's location otherwise. Browser tabs have no OS path access and keep the previous behavior. - Thread titles: seed titles now render file links as their basename instead of raw "[name](path)" markup. This also fixes titles for existing file-tree drag mentions. No contract or server protocol changes: mentions are plain prompt text, so remote environments and every provider handle them unchanged. --- apps/desktop/src/preload.ts | 3 +- apps/web/src/components/ChatView.tsx | 5 +- .../src/components/ComposerPromptEditor.tsx | 16 ++- apps/web/src/components/chat/ChatComposer.tsx | 51 ++++++- .../components/chat/composerFileDrop.test.ts | 135 ++++++++++++++++++ .../src/components/chat/composerFileDrop.ts | 110 ++++++++++++++ .../components/composerInlineTokenPaste.ts | 60 +++++++- packages/contracts/src/ipc.ts | 6 + .../shared/src/composerInlineTokens.test.ts | 44 +++++- packages/shared/src/composerInlineTokens.ts | 24 ++++ 10 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/chat/composerFileDrop.test.ts create mode 100644 apps/web/src/components/chat/composerFileDrop.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..e430a0a82f7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -5,7 +5,7 @@ import type { DesktopPreviewTabState, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + getPathForFile: (file: File) => webUtils.getPathForFile(file), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c260c9e9118..d37c2784d0a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,6 +39,7 @@ import { resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; +import { replaceComposerFileLinksWithBasenames } from "@t3tools/shared/composerInlineTokens"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; @@ -4953,7 +4954,9 @@ function ChatViewContent(props: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = trimmed; + // Mention markup would read as raw "[name](path)" in the thread list; + // seed the title with basenames the way the composer renders them. + let titleSeed = replaceComposerFileLinksWithBasenames(trimmed).trim(); if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 67b82388bbc..b9b081aedd5 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -897,6 +897,7 @@ interface ComposerPromptEditorProps { event: KeyboardEvent, ) => boolean; onPaste: React.ClipboardEventHandler; + resolvePastedFilePath?: (file: File) => string | null; editorRef: React.RefObject; } @@ -1244,16 +1245,20 @@ function ComposerChipSelectionPlugin() { return null; } -function ComposerInlineTokenPastePlugin() { +function ComposerInlineTokenPastePlugin(props: { + resolvePastedFilePath?: (file: File) => string | null; +}) { const [editor] = useLexicalComposerContext(); + const { resolvePastedFilePath } = props; useEffect( () => registerComposerInlineTokenPaste(editor, { createMentionNode: $createComposerMentionNode, getExpandedAbsoluteOffsetForPoint, + ...(resolvePastedFilePath ? { resolvePastedFilePath } : {}), }), - [editor], + [editor, resolvePastedFilePath], ); return null; @@ -1537,6 +1542,7 @@ function ComposerPromptEditorInner({ onChange, onCommandKeyDown, onPaste, + resolvePastedFilePath, editorRef, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); @@ -1779,7 +1785,9 @@ function ComposerPromptEditorInner({ - + @@ -1799,6 +1807,7 @@ export function ComposerPromptEditor({ onChange, onCommandKeyDown, onPaste, + resolvePastedFilePath, editorRef, }: ComposerPromptEditorProps) { const initialValueRef = useRef(value); @@ -1838,6 +1847,7 @@ export function ComposerPromptEditor({ editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} + {...(resolvePastedFilePath ? { resolvePastedFilePath } : {})} /> ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f6d34315dac..c7712873e33 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -47,6 +47,11 @@ import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; +import { + composerMentionPathFromAbsolute, + partitionDroppedComposerFiles, + resolveOsDroppedFilePath, +} from "./composerFileDrop"; import { type ComposerImageAttachment, type DraftId, @@ -2417,6 +2422,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerImages(imageFiles); }; + // Pasted non-image files become mention chips at the cursor (handled inside + // the editor's paste command); this resolves the path they are mentioned by. + const resolvePastedFilePath = useCallback( + (file: File): string | null => { + const absolutePath = resolveOsDroppedFilePath(file); + if (absolutePath === null) return null; + return composerMentionPathFromAbsolute(absolutePath, gitCwd); + }, + [gitCwd], + ); + const onComposerDragEnter = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); @@ -2447,8 +2463,38 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event.preventDefault(); dragDepthRef.current = 0; setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - void addComposerImages(files); + // Images keep the attachment flow; other files become path mentions so + // the agent reads them where they already live on disk. + const dropped = partitionDroppedComposerFiles( + Array.from(event.dataTransfer.files), + resolveOsDroppedFilePath, + gitCwd, + ); + if (dropped.imageFiles.length > 0) { + void addComposerImages(dropped.imageFiles); + } + // After addComposerImages: its synchronous validation clears the thread + // error, and this message must survive a mixed drop. + const firstUnresolved = dropped.unresolvedFileNames[0]; + if (firstUnresolved !== undefined && activeThreadId) { + setThreadError( + activeThreadId, + `Unsupported file type for '${firstUnresolved}'. Please attach image files only.`, + ); + } + if (dropped.mentionText !== null) { + // No focusComposer() here: the insert path focuses on the next frame, + // and focusing synchronously during the drop would sync stale editor + // state back over the inserted mention. + if (!insertComposerTextAtEnd(dropped.mentionText, { ensureLeadingBoundary: true })) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + } + return; + } focusComposer(); }; @@ -3077,6 +3123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} onPaste={onComposerPaste} + resolvePastedFilePath={resolvePastedFilePath} placeholder={ isComposerApprovalState ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") diff --git a/apps/web/src/components/chat/composerFileDrop.test.ts b/apps/web/src/components/chat/composerFileDrop.test.ts new file mode 100644 index 00000000000..266a580b204 --- /dev/null +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + composerMentionPathFromAbsolute, + partitionDroppedComposerFiles, + workspaceRelativeDropPath, +} from "./composerFileDrop.ts"; + +const file = (name: string, type: string) => ({ name, type }); + +describe("workspaceRelativeDropPath", () => { + it("relativizes a path inside the workspace", () => { + expect(workspaceRelativeDropPath("/Users/me/repo/src/app.ts", "/Users/me/repo")).toBe( + "src/app.ts", + ); + }); + + it("ignores workspace root casing and trailing separators", () => { + expect(workspaceRelativeDropPath("/Users/Me/Repo/notes.txt", "/users/me/repo/")).toBe( + "notes.txt", + ); + }); + + it("normalizes Windows separators", () => { + expect(workspaceRelativeDropPath("C:\\repo\\logs\\app.log", "C:\\repo")).toBe("logs/app.log"); + }); + + it("returns null for paths outside the workspace", () => { + expect(workspaceRelativeDropPath("/tmp/other.log", "/Users/me/repo")).toBeNull(); + }); + + it("refuses prefix matches that are not directory boundaries", () => { + expect(workspaceRelativeDropPath("/Users/me/repo-copy/a.txt", "/Users/me/repo")).toBeNull(); + }); + + it("returns null without a workspace root", () => { + expect(workspaceRelativeDropPath("/Users/me/repo/a.txt", null)).toBeNull(); + }); +}); + +describe("composerMentionPathFromAbsolute", () => { + it("prefers the workspace-relative path", () => { + expect(composerMentionPathFromAbsolute("/Users/me/repo/src/app.ts", "/Users/me/repo")).toBe( + "src/app.ts", + ); + }); + + it("falls back to the normalized absolute path", () => { + expect(composerMentionPathFromAbsolute("C:\\other\\notes.txt", "/Users/me/repo")).toBe( + "C:/other/notes.txt", + ); + }); +}); + +describe("partitionDroppedComposerFiles", () => { + it("routes images to the attachment flow untouched", () => { + const image = file("shot.png", "image/png"); + const result = partitionDroppedComposerFiles([image], () => null, null); + expect(result.imageFiles).toEqual([image]); + expect(result.mentionText).toBeNull(); + expect(result.unresolvedFileNames).toEqual([]); + }); + + it("turns a non-image file with a workspace path into a relative mention", () => { + const result = partitionDroppedComposerFiles( + [file("app.log", "text/plain")], + () => "/Users/me/repo/logs/app.log", + "/Users/me/repo", + ); + expect(result.mentionText).toBe("[app.log](logs/app.log) "); + expect(result.imageFiles).toEqual([]); + expect(result.unresolvedFileNames).toEqual([]); + }); + + it("keeps the absolute path for files outside the workspace", () => { + const result = partitionDroppedComposerFiles( + [file("test.mp3", "audio/mpeg")], + () => "/Users/me/Downloads/test.mp3", + "/Users/me/repo", + ); + expect(result.mentionText).toBe("[test.mp3](/Users/me/Downloads/test.mp3) "); + }); + + it("handles directories, which carry an empty MIME type", () => { + const result = partitionDroppedComposerFiles( + [file("fixtures", "")], + () => "/Users/me/repo/test/fixtures", + "/Users/me/repo", + ); + expect(result.mentionText).toBe("[fixtures](test/fixtures) "); + }); + + it("splits a mixed drop between attachments and mentions", () => { + const image = file("shot.png", "image/png"); + const result = partitionDroppedComposerFiles( + [image, file("data.csv", "text/csv")], + () => "/Users/me/repo/data.csv", + "/Users/me/repo", + ); + expect(result.imageFiles).toEqual([image]); + expect(result.mentionText).toBe("[data.csv](data.csv) "); + }); + + it("joins multiple mentions into a single insert", () => { + const paths: Record = { + "a.log": "/repo/a.log", + "b.log": "/repo/b.log", + }; + const result = partitionDroppedComposerFiles( + [file("a.log", "text/plain"), file("b.log", "text/plain")], + (dropped) => paths[dropped.name] ?? null, + "/repo", + ); + expect(result.mentionText).toBe("[a.log](a.log) [b.log](b.log) "); + }); + + it("reports non-image files without a resolvable path", () => { + const result = partitionDroppedComposerFiles( + [file("test.mp3", "audio/mpeg")], + () => null, + "/Users/me/repo", + ); + expect(result.mentionText).toBeNull(); + expect(result.unresolvedFileNames).toEqual(["test.mp3"]); + }); + + it("encodes paths with spaces as valid mention links", () => { + const result = partitionDroppedComposerFiles( + [file("my notes.txt", "text/plain")], + () => "/Users/me/repo/docs/my notes.txt", + "/Users/me/repo", + ); + expect(result.mentionText).toBe("[my notes.txt](docs/my%20notes.txt) "); + }); +}); diff --git a/apps/web/src/components/chat/composerFileDrop.ts b/apps/web/src/components/chat/composerFileDrop.ts new file mode 100644 index 00000000000..0933d6d90fe --- /dev/null +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -0,0 +1,110 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +/** + * The subset of File the drop partition reads. Kept structural so the pure + * logic is testable without constructing DOM File objects. + */ +export interface DroppedFileLike { + readonly name: string; + readonly type: string; +} + +export interface DroppedComposerFilePartition { + /** Files routed to the existing image-attachment flow. */ + readonly imageFiles: T[]; + /** Prompt text (trailing space included) for path mentions, or null. */ + readonly mentionText: string | null; + /** Non-image files whose on-disk path could not be resolved. */ + readonly unresolvedFileNames: string[]; +} + +function normalizePathSeparators(path: string): string { + return path.replaceAll("\\", "/"); +} + +/** + * Relativize an OS path against the workspace root, or null when the path is + * outside it. Comparison is case-insensitive: the dominant filesystems on + * macOS and Windows are, and a false negative merely falls back to the + * absolute path. + */ +export function workspaceRelativeDropPath( + absolutePath: string, + workspaceRoot: string | null, +): string | null { + if (workspaceRoot === null) return null; + const normalizedRoot = normalizePathSeparators(workspaceRoot).replace(/\/+$/, ""); + if (normalizedRoot.length === 0) return null; + const normalizedPath = normalizePathSeparators(absolutePath); + const rootPrefix = `${normalizedRoot.toLowerCase()}/`; + if (!normalizedPath.toLowerCase().startsWith(rootPrefix)) return null; + const relativePath = normalizedPath.slice(rootPrefix.length); + return relativePath.length > 0 ? relativePath : null; +} + +/** + * The path a dropped or pasted OS file should be mentioned by: + * workspace-relative when inside the workspace, the (separator-normalized) + * absolute path otherwise. + */ +export function composerMentionPathFromAbsolute( + absolutePath: string, + workspaceRoot: string | null, +): string { + return ( + workspaceRelativeDropPath(absolutePath, workspaceRoot) ?? normalizePathSeparators(absolutePath) + ); +} + +/** + * Split an OS file drop: images keep the attachment flow, everything else + * becomes a path mention (workspace-relative when the file lives inside the + * workspace) so the agent can read the file where it already is. Files whose + * path cannot be resolved (browser builds have no OS path access) are + * reported by name for the caller to surface. + */ +export function partitionDroppedComposerFiles( + files: ReadonlyArray, + resolvePath: (file: T) => string | null, + workspaceRoot: string | null, +): DroppedComposerFilePartition { + const imageFiles: T[] = []; + const mentions: string[] = []; + const unresolvedFileNames: string[] = []; + for (const file of files) { + if (file.type.startsWith("image/")) { + imageFiles.push(file); + continue; + } + const absolutePath = resolvePath(file); + if (absolutePath === null || absolutePath.length === 0) { + unresolvedFileNames.push(file.name); + continue; + } + mentions.push( + serializeComposerFileLink(composerMentionPathFromAbsolute(absolutePath, workspaceRoot)), + ); + } + return { + imageFiles, + mentionText: mentions.length > 0 ? `${mentions.join(" ")} ` : null, + unresolvedFileNames, + }; +} + +/** + * Resolve the on-disk path of an OS-dropped File via the desktop bridge. + * Returns null outside the desktop shell (browsers expose no OS path) and on + * shells predating the bridge method. + */ +export function resolveOsDroppedFilePath(file: File): string | null { + if (typeof window === "undefined") return null; + const getPathForFile = window.desktopBridge?.getPathForFile; + if (getPathForFile === undefined) return null; + try { + const path = getPathForFile(file); + return path.length > 0 ? path : null; + } catch { + return null; + } +} diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index f43091f09cb..6c045ce049e 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -14,6 +14,64 @@ import { interface ComposerInlineTokenPasteOptions { createMentionNode: (path: string) => LexicalNode; getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number; + /** + * Resolve a pasted OS file to the path it should be mentioned by, or null + * when no path is available (browser builds). Non-image pasted files then + * become mention chips at the cursor; image files are left for the + * attachment paste handler either way. + */ + resolvePastedFilePath?: (file: File) => string | null; +} + +function $insertPastedFileMentions( + event: ClipboardEvent, + clipboardData: DataTransfer, + options: ComposerInlineTokenPasteOptions, +): boolean { + const resolvePastedFilePath = options.resolvePastedFilePath; + if (resolvePastedFilePath === undefined) { + return false; + } + const paths: string[] = []; + for (const file of Array.from(clipboardData.files)) { + if (file.type.startsWith("image/")) { + continue; + } + const path = resolvePastedFilePath(file); + if (path !== null && path.length > 0) { + paths.push(path); + } + } + if (paths.length === 0) { + return false; + } + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + const nodes: LexicalNode[] = []; + const startPoint = selection.isBackward() ? selection.focus : selection.anchor; + const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( + startPoint.getNode(), + startPoint.offset, + ); + const precedingChar = $getRoot() + .getTextContent() + .slice(insertionOffset - 1, insertionOffset); + if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { + nodes.push($createTextNode(" ")); + } + for (const path of paths) { + nodes.push(options.createMentionNode(path)); + // Mention tokens need trailing whitespace to stay valid in the + // serialized prompt. + nodes.push($createTextNode(" ")); + } + selection.insertNodes(nodes); + // Stop the editor's text paste; the event still bubbles, so the composer's + // paste handler attaches any image files from the same clipboard. + event.preventDefault(); + return true; } export function registerComposerInlineTokenPaste( @@ -27,7 +85,7 @@ export function registerComposerInlineTokenPaste( return false; } if (event.clipboardData.files.length > 0) { - return false; + return $insertPastedFileMentions(event, event.clipboardData, options); } const text = event.clipboardData.getData("text/plain"); if (text.length === 0) { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 43167bbf0c3..fe38dc049cb 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1019,6 +1019,12 @@ export interface DesktopBridge { setWslDistro: (distro: string | null) => Promise; setWslOnly: (enabled: boolean) => Promise; pickFolder: (options?: PickFolderOptions) => Promise; + /** + * Resolve the on-disk path of a File dropped from the OS. Electron >= 32 + * removed `File.path`, so the renderer has no other way to learn where a + * dropped file lives. Optional: absent on web builds and older shells. + */ + getPathForFile?: (file: File) => string; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 5a7c14f1725..bc01709f91a 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; +import { + collectComposerInlineTokens, + replaceComposerFileLinksWithBasenames, +} from "./composerInlineTokens.ts"; describe("collectComposerInlineTokens", () => { it("collects file links, mentions, and skills with source ranges", () => { @@ -130,3 +133,42 @@ describe("collectComposerInlineTokens", () => { ]); }); }); + +describe("replaceComposerFileLinksWithBasenames", () => { + it("replaces a file link with its basename", () => { + expect(replaceComposerFileLinksWithBasenames("Check [app.log](logs/app.log) for errors")).toBe( + "Check app.log for errors", + ); + }); + + it("replaces absolute-path links from OS file drops", () => { + expect( + replaceComposerFileLinksWithBasenames( + "[Rólegur kúreki.mp3](/Users/kr/R%C3%B3legur%20k%C3%BAreki.mp3)", + ), + ).toBe("Rólegur kúreki.mp3"); + }); + + it("handles a trimmed prompt that ends with a link", () => { + expect(replaceComposerFileLinksWithBasenames("Fix [a.ts](src/a.ts)")).toBe("Fix a.ts"); + }); + + it("replaces multiple links and leaves surrounding text alone", () => { + expect(replaceComposerFileLinksWithBasenames("[a.ts](src/a.ts) vs [b.ts](lib/b.ts) diff")).toBe( + "a.ts vs b.ts diff", + ); + }); + + it("leaves @-mentions, skills, and plain text untouched", () => { + expect(replaceComposerFileLinksWithBasenames("Use $ui with @src/Chat.tsx please")).toBe( + "Use $ui with @src/Chat.tsx please", + ); + expect(replaceComposerFileLinksWithBasenames("no tokens here")).toBe("no tokens here"); + }); + + it("handles Windows separators in link destinations", () => { + expect(replaceComposerFileLinksWithBasenames("[app.log](C:%5Crepo%5Capp.log) tail")).toBe( + "app.log tail", + ); + }); +}); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index dda548059df..e64bc67d8bf 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -80,6 +80,30 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { return matches; } +/** + * Replace serialized file-link mentions ("[app.log](logs/app.log)") with + * their basename so prompt-derived text reads like the composer renders it. + * Used for thread-title seeds; @-mentions and skill tokens are already short + * and stay untouched. + */ +export function replaceComposerFileLinksWithBasenames(text: string): string { + // Tokens require trailing whitespace; the appended newline lets a mention + // at the end of trimmed text match without shifting any token offsets. + const tokens = collectComposerInlineTokens(`${text}\n`); + let result = ""; + let cursor = 0; + for (const token of tokens) { + if (token.type !== "mention" || !token.source.startsWith("[") || token.start < cursor) { + continue; + } + result += text.slice(cursor, token.start); + const separatorIndex = Math.max(token.value.lastIndexOf("/"), token.value.lastIndexOf("\\")); + result += separatorIndex >= 0 ? token.value.slice(separatorIndex + 1) : token.value; + cursor = token.end; + } + return result + text.slice(cursor); +} + export function collectComposerInlineTokens( text: string, options: CollectComposerInlineTokensOptions = {}, From 07c45e3996ba3da57be3ab76e79de413161be3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Ingi?= Date: Thu, 6 Aug 2026 18:47:16 +0000 Subject: [PATCH 2/6] fix(web): keep file drop mentions on accessible paths --- apps/web/src/components/ChatView.tsx | 5 ++ .../components/ComposerPromptEditor.test.ts | 86 ++++++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 42 ++++++--- .../components/chat/composerFileDrop.test.ts | 25 +++++- .../src/components/chat/composerFileDrop.ts | 29 +++++-- 5 files changed, 164 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d37c2784d0a..c01598ae3cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -233,6 +233,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { canResolveComposerHostFilePaths } from "./chat/composerFileDrop"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -1212,6 +1213,9 @@ function ChatViewContent(props: ChatViewProps) { () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); + const canResolveComposerHostPaths = canResolveComposerHostFilePaths( + environmentById.get(environmentId)?.entry.target._tag ?? null, + ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const draftThread = useComposerDraftStore((store) => @@ -6148,6 +6152,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + canResolveHostFilePaths={canResolveComposerHostPaths} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0aab8fb0c2d..39bd4a4202a 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -15,10 +15,10 @@ import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; class TestClipboardEvent extends Event { readonly clipboardData: DataTransfer; - constructor(text: string) { + constructor(text: string, files: readonly File[] = []) { super("paste", { cancelable: true }); this.clipboardData = { - files: [], + files, getData: (type: string) => (type === "text/plain" ? text : ""), } as unknown as DataTransfer; } @@ -71,6 +71,88 @@ describe("registerComposerInlineTokenPaste", () => { ); }); + it("inserts pasted non-image files as mentions and leaves images to the attachment handler", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const plainTextFallback = vi.fn(() => true); + const resolvePastedFilePath = vi.fn((file: File) => + file.name === "app.ts" ? "src/app.ts" : null, + ); + const image = { name: "shot.png", type: "image/png" } as File; + const source = { name: "app.ts", type: "text/plain" } as File; + + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode("Ask")); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 3, + resolvePastedFilePath, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent("", [image, source]); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(resolvePastedFilePath).toHaveBeenCalledOnce(); + expect(resolvePastedFilePath).toHaveBeenCalledWith(source); + expect(plainTextFallback).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + "Ask ", + ); + }); + + it("leaves an unavailable pasted file for the parent error handler", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const plainTextFallback = vi.fn(() => false); + const source = { name: "app.ts", type: "text/plain" } as File; + + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode("Ask")); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 3, + resolvePastedFilePath: () => null, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent("", [source]); + let handled = true; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(false); + expect(plainTextFallback).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(false); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe("Ask"); + }); + it.each([ "yarn expo install @expo/ui", "npm install @jane/foo.js", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c7712873e33..85a02f5f18a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -605,6 +605,7 @@ export interface ChatComposerProps { keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; + canResolveHostFilePaths: boolean; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -696,6 +697,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, terminalOpen, gitCwd, + canResolveHostFilePaths, promptRef, composerRef, composerImagesRef, @@ -2413,26 +2415,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ - const onComposerPaste = (event: React.ClipboardEvent) => { - const files = Array.from(event.clipboardData.files); - if (files.length === 0) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; - event.preventDefault(); - void addComposerImages(imageFiles); - }; + const resolveDroppedFileAbsolutePath = useCallback( + (file: File): string | null => + canResolveHostFilePaths ? resolveOsDroppedFilePath(file) : null, + [canResolveHostFilePaths], + ); // Pasted non-image files become mention chips at the cursor (handled inside // the editor's paste command); this resolves the path they are mentioned by. const resolvePastedFilePath = useCallback( (file: File): string | null => { - const absolutePath = resolveOsDroppedFilePath(file); + const absolutePath = resolveDroppedFileAbsolutePath(file); if (absolutePath === null) return null; return composerMentionPathFromAbsolute(absolutePath, gitCwd); }, - [gitCwd], + [gitCwd, resolveDroppedFileAbsolutePath], ); + const onComposerPaste = (event: React.ClipboardEvent) => { + const files = Array.from(event.clipboardData.files); + if (files.length === 0) return; + event.preventDefault(); + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length > 0) { + void addComposerImages(imageFiles); + } + const firstUnresolved = files.find( + (file) => !file.type.startsWith("image/") && resolveDroppedFileAbsolutePath(file) === null, + ); + if (firstUnresolved !== undefined && activeThreadId) { + setThreadError( + activeThreadId, + `'${firstUnresolved.name}' can't be mentioned in this environment. Only image files can be attached.`, + ); + } + }; + const onComposerDragEnter = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); @@ -2467,7 +2485,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // the agent reads them where they already live on disk. const dropped = partitionDroppedComposerFiles( Array.from(event.dataTransfer.files), - resolveOsDroppedFilePath, + resolveDroppedFileAbsolutePath, gitCwd, ); if (dropped.imageFiles.length > 0) { @@ -2479,7 +2497,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (firstUnresolved !== undefined && activeThreadId) { setThreadError( activeThreadId, - `Unsupported file type for '${firstUnresolved}'. Please attach image files only.`, + `'${firstUnresolved}' can't be mentioned in this environment. Only image files can be attached.`, ); } if (dropped.mentionText !== null) { diff --git a/apps/web/src/components/chat/composerFileDrop.test.ts b/apps/web/src/components/chat/composerFileDrop.test.ts index 266a580b204..63e97d64d2f 100644 --- a/apps/web/src/components/chat/composerFileDrop.test.ts +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { + canResolveComposerHostFilePaths, composerMentionPathFromAbsolute, partitionDroppedComposerFiles, workspaceRelativeDropPath, @@ -8,6 +9,16 @@ import { const file = (name: string, type: string) => ({ name, type }); +describe("canResolveComposerHostFilePaths", () => { + it("allows only the primary same-host environment", () => { + expect(canResolveComposerHostFilePaths("PrimaryConnectionTarget")).toBe(true); + expect(canResolveComposerHostFilePaths("BearerConnectionTarget")).toBe(false); + expect(canResolveComposerHostFilePaths("SshConnectionTarget")).toBe(false); + expect(canResolveComposerHostFilePaths("RelayConnectionTarget")).toBe(false); + expect(canResolveComposerHostFilePaths(null)).toBe(false); + }); +}); + describe("workspaceRelativeDropPath", () => { it("relativizes a path inside the workspace", () => { expect(workspaceRelativeDropPath("/Users/me/repo/src/app.ts", "/Users/me/repo")).toBe( @@ -15,10 +26,16 @@ describe("workspaceRelativeDropPath", () => { ); }); - it("ignores workspace root casing and trailing separators", () => { - expect(workspaceRelativeDropPath("/Users/Me/Repo/notes.txt", "/users/me/repo/")).toBe( - "notes.txt", - ); + it("ignores Windows path casing and trailing separators", () => { + expect( + workspaceRelativeDropPath("C:\\Users\\Me\\Repo\\notes.txt", "c:\\users\\me\\repo\\"), + ).toBe("notes.txt"); + }); + + it("preserves case when comparing POSIX paths", () => { + expect( + workspaceRelativeDropPath("/home/alice/repo/secrets.txt", "/home/alice/Repo"), + ).toBeNull(); }); it("normalizes Windows separators", () => { diff --git a/apps/web/src/components/chat/composerFileDrop.ts b/apps/web/src/components/chat/composerFileDrop.ts index 0933d6d90fe..623d108970c 100644 --- a/apps/web/src/components/chat/composerFileDrop.ts +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -1,3 +1,4 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; /** @@ -22,11 +23,25 @@ function normalizePathSeparators(path: string): string { return path.replaceAll("\\", "/"); } +function isWindowsAbsolutePath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"); +} + +/** + * A desktop renderer can only hand an agent host paths when the selected + * environment is the primary backend on that same host. Saved remotes, SSH, + * relays, and desktop-local WSL backends have different filesystems. + */ +export function canResolveComposerHostFilePaths( + targetTag: ConnectionTarget["_tag"] | null, +): boolean { + return targetTag === "PrimaryConnectionTarget"; +} + /** * Relativize an OS path against the workspace root, or null when the path is - * outside it. Comparison is case-insensitive: the dominant filesystems on - * macOS and Windows are, and a false negative merely falls back to the - * absolute path. + * outside it. Windows paths compare case-insensitively; POSIX paths preserve + * case so Linux workspaces cannot accidentally resolve to a different file. */ export function workspaceRelativeDropPath( absolutePath: string, @@ -36,8 +51,12 @@ export function workspaceRelativeDropPath( const normalizedRoot = normalizePathSeparators(workspaceRoot).replace(/\/+$/, ""); if (normalizedRoot.length === 0) return null; const normalizedPath = normalizePathSeparators(absolutePath); - const rootPrefix = `${normalizedRoot.toLowerCase()}/`; - if (!normalizedPath.toLowerCase().startsWith(rootPrefix)) return null; + const compareCaseInsensitive = + isWindowsAbsolutePath(absolutePath) && isWindowsAbsolutePath(workspaceRoot); + const comparableRoot = compareCaseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + const comparablePath = compareCaseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const rootPrefix = `${comparableRoot}/`; + if (!comparablePath.startsWith(rootPrefix)) return null; const relativePath = normalizedPath.slice(rootPrefix.length); return relativePath.length > 0 ? relativePath : null; } From fc07cad97595f48d066cc8de88e196e654c1d403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Ingi?= Date: Thu, 6 Aug 2026 18:57:40 +0000 Subject: [PATCH 3/6] fix(web): stop rewriting backslashes in POSIX drop paths Backslash is a valid filename character on POSIX, so normalizing it to "/" turned a dropped /repo/a\b.txt into a mention for a/b.txt, a different or nonexistent file. Separator normalization (and the case-insensitive compare) now applies only when both sides are Windows paths. --- .../components/chat/composerFileDrop.test.ts | 10 +++++++++ .../src/components/chat/composerFileDrop.ts | 22 +++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/composerFileDrop.test.ts b/apps/web/src/components/chat/composerFileDrop.test.ts index 63e97d64d2f..20ed526d155 100644 --- a/apps/web/src/components/chat/composerFileDrop.test.ts +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -53,6 +53,10 @@ describe("workspaceRelativeDropPath", () => { it("returns null without a workspace root", () => { expect(workspaceRelativeDropPath("/Users/me/repo/a.txt", null)).toBeNull(); }); + + it("preserves backslashes in POSIX filenames", () => { + expect(workspaceRelativeDropPath("/Users/me/repo/a\\b.txt", "/Users/me/repo")).toBe("a\\b.txt"); + }); }); describe("composerMentionPathFromAbsolute", () => { @@ -67,6 +71,12 @@ describe("composerMentionPathFromAbsolute", () => { "C:/other/notes.txt", ); }); + + it("preserves backslashes in POSIX paths outside the workspace", () => { + expect(composerMentionPathFromAbsolute("/tmp/a\\b.txt", "/Users/me/repo")).toBe( + "/tmp/a\\b.txt", + ); + }); }); describe("partitionDroppedComposerFiles", () => { diff --git a/apps/web/src/components/chat/composerFileDrop.ts b/apps/web/src/components/chat/composerFileDrop.ts index 623d108970c..cd3c0e64da3 100644 --- a/apps/web/src/components/chat/composerFileDrop.ts +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -42,19 +42,22 @@ export function canResolveComposerHostFilePaths( * Relativize an OS path against the workspace root, or null when the path is * outside it. Windows paths compare case-insensitively; POSIX paths preserve * case so Linux workspaces cannot accidentally resolve to a different file. + * Backslashes count as separators only in Windows paths; on POSIX they are + * valid filename characters and pass through untouched. */ export function workspaceRelativeDropPath( absolutePath: string, workspaceRoot: string | null, ): string | null { if (workspaceRoot === null) return null; - const normalizedRoot = normalizePathSeparators(workspaceRoot).replace(/\/+$/, ""); + const isWindows = isWindowsAbsolutePath(absolutePath) && isWindowsAbsolutePath(workspaceRoot); + const normalizedRoot = ( + isWindows ? normalizePathSeparators(workspaceRoot) : workspaceRoot + ).replace(/\/+$/, ""); if (normalizedRoot.length === 0) return null; - const normalizedPath = normalizePathSeparators(absolutePath); - const compareCaseInsensitive = - isWindowsAbsolutePath(absolutePath) && isWindowsAbsolutePath(workspaceRoot); - const comparableRoot = compareCaseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; - const comparablePath = compareCaseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const normalizedPath = isWindows ? normalizePathSeparators(absolutePath) : absolutePath; + const comparableRoot = isWindows ? normalizedRoot.toLowerCase() : normalizedRoot; + const comparablePath = isWindows ? normalizedPath.toLowerCase() : normalizedPath; const rootPrefix = `${comparableRoot}/`; if (!comparablePath.startsWith(rootPrefix)) return null; const relativePath = normalizedPath.slice(rootPrefix.length); @@ -63,15 +66,16 @@ export function workspaceRelativeDropPath( /** * The path a dropped or pasted OS file should be mentioned by: - * workspace-relative when inside the workspace, the (separator-normalized) - * absolute path otherwise. + * workspace-relative when inside the workspace, the absolute path otherwise + * (separator-normalized only when it is a Windows path). */ export function composerMentionPathFromAbsolute( absolutePath: string, workspaceRoot: string | null, ): string { return ( - workspaceRelativeDropPath(absolutePath, workspaceRoot) ?? normalizePathSeparators(absolutePath) + workspaceRelativeDropPath(absolutePath, workspaceRoot) ?? + (isWindowsAbsolutePath(absolutePath) ? normalizePathSeparators(absolutePath) : absolutePath) ); } From ded9627d13ea958c6a8bd70ec686eb34839b6bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Ingi?= Date: Thu, 6 Aug 2026 19:01:47 +0000 Subject: [PATCH 4/6] fix(web): handle root workspaces and Unicode casing in drop paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two workspaceRelativeDropPath fixes: - A POSIX workspace root of "/" trimmed to an empty string and bailed, so files under a filesystem-root workspace were mentioned by absolute path. The empty-root guard now checks the original input, and the separator appended for the prefix check restores the trimmed root. - The relative slice offset came from the lowercased comparable prefix, whose length can differ from the original for some Unicode (e.g. "İ" lowercases to two code points), truncating the result. Slice by the original root's length instead, and require the boundary character to be the separator so any residual misalignment falls back to the absolute path rather than a wrong relative one. --- .../src/components/chat/composerFileDrop.test.ts | 14 ++++++++++++++ apps/web/src/components/chat/composerFileDrop.ts | 15 ++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/composerFileDrop.test.ts b/apps/web/src/components/chat/composerFileDrop.test.ts index 20ed526d155..547828652a7 100644 --- a/apps/web/src/components/chat/composerFileDrop.test.ts +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -57,6 +57,20 @@ describe("workspaceRelativeDropPath", () => { it("preserves backslashes in POSIX filenames", () => { expect(workspaceRelativeDropPath("/Users/me/repo/a\\b.txt", "/Users/me/repo")).toBe("a\\b.txt"); }); + + it("relativizes against a filesystem-root workspace", () => { + expect(workspaceRelativeDropPath("/var/log/app.log", "/")).toBe("var/log/app.log"); + expect(workspaceRelativeDropPath("/", "/")).toBeNull(); + }); + + it("returns null for an empty workspace root", () => { + expect(workspaceRelativeDropPath("/var/log/app.log", "")).toBeNull(); + }); + + it("keeps the slice aligned when lowercasing changes string length", () => { + // "İ" (U+0130) lowercases to a two-code-point sequence. + expect(workspaceRelativeDropPath("C:\\İstanbul\\a.txt", "C:\\İstanbul")).toBe("a.txt"); + }); }); describe("composerMentionPathFromAbsolute", () => { diff --git a/apps/web/src/components/chat/composerFileDrop.ts b/apps/web/src/components/chat/composerFileDrop.ts index cd3c0e64da3..89db6af57dc 100644 --- a/apps/web/src/components/chat/composerFileDrop.ts +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -49,18 +49,23 @@ export function workspaceRelativeDropPath( absolutePath: string, workspaceRoot: string | null, ): string | null { - if (workspaceRoot === null) return null; + if (workspaceRoot === null || workspaceRoot.length === 0) return null; const isWindows = isWindowsAbsolutePath(absolutePath) && isWindowsAbsolutePath(workspaceRoot); + // A filesystem-root workspace ("/") trims to an empty string here; the + // separator appended for the prefix check below restores it. const normalizedRoot = ( isWindows ? normalizePathSeparators(workspaceRoot) : workspaceRoot ).replace(/\/+$/, ""); - if (normalizedRoot.length === 0) return null; const normalizedPath = isWindows ? normalizePathSeparators(absolutePath) : absolutePath; const comparableRoot = isWindows ? normalizedRoot.toLowerCase() : normalizedRoot; const comparablePath = isWindows ? normalizedPath.toLowerCase() : normalizedPath; - const rootPrefix = `${comparableRoot}/`; - if (!comparablePath.startsWith(rootPrefix)) return null; - const relativePath = normalizedPath.slice(rootPrefix.length); + if (!comparablePath.startsWith(`${comparableRoot}/`)) return null; + // Slice by the original root's length, not the comparable prefix's: + // lowercasing can change string length for some Unicode (e.g. "İ"), and a + // mismatched offset must fall back to the absolute path, so require the + // boundary in the original path to be the separator itself. + if (normalizedPath[normalizedRoot.length] !== "/") return null; + const relativePath = normalizedPath.slice(normalizedRoot.length + 1); return relativePath.length > 0 ? relativePath : null; } From 8ef7683a35e3c4144c7b04c70c9af035ceb789a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Ingi?= Date: Thu, 6 Aug 2026 19:15:15 +0000 Subject: [PATCH 5/6] fix(web): reject cross-platform host paths for file mentions In desktop WSL-only mode the WSL server occupies the primary backend slot, so the connection-target gate passed while webUtils.getPathForFile returned Windows host paths the Linux-side agent cannot read. Dropped files were mentioned as C:/... paths pointing nowhere. The resolver now also compares the resolved path's style against the selected environment's platform.os and treats mismatches as unresolvable, surfacing the existing per-environment error instead. Mismatches are rejected rather than translated because WSL mount roots are configurable and a guessed /mnt/c/... path would silently point at a nonexistent file. --- apps/web/src/components/ChatView.tsx | 6 ++++- apps/web/src/components/chat/ChatComposer.tsx | 14 ++++++++--- .../components/chat/composerFileDrop.test.ts | 23 +++++++++++++++++++ .../src/components/chat/composerFileDrop.ts | 18 +++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c01598ae3cf..8e830c2d263 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1213,9 +1213,12 @@ function ChatViewContent(props: ChatViewProps) { () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); + const composerHostEnvironment = environmentById.get(environmentId); const canResolveComposerHostPaths = canResolveComposerHostFilePaths( - environmentById.get(environmentId)?.entry.target._tag ?? null, + composerHostEnvironment?.entry.target._tag ?? null, ); + const composerEnvironmentPlatformOs = + composerHostEnvironment?.serverConfig?.environment.platform.os ?? null; const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const draftThread = useComposerDraftStore((store) => @@ -6153,6 +6156,7 @@ function ChatViewContent(props: ChatViewProps) { terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} canResolveHostFilePaths={canResolveComposerHostPaths} + environmentPlatformOs={composerEnvironmentPlatformOs} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 85a02f5f18a..29bc65725c0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,6 +1,7 @@ import type { ApprovalRequestId, EnvironmentId, + ExecutionEnvironmentPlatformOs, ModelSelection, PreviewAnnotationPayload, ProviderApprovalDecision, @@ -49,6 +50,7 @@ import { } from "./composerMentionDrag"; import { composerMentionPathFromAbsolute, + hostPathUsableOnPlatform, partitionDroppedComposerFiles, resolveOsDroppedFilePath, } from "./composerFileDrop"; @@ -606,6 +608,7 @@ export interface ChatComposerProps { terminalOpen: boolean; gitCwd: string | null; canResolveHostFilePaths: boolean; + environmentPlatformOs: ExecutionEnvironmentPlatformOs | null; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -698,6 +701,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalOpen, gitCwd, canResolveHostFilePaths, + environmentPlatformOs, promptRef, composerRef, composerImagesRef, @@ -2416,9 +2420,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: paste / drag // ------------------------------------------------------------------ const resolveDroppedFileAbsolutePath = useCallback( - (file: File): string | null => - canResolveHostFilePaths ? resolveOsDroppedFilePath(file) : null, - [canResolveHostFilePaths], + (file: File): string | null => { + if (!canResolveHostFilePaths) return null; + const absolutePath = resolveOsDroppedFilePath(file); + if (absolutePath === null) return null; + return hostPathUsableOnPlatform(absolutePath, environmentPlatformOs) ? absolutePath : null; + }, + [canResolveHostFilePaths, environmentPlatformOs], ); // Pasted non-image files become mention chips at the cursor (handled inside diff --git a/apps/web/src/components/chat/composerFileDrop.test.ts b/apps/web/src/components/chat/composerFileDrop.test.ts index 547828652a7..d392e7bbba2 100644 --- a/apps/web/src/components/chat/composerFileDrop.test.ts +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { canResolveComposerHostFilePaths, composerMentionPathFromAbsolute, + hostPathUsableOnPlatform, partitionDroppedComposerFiles, workspaceRelativeDropPath, } from "./composerFileDrop.ts"; @@ -19,6 +20,28 @@ describe("canResolveComposerHostFilePaths", () => { }); }); +describe("hostPathUsableOnPlatform", () => { + it("rejects Windows renderer paths for a POSIX environment (WSL-only mode)", () => { + expect(hostPathUsableOnPlatform("C:\\Users\\me\\file.txt", "linux")).toBe(false); + expect(hostPathUsableOnPlatform("\\\\wsl$\\Ubuntu\\home\\me\\file.txt", "linux")).toBe(false); + }); + + it("accepts paths whose style matches the environment", () => { + expect(hostPathUsableOnPlatform("C:\\Users\\me\\file.txt", "windows")).toBe(true); + expect(hostPathUsableOnPlatform("/Users/me/file.txt", "darwin")).toBe(true); + expect(hostPathUsableOnPlatform("/home/me/file.txt", "linux")).toBe(true); + }); + + it("rejects POSIX paths for a Windows environment", () => { + expect(hostPathUsableOnPlatform("/home/me/file.txt", "windows")).toBe(false); + }); + + it("allows unknown platforms to preserve behavior", () => { + expect(hostPathUsableOnPlatform("C:\\Users\\me\\file.txt", "unknown")).toBe(true); + expect(hostPathUsableOnPlatform("/home/me/file.txt", null)).toBe(true); + }); +}); + describe("workspaceRelativeDropPath", () => { it("relativizes a path inside the workspace", () => { expect(workspaceRelativeDropPath("/Users/me/repo/src/app.ts", "/Users/me/repo")).toBe( diff --git a/apps/web/src/components/chat/composerFileDrop.ts b/apps/web/src/components/chat/composerFileDrop.ts index 89db6af57dc..3e167d229b2 100644 --- a/apps/web/src/components/chat/composerFileDrop.ts +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -1,4 +1,5 @@ import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; /** @@ -38,6 +39,23 @@ export function canResolveComposerHostFilePaths( return targetTag === "PrimaryConnectionTarget"; } +/** + * A resolved host path is only meaningful when the renderer and the selected + * environment share a filesystem, which the connection target alone cannot + * establish: in desktop WSL-only mode the primary slot is a Linux server on a + * Windows host, so getPathForFile yields Windows paths the agent cannot read. + * Mismatched path styles are rejected rather than translated because WSL + * mount roots are configurable, and a guessed /mnt/c/... path would silently + * point the agent at a nonexistent file. + */ +export function hostPathUsableOnPlatform( + absolutePath: string, + environmentOs: ExecutionEnvironmentPlatformOs | null, +): boolean { + if (environmentOs === null || environmentOs === "unknown") return true; + return isWindowsAbsolutePath(absolutePath) === (environmentOs === "windows"); +} + /** * Relativize an OS path against the workspace root, or null when the path is * outside it. Windows paths compare case-insensitively; POSIX paths preserve From d544e80f02fae4d51247d962747a35f4158928ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Ingi?= Date: Thu, 6 Aug 2026 19:17:51 +0000 Subject: [PATCH 6/6] fix(web): keep pasted file mentions when the selection is not a range A paste can land while a chip is node-selected, so $getSelection() is not a range selection and $insertPastedFileMentions bailed without inserting. Nothing downstream recovers: the default paste cannot turn files into mentions and the composer's paste handler prevents it while only reporting unresolvable files, so resolvable files vanished with no chip and no error. Fall back to a range selection at the end of the prompt instead of bailing. --- .../components/ComposerPromptEditor.test.ts | 37 +++++++++++++++++++ .../components/composerInlineTokenPaste.ts | 12 +++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 39bd4a4202a..fdaec108ab4 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -116,6 +116,43 @@ describe("registerComposerInlineTokenPaste", () => { ); }); + it("appends pasted file mentions when the selection is not a range", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const source = { name: "app.ts", type: "text/plain" } as File; + + // No selectEnd(): the paste lands with no range selection, as it does + // when a chip is node-selected. + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode("Ask")); + $getRoot().append(paragraph); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 3, + resolvePastedFilePath: () => "src/app.ts", + }); + + const event = new TestClipboardEvent("", [source]); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + "Ask ", + ); + }); + it("leaves an unavailable pasted file for the parent error handler", () => { vi.stubGlobal("ClipboardEvent", TestClipboardEvent); const editor = createEditor(); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 6c045ce049e..291d18565ec 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -46,11 +46,13 @@ function $insertPastedFileMentions( return false; } const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return false; - } + // The selection is not always a range when the paste lands (a chip can be + // node-selected). Bailing here would drop the files entirely: the default + // paste cannot turn them into mentions and the composer's paste handler + // prevents it anyway, so fall back to inserting at the end of the prompt. + const rangeSelection = $isRangeSelection(selection) ? selection : $getRoot().selectEnd(); const nodes: LexicalNode[] = []; - const startPoint = selection.isBackward() ? selection.focus : selection.anchor; + const startPoint = rangeSelection.isBackward() ? rangeSelection.focus : rangeSelection.anchor; const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( startPoint.getNode(), startPoint.offset, @@ -67,7 +69,7 @@ function $insertPastedFileMentions( // serialized prompt. nodes.push($createTextNode(" ")); } - selection.insertNodes(nodes); + rangeSelection.insertNodes(nodes); // Stop the editor's text paste; the event still bubbles, so the composer's // paste handler attaches any image files from the same clipboard. event.preventDefault();