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..8e830c2d263 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"; @@ -232,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"; @@ -1211,6 +1213,12 @@ function ChatViewContent(props: ChatViewProps) { () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); + const composerHostEnvironment = environmentById.get(environmentId); + const canResolveComposerHostPaths = canResolveComposerHostFilePaths( + 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) => @@ -4953,7 +4961,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}`; @@ -6145,6 +6155,8 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + canResolveHostFilePaths={canResolveComposerHostPaths} + environmentPlatformOs={composerEnvironmentPlatformOs} 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..fdaec108ab4 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,125 @@ 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("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(); + 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/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..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, @@ -47,6 +48,12 @@ import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; +import { + composerMentionPathFromAbsolute, + hostPathUsableOnPlatform, + partitionDroppedComposerFiles, + resolveOsDroppedFilePath, +} from "./composerFileDrop"; import { type ComposerImageAttachment, type DraftId, @@ -600,6 +607,8 @@ export interface ChatComposerProps { keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; + canResolveHostFilePaths: boolean; + environmentPlatformOs: ExecutionEnvironmentPlatformOs | null; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -691,6 +700,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, terminalOpen, gitCwd, + canResolveHostFilePaths, + environmentPlatformOs, promptRef, composerRef, composerImagesRef, @@ -2408,13 +2419,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ + const resolveDroppedFileAbsolutePath = useCallback( + (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 + // the editor's paste command); this resolves the path they are mentioned by. + const resolvePastedFilePath = useCallback( + (file: File): string | null => { + const absolutePath = resolveDroppedFileAbsolutePath(file); + if (absolutePath === null) return null; + return composerMentionPathFromAbsolute(absolutePath, gitCwd); + }, + [gitCwd, resolveDroppedFileAbsolutePath], + ); + 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 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) => { @@ -2447,8 +2489,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), + resolveDroppedFileAbsolutePath, + 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, + `'${firstUnresolved}' can't be mentioned in this environment. Only image files can be attached.`, + ); + } + 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 +3149,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..d392e7bbba2 --- /dev/null +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + canResolveComposerHostFilePaths, + composerMentionPathFromAbsolute, + hostPathUsableOnPlatform, + partitionDroppedComposerFiles, + workspaceRelativeDropPath, +} from "./composerFileDrop.ts"; + +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("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( + "src/app.ts", + ); + }); + + 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", () => { + 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(); + }); + + 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", () => { + 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", + ); + }); + + 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", () => { + 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..3e167d229b2 --- /dev/null +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -0,0 +1,156 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; +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("\\", "/"); +} + +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"; +} + +/** + * 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 + * 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 || 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(/\/+$/, ""); + const normalizedPath = isWindows ? normalizePathSeparators(absolutePath) : absolutePath; + const comparableRoot = isWindows ? normalizedRoot.toLowerCase() : normalizedRoot; + const comparablePath = isWindows ? normalizedPath.toLowerCase() : normalizedPath; + 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; +} + +/** + * The path a dropped or pasted OS file should be mentioned by: + * 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) ?? + (isWindowsAbsolutePath(absolutePath) ? normalizePathSeparators(absolutePath) : 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..291d18565ec 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -14,6 +14,66 @@ 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(); + // 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 = rangeSelection.isBackward() ? rangeSelection.focus : rangeSelection.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(" ")); + } + 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(); + return true; } export function registerComposerInlineTokenPaste( @@ -27,7 +87,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 = {},