diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..e3a6198de25 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -123,6 +123,11 @@ import { } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +const TEXT_ATTACHMENT_MAX_BYTES = 1024 * 1024; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -613,6 +618,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -1758,14 +1764,46 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; // ------------------------------------------------------------------ - // Callbacks: images + // Callbacks: attachments // ------------------------------------------------------------------ - const addComposerImages = (files: File[]) => { + const addComposerTextAttachment = async (file: File): Promise => { + if (!gitCwd) return `Could not resolve the workspace path for '${file.name}'.`; + if (file.size > TEXT_ATTACHMENT_MAX_BYTES) { + return `'${file.name}' exceeds the 1 MB text attachment limit.`; + } + const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-") || "context.md"; + const relativePath = `.t3/attachments/${randomUUID()}/${safeName}`; + const result = await file + .arrayBuffer() + .then((buffer) => { + const bytes = new Uint8Array(buffer); + if (bytes.includes(0)) return null; + const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return writeProjectFile({ + environmentId, + input: { cwd: gitCwd, relativePath, contents }, + }); + }) + .catch(() => null); + if (result === null) return `'${file.name}' is not a supported text file.`; + if (result._tag === "Failure") return `Could not attach '${file.name}'.`; + + const currentPrompt = promptRef.current; + const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; + applyPromptReplacement( + currentPrompt.length, + currentPrompt.length, + `${separator}${serializeComposerFileLink(resolvePathLinkTarget(relativePath, gitCwd))} `, + ); + return null; + }; + + const addComposerAttachments = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } @@ -1774,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + error = (await addComposerTextAttachment(file)) ?? error; continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { @@ -1818,7 +1856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + void addComposerAttachments(imageFiles); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -1852,7 +1890,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); - addComposerImages(files); + void addComposerAttachments(files); focusComposer(); }; const handleInterruptPrimaryAction = useCallback(() => { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..b39ebee90dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -338,6 +338,29 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); + it("does not collapse short visible prompts with long file-link destinations", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const fileLinks = Array.from( + { length: 15 }, + (_, index) => + `[test-${index}.ts](/Users/test/project/.t3/attachments/12345678-1234-1234-1234-123456789abc/test-${index}.ts)`, + ); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("test-0.ts"); + expect(markup).toContain("test-14.ts"); + expect(markup).toContain("tell me the contents of each file"); + expect(markup).toContain('data-user-message-body="true"'); + expect(markup).not.toContain("Show full message"); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..87173caba1f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1375,15 +1375,19 @@ const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; +const USER_MESSAGE_FILE_LINK_PATTERN = /\[((?:\\.|[^\]\\])*)\]\([^)\s]+\)/g; function shouldCollapseUserMessage(text: string): boolean { - if (text.trim().length === 0) { + const visibleText = text.replace(USER_MESSAGE_FILE_LINK_PATTERN, (_source, label: string) => + label.replace(/\\(.)/g, "$1"), + ); + if (visibleText.trim().length === 0) { return false; } return ( - text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES + visibleText.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || + visibleText.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES ); }