Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | null> => {
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}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid storing dropped text attachments in tracked workspaces

This writes every dropped text/markdown attachment under .t3/attachments inside the user's project, but the code does not ensure that .t3/ is ignored for arbitrary workspaces. In projects that have not already added that ignore rule, attaching a file creates untracked files that show up in git status and can be accidentally committed along with the user's changes.

Useful? React with 👍 / 👎.

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))} `,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep text attachments inside the new worktree

When the first message is sent in New worktree mode, gitCwd is still the original project root because the worktree has not been created yet, so this inserts an absolute link to .t3/attachments in the original checkout. The bootstrap path then creates the worktree and starts the provider session there, leaving the model with a prompt that points outside its active workspace/sandbox instead of to a file in the worktree.

Useful? React with 👍 / 👎.

);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel drops corrupt prompt

High Severity

Each text attachment appends using applyPromptReplacement at promptRef.current.length captured after its own async work, with no serialization across overlapping addComposerAttachments calls. Concurrent drops can insert links at the same stale offset and splice into the middle of the prompt instead of the end.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f044e40. Configure here.

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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed batch errors overwritten

Medium Severity

In a mixed drop, text failures use error = (await addComposerTextAttachment(file)) ?? error, but image size and count checks assign error = ... directly. A later image error replaces an earlier text error, and a lingering image error can remain after a later text file attaches successfully.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f044e40. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image limit skips text files

Low Severity

When the per-message image cap is reached, addComposerAttachments breaks out of the file loop. Any non-image files later in the same DataTransfer list are never passed to addComposerTextAttachment, so they are dropped silently aside from the image-limit error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f044e40. Configure here.

void addComposerAttachments(imageFiles);
};

const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send races text attachment

Medium Severity

When text files are dropped or pasted, their asynchronous processing to write the file and add its link to the prompt is not awaited. This allows the composer's prompt to be submitted prematurely, resulting in messages sent without the intended attachment link and potentially leaving orphaned attachment files.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f044e40. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent sending while dropped text files are still attaching

For dropped markdown/text files this handler now fire-and-forgets an async path: addComposerAttachments waits for file.arrayBuffer() and projects.writeFile before inserting the markdown link. If the user presses Send before that RPC finishes, onSend snapshots the prompt without the file link and clears it; the async callback then appends the link afterward, so the turn is sent without the attachment and a stale attachment link is left in the composer.

Useful? React with 👍 / 👎.

focusComposer();
};
const handleInterruptPrimaryAction = useCallback(() => {
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
buildUserTimelineEntry([...fileLinks, "tell me the contents of each file"].join(" ")),
]}
/>,
);

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(
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
Loading