From 9219b901e431cdb92e4ec74fa503d8ac9df3439e Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 21:09:38 -0500 Subject: [PATCH 1/4] fix(web): handle pasted folders as workspace references - Separate copied folders from files before attachment validation - Keep attachment labels readable by truncating names before size details --- apps/web/src/components/chat/ChatComposer.tsx | 119 ++++++++++-------- .../components/chat/workspaceFileDrop.test.ts | 52 ++++++++ .../src/components/chat/workspaceFileDrop.ts | 6 +- apps/web/src/components/contextChipParts.tsx | 16 +-- 4 files changed, 129 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 65656ba4f2a0..ea0a0906ebcc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,6 +63,7 @@ import { } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; +import { splitTransferredFiles } from "./workspaceFileDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { @@ -5711,32 +5712,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; }; - const onComposerPaste = (event: React.ClipboardEvent) => { - const files = Array.from(event.clipboardData.files); - const plainText = event.clipboardData.getData("text/plain"); - const bypassAutoAttachment = consumePasteAsTextShortcut(pasteAsTextShortcutUntilRef); - if ( - files.length > 0 && - activeThreadId && - shouldHandleComposerAttachmentPaste({ files, plainText }) - ) { - event.preventDefault(); - event.stopPropagation(); - void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment }); - return; - } - - if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; - if (!foldPastedText(plainText, bypassAutoAttachment)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - }; - // The editor is memoized; fresh closures here re-render it on every parent - // render, which during a stream is every delta. - const onComposerPasteStable = useStableCallback(onComposerPaste); const onComposerCommandKeyStable = useStableCallback(onComposerCommandKey); const isPathQueryActiveStable = useStableCallback( () => resolveActiveComposerTrigger().trigger?.kind === "path", @@ -5801,6 +5776,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const insertComposerTextAtEndStable = useStableCallback(insertComposerTextAtEnd); + const addComposerFolders = (folders: File[]) => { + const target = folderDropTarget({ + localEnvironmentDisabled: isLocalEnvironmentDisabled(), + environmentId, + primaryEnvironmentId, + }); + if (target === "remote") { + toastManager.add({ + type: "error", + title: "Folders can only be referenced in the local desktop environment", + }); + return; + } + for (const folder of folders) { + const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile); + if (path === null) { + toastManager.add({ + type: "error", + title: `Couldn't get the path of "${folder.name}"`, + description: "Type the folder path with @ instead.", + }); + continue; + } + insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { + ensureLeadingBoundary: true, + }); + } + focusComposer(); + }; + const addComposerFoldersStable = useStableCallback(addComposerFolders); + + const onComposerPaste = (event: React.ClipboardEvent) => { + const { files, folders } = splitTransferredFiles(event.clipboardData); + const plainText = event.clipboardData.getData("text/plain"); + const bypassAutoAttachment = consumePasteAsTextShortcut(pasteAsTextShortcutUntilRef); + if ( + folders.length > 0 || + (activeThreadId && + files.length > 0 && + shouldHandleComposerAttachmentPaste({ files, plainText })) + ) { + event.preventDefault(); + event.stopPropagation(); + if (folders.length > 0) addComposerFoldersStable(folders); + if (files.length > 0) { + void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment }); + } + return; + } + + if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; + if (!foldPastedText(plainText, bypassAutoAttachment)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + }; + // The editor is memoized; fresh closures here re-render it on every parent + // render, which during a stream is every delta. + const onComposerPasteStable = useStableCallback(onComposerPaste); const insertContextReferencesAtCaret = ( references: ReadonlyArray, ): boolean => @@ -6073,35 +6109,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!inserted) focusComposer(); }); }, - addDroppedFolders: (folders: File[]) => { - const target = folderDropTarget({ - localEnvironmentDisabled: isLocalEnvironmentDisabled(), - environmentId, - primaryEnvironmentId, - }); - if (target === "remote") { - toastManager.add({ - type: "error", - title: "Folders can't be dropped into remote environments", - }); - return; - } - for (const folder of folders) { - const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile); - if (path === null) { - toastManager.add({ - type: "error", - title: `Couldn't get the path of "${folder.name}"`, - description: "Type the folder path with @ instead.", - }); - continue; - } - insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { - ensureLeadingBoundary: true, - }); - } - focusComposer(); - }, + addDroppedFolders: addComposerFoldersStable, hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 || pendingDraftWork.has(attachmentTargetKey), @@ -6303,6 +6311,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activeThread, addComposerAttachments, + addComposerFoldersStable, foldPastedText, composerDraftTarget, composerModelOptions, diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts index e7efc2864f25..283a9cd22741 100644 --- a/apps/web/src/components/chat/workspaceFileDrop.test.ts +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -1,10 +1,62 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { makeWorkspaceFileDropHandlers, + splitTransferredFiles, type WorkspaceFileDragEvent, type WorkspaceFileDropHost, } from "./workspaceFileDrop"; +describe("splitTransferredFiles", () => { + it("separates copied folders from files before attachment validation", () => { + const folder = new File([], "photos.png"); + const emptyFile = new File([], "empty.txt"); + const image = new File(["image"], "photo.png", { type: "image/png" }); + + expect( + splitTransferredFiles({ + files: [folder, emptyFile, image], + items: [ + { + kind: "string", + getAsFile: () => null, + webkitGetAsEntry: () => null, + }, + { + kind: "file", + getAsFile: () => folder, + webkitGetAsEntry: () => ({ isDirectory: true }), + }, + { + kind: "file", + getAsFile: () => emptyFile, + webkitGetAsEntry: () => ({ isDirectory: false }), + }, + { + kind: "file", + getAsFile: () => image, + webkitGetAsEntry: () => null, + }, + ], + }), + ).toEqual({ files: [emptyFile, image], folders: [folder] }); + }); + + it("ignores clipboard file items that cannot provide a file", () => { + expect( + splitTransferredFiles({ + files: [], + items: [ + { + kind: "file", + getAsFile: () => null, + webkitGetAsEntry: () => ({ isDirectory: true }), + }, + ], + }), + ).toEqual({ files: [], folders: [] }); + }); +}); + function makeDragEvent(options?: { types?: string[]; files?: File[]; diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts index 9ac85419f178..7f7019dff4ca 100644 --- a/apps/web/src/components/chat/workspaceFileDrop.ts +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -30,7 +30,9 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); } -function splitDroppedItems(dataTransfer: WorkspaceFileDragEvent["dataTransfer"]): { +export function splitTransferredFiles( + dataTransfer: Pick, +): { files: File[]; folders: File[]; } { @@ -76,7 +78,7 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { if (!isFileDrag(event)) return; event.preventDefault(); host.setDragActive(false); - const { files, folders } = splitDroppedItems(event.dataTransfer); + const { files, folders } = splitTransferredFiles(event.dataTransfer); if (files.length > 0) host.addFiles(files); if (folders.length > 0) host.addFolders(folders); }, diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index fc874f1e489d..4260a415127c 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -201,9 +201,11 @@ export function ImageChipButton({ ) : ( )} - {middleTruncateAttachmentName(name)} - {size} - {suffix ? {suffix} : null} + + {middleTruncateAttachmentName(name)} + {size} + {suffix ? {suffix} : null} + ); } @@ -275,11 +277,11 @@ function FileChipContent(props: { ) : ( )} - - {middleTruncateAttachmentName(props.name)} + + {middleTruncateAttachmentName(props.name)} + {props.size} + {props.suffix ? {props.suffix} : null} - {props.size} - {props.suffix ? {props.suffix} : null} ); } From b89e05faaa173417248f83abc36a8f46827412bc Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 21:48:04 -0500 Subject: [PATCH 2/4] fix(web): report rejected folder references --- apps/web/src/components/chat/ChatComposer.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ea0a0906ebcc..ae5b1bc54671 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5799,9 +5799,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); continue; } - insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { + const inserted = insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { ensureLeadingBoundary: true, }); + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add folder", + description: + pendingUserInputs.length > 0 + ? "Finish answering the current question, then add the folder to your next message." + : "The composer is busy; try again once it is ready.", + }); + break; + } } focusComposer(); }; From 4e460b11270a8caa9e25af2b05f5072651d7cf90 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 22:02:29 -0500 Subject: [PATCH 3/4] fix(web): allow folder references in question answers --- apps/web/src/components/chat/ChatComposer.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ae5b1bc54671..f87ddab1371f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5730,7 +5730,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) text.length === 0 || isConnecting || isComposerApprovalState || - pendingUserInputs.length > 0 || + activePendingIsResponding || + (pendingUserInputs.length > 0 && !activePendingProgress?.activeQuestion) || projectSelectionRequired || (options?.citationCommentAnchor && !composerEditorRef.current) ) { @@ -5806,10 +5807,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toastManager.add({ type: "error", title: "Unable to add folder", - description: - pendingUserInputs.length > 0 - ? "Finish answering the current question, then add the folder to your next message." - : "The composer is busy; try again once it is ready.", + description: "The composer is busy; try again once it is ready.", }); break; } From eb6a3b05043096baa6fc380626130880d55aac04 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 22:15:38 -0500 Subject: [PATCH 4/4] chore: rerun CI against repaired main