carry pasted text through queue, copy and export

Three places still read a message as if all its text were in `content`, so a
paste that crossed the threshold went missing from each.

The Queue button was gated on `canQueueCurrentPrompt`, which is false for any
attachment, and `onQueueClick` returned early on empty composer text. Only the
Enter path got the pasted-text branch in 16d1b72, so a mouse or touch user could
not queue what the keyboard could. Both now go through `queuePastedTextPrompt`.

`CopyButton` copies `getCopyText()`, which reads content alone, so copying a
paste-only turn produced nothing and a turn with an instruction produced only
the instruction. It now appends the pasted bodies.

`messageToText`, `messageToMarkdown` and `messageToPlainText` flatten attachment
text verbatim, so Markdown, CSV, JSONL and fine-tuning exports carried the
`<pasted_text name=... bytes=...>` marker that the same text never had when it
fitted inline. `unwrapPastedTextContent` strips it and returns anything else
untouched, so the `<attachment ...>` wrapper a real file has always exported
with is unchanged.

`attachmentsPastedText` joins with a blank line rather than a space now that
copy uses it; Chat Search collapses whitespace either way.
This commit is contained in:
Maheswar Kumar 2026-08-13 19:45:51 +12:00
parent fdb7792820
commit b5dc502523
5 changed files with 65 additions and 12 deletions

View file

@ -36,6 +36,7 @@ import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { ChatDictationBar } from "@/components/assistant-ui/chat-dictation-bar";
import {
attachmentsPastedText,
isPastedTextFile,
pasteClipboardFiles,
pasteLongTextAsFile,
@ -3706,9 +3707,20 @@ const Composer: FC<{
}
// disableQueue (project new-chat composer) also blocks the queue
// button, so a running thread shows Stop instead of Queue.
queueDisabled={disableQueue || !canQueueCurrentPrompt}
queueDisabled={
disableQueue ||
!(canQueueCurrentPrompt || canQueuePastedTextPrompt)
}
onQueueClick={() => {
if (disableQueue) return;
// Same pasted-text path the Enter key takes, or the button
// would refuse what submitting the form accepts.
if (
canQueuePastedTextPrompt &&
queuePastedTextPrompt(true)
) {
return;
}
const queuedPrompt = composerText.trim();
if (queuedPrompt.length === 0) {
return;
@ -6067,7 +6079,11 @@ const CopyButton: FC = () => {
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = async () => {
const text = aui.message().getCopyText();
// getCopyText reads content only, and a long paste sits in an attachment.
const pasted = attachmentsPastedText(aui.message().getState().attachments);
const text = [aui.message().getCopyText(), pasted]
.filter((part) => part.length > 0)
.join("\n\n");
if (await copyToClipboard(text)) {
setCopied(true);
if (resetTimeoutRef.current) {

View file

@ -147,6 +147,7 @@ export { pasteClipboardFiles } from "./utils/clipboard-files";
export {
PASTED_TEXT_PREVIEW_MAX_CHARS,
attachmentContentText,
attachmentsPastedText,
createPastedTextFile,
isPastedTextContent,
isPastedTextFile,

View file

@ -60,6 +60,7 @@ import { toolResultModelText } from "../api/chat-adapter";
import { usePlusMenuPrefsStore } from "../stores/plus-menu-prefs-store";
import type { ThreadRecord, MessageRecord } from "../types";
import { createConversationMarkdownExporter } from "../utils/conversation-markdown-export";
import { unwrapPastedTextContent } from "../utils/pasted-text.ts";
import {
contentBlocksToMarkdownBlocks,
renderConversationBlocks,
@ -254,7 +255,11 @@ function messageToText(msg: { content: unknown; attachments?: unknown }): string
if (Array.isArray(msg.attachments)) {
for (const attachment of msg.attachments as Array<{ content?: unknown }>) {
if (!attachment?.content) continue;
const attText = contentBlocksToText(attachment.content);
// A paste carries a wrapper the same text never had when it fitted
// inline, so strip it rather than exporting the marker.
const attText = unwrapPastedTextContent(
contentBlocksToText(attachment.content),
);
if (attText) parts.push(attText);
}
}
@ -273,6 +278,10 @@ function messageToMarkdown(msg: { content: unknown; attachments?: unknown }): st
...contentBlocksToMarkdownBlocks(
attachment.content,
normalizeToolResult,
).map((block) =>
block.kind === "text"
? { ...block, text: unwrapPastedTextContent(block.text) }
: block,
),
);
}
@ -623,7 +632,8 @@ function messageToPlainText(msg: {
}
const block = b as Record<string, unknown>;
if (block.type === "text" && typeof block.text === "string" && block.text) {
parts.push(block.text);
// No-op for message content, which never carries the paste wrapper.
parts.push(unwrapPastedTextContent(block.text));
}
}
};

View file

@ -222,17 +222,25 @@ export function attachmentsSample(
return "";
}
/** The whole body of a sent paste, unwrapped. Empty for any other content. */
export function pastedTextContentBody(content: string): string {
if (!isPastedTextContent(content)) return "";
/**
* A pasted body without its wrapper, for anything that shows the text as the
* user pasted it: copy, exports, fine-tuning rows. Other content is untouched.
*/
export function unwrapPastedTextContent(content: string): string {
if (!isPastedTextContent(content)) return content;
const { start, end } = attachmentBodyRange(content);
return content.slice(start, end);
}
/** The whole body of a sent paste, unwrapped. Empty for any other content. */
export function pastedTextContentBody(content: string): string {
return isPastedTextContent(content) ? unwrapPastedTextContent(content) : "";
}
/**
* Every pasted body on a message. Chat Search reads `content` only, so without
* this a paste stops being findable the moment it moves into an attachment.
* Other attachment types were never indexed and stay that way.
* Every pasted body on a message, for the paths that read `content` alone and
* would otherwise lose the paste: Chat Search and copying a message. Other
* attachment types are not included, since neither path ever had them.
*/
export function attachmentsPastedText(
attachments: readonly AttachmentLike[] | undefined,
@ -245,7 +253,7 @@ export function attachmentsPastedText(
if (body.length > 0) bodies.push(body);
}
}
return bodies.join(" ");
return bodies.join("\n\n");
}
function clipboardText(clipboardData: DataTransfer): string {

View file

@ -24,6 +24,7 @@ import {
pastedTextOf,
pastedTextPreview,
shouldAttachPastedText,
unwrapPastedTextContent,
} from "../src/features/chat/utils/pasted-text.ts";
type ClipboardStub = {
@ -296,10 +297,27 @@ test("chat search still finds what the paste moved out of the message", () => {
{ content: [{ type: "text", text: sent }] },
{ content: [{ type: "text", text: second }] },
]),
`${body} second body`,
`${body}\n\nsecond body`,
);
});
test("copies and exports carry the paste, not its wrapper", () => {
const body = "Deploy log\nline two";
const sent = attachmentContentText("Deploy log.txt", body, true, 19);
// The marker is an implementation detail; the same text pasted below the
// threshold never had one.
assert.equal(unwrapPastedTextContent(sent), body);
assert.ok(!unwrapPastedTextContent(sent).includes("pasted_text"));
// Everything else is handed back untouched, including the wrapper a real
// attachment has always exported with.
const attached = attachmentContentText("notes.txt", body, false);
assert.equal(unwrapPastedTextContent(attached), attached);
assert.equal(unwrapPastedTextContent("bare text"), "bare text");
assert.equal(unwrapPastedTextContent(""), "");
});
test("a sample of unwrapped content is left as it is", () => {
// PDF and DOCX adapters write their own prefix with no wrapper.
assert.equal(