feat(web-shell): fold oversized pastes into an attachment card (#12084)

A paste carrying text/plain fell through to the editor and was inserted at any
size, so a long paste became a wall of text the user could neither review nor
edit, and three limits downstream failed with nothing said at paste time: the
daemon rejects a prompt body above 10 MB, a mid-turn insertion carries at most
16,384 characters, and the text-only session draft stops being restorable after
a reload.

A paste of at least 200 lines or 8,000 characters now folds into an attachment
card when its UTF-8 size is within the attachment upload ceiling; a larger paste
stays inline, because an attachment that size could never be sent. The card is
the existing chip, named and titled by the beginning of the paste itself so the
sent message shows the string the card showed, carrying a size and one action
that moves the text into the editor as a single undoable edit. The name keeps
its .txt suffix: the bridge re-derives a stored attachment's type from its name,
so a name without one would resolve the text as an opaque blob.

Exemptions keep today's behavior wherever there is no prompt to attach to: a
disabled composer, shell mode, a host with attachments turned off, a paste that
would replace selected text, and a paste that would leave a `!` or `/` command
leading the draft. A folded paste also outranks a pending follow-up suggestion,
which an empty draft would otherwise submit in its place.

Folding does not reduce what the model sees — an uploaded attachment is resolved
back into prompt text before the turn is built. The gain is in composing and
transport. Design notes and the accepted Risks are recorded in
docs/design/web-shell/web-shell-paste-as-attachment.md.

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-17 11:03:27 +00:00
1 parent b82e02fa37
commit 91a868d9c1
12 files changed
+1824 -44

No files matched your search

@@ -0,0 +1,226 @@
# Folding oversized pasted text into an attachment
[English](web-shell-paste-as-attachment.md) | [简体中文](web-shell-paste-as-attachment.zh-CN.md)
## Problem
The Web Shell composer intercepts pasted images and files, but never text. A
paste that carries `text/plain` falls through to the editor and is inserted
inline at any size, so a long paste becomes a wall of text in the input box: the
user cannot review or edit it, and their own words are pushed out of view.
Three limits make oversized inline text fail later with nothing said at paste
time. The daemon rejects a prompt body above 10 MB, so the send fails outright.
A mid-turn insertion carries at most 16,384 characters, so a long queued prompt
cannot be inserted into the running turn. The per-session draft keeps only text,
and its storage failures are swallowed, so an oversized paste silently stops
being restorable across a reload.
Folding does not reduce what the model sees. An uploaded attachment is resolved
back into prompt text before the turn is built, so the content reaches the model
either way. The gain is in composing and transport, not in context.
## Behavior
A paste is folded into an attachment card when the pasted fragment alone is at
least 200 lines or at least 8,000 characters, and its UTF-8 size is within the
attachment upload limit. Shorter pastes are inserted inline exactly as today.
The two conditions are measured on the pasted fragment, not on the resulting
draft: characters are counted as string length and lines as newline count plus
one.
Two states keep a paste inline whatever its size: a disabled composer, and a
shell command being composed. Neither has a prompt to attach to — shell mode
in particular would silently drop the `!` prefix and send the script to the
model instead of running it — so both behave exactly as they do today. A host
with attachments turned off is a third exemption, for the same reason: there is
no card to fold into.
The 8,000-character condition is deliberate rather than incidental. A fragment
left inline stays under the 16,384-character mid-turn insertion limit, so it can
still be inserted into a running turn. That limit bounds one queued message, not
the draft as a whole — several sub-threshold pastes, or one plus typing, can
still exceed it — so the guarantee is about the fragment, and the wording here
says no more than that. The line condition is what makes the rule legible to a
user ("two hundred lines is too long"); the character condition is the floor
that keeps it honest, since a single minified line can carry megabytes.
A paste above the attachment upload limit is not folded, because an attachment
that large cannot be sent: folding it would turn a payload the shell accepts
today into one that fails at send time. The ceiling is the same one the dropped
file path already applies, so a paste and a file of identical size now fail the
same way — by staying inline rather than by becoming a card that cannot upload.
The card is the existing attachment chip, widened for text and rendered
alongside pasted images and files above the input box. It is titled by the
beginning of the paste itself — line breaks and runs of whitespace collapse to
single spaces, characters a file name cannot carry become spaces too, and the
result is clipped short — because that title is also the attachment's name, and
a paste whose first line is one word still gets a title that reads. The card's
one action leads the second line and the size follows it: **Show inline**.
Choosing that action removes the card and inserts the full text at the caret as
a single undoable edit on the CodeMirror editor; the card does not come back.
Submitting without choosing it sends the content as an attachment. The card also
keeps the existing attachment preview (on its title) and removal.
Content stays in the user's control: the card is a default, not a decision made
for them, and the text is one click away in either direction of the submit.
A paste that replaces selected text stays inline to preserve replacement and undo behavior. Command detection checks both the existing and resulting draft: shell (`!`) and slash (`/`) commands stay inline, whether pasted in full, given pasted arguments, or preceded by pasted text, so folding cannot change command routing or discard arguments. A folded card also outranks a pending follow-up suggestion: folding leaves the draft empty, which is the state a suggestion is submitted from, so the paste is what submitting sends. Expanding a card is isolated from adjacent CodeMirror undo steps.
## Design
Folding produces an ordinary pasted file: name, media type, and text, placed in
the same attachment list that pasted images and files already use. Chip
rendering, removal, preview, upload at submit, rollback on failure, and the
optimistic user-message echo are existing behavior and are reused unchanged; the
wire format is untouched.
A folded paste is named after its own title, with a `.txt` extension and the
`text/plain` media type, so the message that goes out shows the string the card
showed. The suffix is load-bearing rather than cosmetic: the bridge re-derives a
stored attachment's type from its stored name, so a name without it resolves the
content as an opaque blob instead of text. The title is built to survive the
attachment-name sanitizer for the same reason — a character a file name cannot
carry becomes a space, and an astral character the title window cuts in half is
dropped, because the attachment token carries the name through
`encodeURIComponent`.
Text-drop is deliberately not covered. A drag that carries text from inside the
composer is indistinguishable from one that carries text from another window,
and folding the former would leave the original text in place and add a card for
a copy of it. Paste is the gesture this increment promises.
Only a paste aimed at the composer's editor is folded. The handlers that carry
this rule sit on the composer surface, which also contains controls that take
text of their own — the history search box today, a host-provided footer
tomorrow — and those must keep receiving their own pastes.
When a clipboard carries both text and an image, text keeps today's precedence:
the text is folded (or inserted) and the image is not ingested. The fold never
changes which representation wins.
These thresholds are deliberately larger than the terminal UI's own large-paste
thresholds, which fold a paste into a text placeholder inside a single-line
prompt. The two surfaces optimize different things and neither can import the
other's module, so each owns its numbers; the divergence is recorded here so a
future tuning pass does not read it as drift.
There is no daemon capability gate. Every daemon the Web Shell talks to today
serves attachment upload, and a gate would add a branch that cannot be exercised
by any supported deployment. The accepted consequence is recorded under Risks.
### Retention and the draft
What happens to folded content depends on how far the user got, and only the
middle case needs code.
Once a prompt is submitted, the attachment is already uploaded and stored by the
daemon, so a reload or a session re-entry recovers it from the daemon side.
If a send fails or is rejected, the existing restore path returns the prompt
content to the input box and keeps the attachment on the failed message row for
retry. A folded card is the same kind of object and inherits both behaviors.
Before a send, the card lives in composer memory exactly as pasted images and
files do today. This increment does not add persistent storage for the text:
the per-session draft is text-only, and durable attachment storage exists on the
daemon side already.
Nothing guards an unsubmitted card against a reload, and an inline paste of the
same size used to survive one through the composer draft — so for a paste up to
the draft's quota this is a real reduction in recoverability, not only an
edge case for oversized input. A `beforeunload` confirmation was considered and
rejected: it would fire for every unsubmitted attachment rather than for folded
pastes alone, and the shell has programmatic reload paths of its own that such a
prompt can interrupt. The loss stays bounded because the content was pasted and
is usually still on the clipboard, and because a reload only discards an unsent
draft — never content the daemon already holds.
Showing the card's text in the editor must not re-encode or re-upload anything:
the card and the expanded text share one in-memory copy.
## Scope and non-goals
In scope: the fold predicate and its two thresholds, the card's title and size
display, and the one action that moves content into the editor.
Not in scope: folding for a text drag; persistent storage for unsubmitted folded
text; a reload guard; content-hash deduplication of repeated pastes; extension
sniffing; folding for pasted text inside other dialogs (a scheduled-task prompt
keeps its own editor and its own limits); and changes to the mid-turn insertion
route.
The mid-turn insertion affordance is unchanged. A queued prompt that carries an
attachment already hides Insert, so a folded paste simply removes that
affordance instead of failing when the user presses it. Teaching insertion to
carry attachments is a separate increment, and it must also address the
child-side 100,000-character resource limit, which drops an oversized inserted
resource silently.
## Risks
- **No capability gate.** On a daemon without attachment upload, sending a
folded paste fails instead of degrading. Accepted deliberately: no supported
deployment is in that state, and an unreachable branch is worse than a
documented one.
- **Folded text is session-scoped.** The attachment lives with the session and
is released with it, so a restored conversation shows the attachment token
rather than the body. Inline text does not have this property, which is why
the one-way move into the editor has to stay prominent and cheap.
- **No context saving.** Both forms reach the model. A user expecting the fold
to protect the context window will be surprised.
- **Threshold perception.** A dense 150-line paste folds at the character
condition even though it is under the line condition. The card's
content-derived title and its size keep that legible: the title names what was
folded, and the size says how much.
- **An unsubmitted card does not survive a reload.** The composer keeps it in
memory, like a pasted image or file, and nothing warns before the reload
discards it.
- **An undo after showing the text discards the paste.** The card is already
gone when the text is inserted, so undoing that one edit leaves the content in
neither place. The alternative — tracking the insert so an undo restores the
card — is machinery this increment does not pay for; the content came from the
clipboard, and pasting again costs one gesture.
- **The single-undo promise covers the editor backend only.** On the touch
textarea the insert is an ordinary controlled-value write with no app-level
undo step, and the native stack's behavior there was not verified. The card is
removed either way, so the paste can end up in neither place.
- **A shell command submit discards the card.** A card folded in chat mode
survives into shell mode, and the shell path carries no attachments, so
submitting a command clears the card without sending it and without a word.
Pasted images and dropped files already behave this way, so the increment
neither introduces nor fixes the loss; it is recorded because folded content
is what a user would least want dropped silently.
## Affected components and validation
- `packages/web-shell/client/hooks/useComposerCore.ts`: the paste predicate, the
folded-card state, and the move-into-editor transaction.
- A new small composer-side helper module: thresholds, the fold predicate, the
attachment ceiling, and card construction (name, media type, text).
- `packages/web-shell/client/components/ChatEditor.tsx`: the card's title, size,
and action, alongside the existing chip.
- `packages/web-shell/client/utils/imageIngestion.ts`: reuse of the existing
attachment-name sanitization, deduplication, and size ceiling.
- `packages/web-shell/client/i18n.tsx`: the new card action label, in both
languages.
- Collocated unit tests beside each changed file, and a browser spec tagged for
the pull-request gate.
Validation: unit tests for the predicate boundaries (199/200 lines, 7,999/8,000
characters, a single very long line, text with no trailing newline, a paste at
and above the attachment ceiling), for card construction and naming (including
a second folded paste), for a paste aimed at another control on the composer
surface, for selected-text replacement and `!`/`/` command detection, for the
shell-mode, disabled-composer, and disabled-attachment exemptions, for a folded
card winning over a pending follow-up suggestion, for the move-into-editor edit
being a single undo, for the card's size label (a card without a size shows
none), for the fold and the move on the touch textarea backend, and for a folded
card reaching the submit payload as an attachment. Browser verification covers the paste-to-card path, the inline
default, and the move into the editor; the send path is verified at the actions
layer, because the browser mock daemon has no attachment store. See `.qwen/e2e-tests/web-shell-paste-as-attachment.md` for
the commands and the results.
No open design questions. The follow-up recorded above (attachment-carrying
mid-turn insertion) is a deliberate exclusion, not an unresolved decision.
@@ -0,0 +1,163 @@
# 超大粘贴文本折叠为附件
[English](web-shell-paste-as-attachment.md) | [简体中文](web-shell-paste-as-attachment.zh-CN.md)
## 问题
Web Shell 的输入框会拦截粘贴进来的图片和文件,但从不拦截文本。带 `text/plain`
的粘贴会直接落到编辑器里,多长都原样插入,于是一段长粘贴就变成输入框里的一堵墙:
用户既看不清也改不动,自己敲的字还被顶出视野。
超长内联文本还会在后面的三个环节失败,而粘贴当下不会有任何提示。daemon 拒绝超过
10 MB 的请求体,所以发送会直接失败。回合中插入最多 16,384 个字符,所以一条长的排队
消息无法插进正在跑的回合。会话草稿只存文本、且写失败被静默吞掉,所以超大粘贴会在刷新
后悄悄失去可恢复性。
折叠并不减少模型看到的内容。上传的附件在建轮次之前会被还原成 prompt 正文,
所以两条路径最终都把内容交给了模型。收益在书写与传输,不在上下文。
## 行为
当被粘贴的那一段内容达到 200 行、或达到 8,000 个字符,且其 UTF-8 大小在附件上传上限
之内时,粘贴会折叠成一张附件卡片。更短的粘贴和今天一样内联插入。两个条件都以
**被粘贴的那一段**为度量对象,而不是粘贴之后的整份草稿:字符按字符串长度计,
行按换行数加一计。
有两种状态无论多长都保持内联:输入框被禁用时,以及正在编写 shell 命令时。
两者都没有可挂附件的 prompt——尤其是 shell 模式,折叠会静默丢掉 `!` 前缀,
把脚本当成聊天消息发给模型而不是执行它——所以两者完全保持今天的行为。
关闭了附件能力的主机是第三种豁免,理由相同:没有卡片可折叠。
8,000 字符这一条是刻意选的,不是随手取的。留在输入框里的单段内容压在
中途插入的 16,384 字符上限之下,因此仍然可以插进正在跑的回合。
那条上限约束的是**一条排队消息**,而不是整份草稿——几段各自低于阈值的粘贴,
或者一段粘贴加打字,依然可能超过它——所以这里的保证只针对单段内容,措辞不多说一分。
行数条件让规则对用户可读(“两百行太长了”),字符条件则是保住这条规则诚实性的兜底,
因为压缩成一行的内容可以有几个 MB。
超过附件上传上限的粘贴不折叠:那么大的附件根本发不出去,折叠等于把一个今天还能发出的
载荷变成发送时才失败的载荷。这条上限与拖入文件那条路径用的是同一个,于是同样大小的
粘贴和文件从此以同一种方式失败——留在输入框里,而不是变成一张传不上去的卡片。
卡片是现有附件芯片的加宽形态,和粘贴的图片、文件一起排在输入框上方。
它的标题取自**正文开头本身**——先把换行与连续空白折叠成单个空格,文件名承载不了的
字符也折成空格,再截短——因为这条标题同时就是附件的名字;这样首行只有一个词时,
标题依然是能读的一段话。第二行以卡片唯一的动作开头,大小紧随其后:**显示到输入框**。
选择该动作会移除卡片,并把全文以一次可撤销的编辑插入到光标处(仅限 CodeMirror
编辑器后端),卡片不再回来。
不选择它直接发送,则按附件发送。卡片同时保留既有的附件预览(点标题)与移除。
内容始终在用户手里:卡片是默认值,不是替用户做的决定;提交前后两个方向都只差一次点击。
粘贴替换选中文字时保持内联,以保留选区替换与撤销行为。命令判断同时检查原草稿与粘贴后的完整草稿:shell(`!`)和斜杠(`/`)命令无论是整段粘贴、粘贴参数,还是在已有命令前粘贴正文,都保持内联,避免折叠改变命令路由或丢弃参数。折叠的卡片也优先于待确认的跟进建议:折叠后草稿为空,而空草稿正是建议被提交的状态,所以提交发出的是粘贴内容。展开卡片的 CodeMirror 撤销步骤与前后编辑隔离。
## 设计
折叠产出的就是一个普通的粘贴文件:名字、媒体类型、正文,放进粘贴图片和文件已经在用的
同一个附件列表。芯片渲染、移除、预览、提交时上传、失败回滚、以及乐观消息回显都是既有行为,
原样复用;协议格式不动。
折叠粘贴的名字就是它自己的标题加 `.txt` 后缀,声明 `text/plain`,因此发出去的消息与卡片
显示的是同一条串。这个后缀是承重的,不是观感问题:桥接层按存储时的文件名反查类型,
没有它正文就会被解析成不透明二进制。标题同样被构造成能过附件名清洗器——文件名承载不了
的字符一律折成空格,而标题窗口恰好把一个星形字符切成两半时,那半个会被丢弃,因为附件
令牌要带着这个名字过 `encodeURIComponent`。
刻意不覆盖纯文本拖拽。从输入框内部拖出的文本和从别的窗口拖进来的文本,
在同一条路径上无法区分;折叠前者会把原文留在输入框里,同时为它的一份副本再建一张卡片。
本次增量承诺的手势是粘贴。
只有指向编辑器本身的粘贴才会被折叠。承载这条规则的处理器挂在输入框 surface 上,
而这个 surface 里还有自己接收文本的控件——今天的搜索框,以及将来宿主注入的页脚——
它们必须继续拿到属于自己的粘贴。
剪贴板同时带文本和图片时,保持今天的“文本优先”:文本被折叠(或内联),图片不被摄取。
折叠绝不改变哪种表示胜出。
这里的阈值刻意大于终端 UI 自己的大段粘贴阈值(后者把粘贴折叠成单行输入框里的
文本占位符)。两个界面优化的是不同的东西,而且彼此都无法 import 对方的模块,
所以各自持有自己的数值;把这条分歧写在这里,是为了让将来调参的人不会把它读成漂移。
不做 daemon 能力门控。今天 Web Shell 会连接的所有 daemon 都提供附件上传,
加门控只会引入一个任何受支持部署都走不到的分支。这个取舍的后果记在“风险”一节。
### 保留与草稿
折叠内容之后会怎样,取决于用户走到了哪一步,只有中间那一种情况需要写代码。
一旦 prompt 已提交,附件就已经上传并由 daemon 保存,所以刷新或重进会话都能从
daemon 侧取回。
如果发送失败或被拒,现有的回填路径会把 prompt 内容放回输入框,并把附件留在失败的
消息行上供重试。折叠出来的卡片是同一类对象,自动继承这两种行为。
在发送之前,卡片和今天的粘贴图片、粘贴文件一样,只活在输入框的内存里。
本次增量不为正文增加持久化:会话草稿只存文本,而持久化的附件存储已经在 daemon 侧存在。
未提交的卡片没有任何防刷新保护;而同等大小的内联粘贴过去能靠输入框草稿活过一次刷新,
所以对草稿配额以内的粘贴而言,这是一次真实的可恢复性下降,而不只是超大输入的边角情况。
`beforeunload` 确认提示是**考虑过并否决**的:它会对任何未提交附件都触发,
而不是只对折叠的粘贴;而且 Web Shell 自身有程序化的刷新路径,这种弹窗会打断它们。
丢失依然是有界的——内容来自粘贴,通常还在剪贴板上;而且刷新只会丢掉未发送的草稿,
绝不会丢掉 daemon 已经保存的内容。
把卡片的正文放回编辑器时,不得重新编码或重新上传:卡片与展开后的文本共用同一份内存副本。
## 范围与非目标
做:折叠判定与两个阈值、卡片的标题与大小显示、以及把内容移入编辑器的那个动作。
不做:纯文本拖拽的折叠;未提交折叠正文的持久化存储;刷新护栏;重复粘贴的内容哈希去重;
后缀嗅探;其它对话框内的粘贴文本折叠(定时任务的 prompt 有自己的编辑器和自己的限制);
以及中途插入路由的任何改动。
中途插入入口保持原样。带附件的排队消息本来就不会显示 Insert,所以折叠后的粘贴
只是让这个入口消失,而不是在用户按下时失败。让插入也能携带附件是独立的增量,
且必须同时处理子进程侧那条 10 万字符的资源上限——它会把超大的插入内容静默丢弃。
## 风险
- **没有能力门控。** 在不支持附件上传的 daemon 上,发送折叠后的粘贴会失败而不是降级。
这是刻意的接受:没有任何受支持的部署处在这个状态,而一个走不到的分支比一个有记录的
取舍更糟。
- **折叠内容是会话级的。** 附件随会话生命周期存在与释放,所以恢复旧会话看到的是附件
令牌而不是正文。内联文本没有这个性质,这也是“移入编辑器”这条单向出口必须显眼、
必须便宜的原因。
- **不省上下文。** 两种形态最终都会进模型。期待折叠能保护上下文窗口的用户会失望。
- **阈值观感。** 一段紧凑的 150 行内容虽然没到行数条件,也会被字符条件折叠。
卡片用内容本身作标题并显示大小——标题说明折进去的是什么,大小说明有多少。
- **未提交的卡片过不了刷新。** 输入框把它放在内存里,和粘贴的图片、文件一样,
刷新丢弃它之前不会有任何提示。
- **展开后按撤销会丢掉这段内容。** 文本插入时卡片已经移除,所以撤销那一次编辑之后,
内容两边都不在了。替代方案——跟踪这次插入、让撤销把卡片放回来——是本次增量不愿付的
复杂度;内容来自剪贴板,重贴一次只需一个动作。
- **“一次可撤销”只对编辑器后端成立。** 移动端 textarea 上的插入是普通的受控值写入,
没有应用级撤销步骤,原生撤销栈在那里的行为也未经验证。卡片同样已经移除,所以内容
可能两边都不在。
- **提交 shell 命令会丢掉卡片。** 聊天模式折出的卡片能带进 shell 模式,而 shell 路径
不携带附件,所以提交命令会把卡片清掉——既没发出去,也没有任何提示。粘贴图片和拖入
文件今天就是这个行为,本次增量既没有引入也没有修掉它;记在这里,是因为折叠内容正是
用户最不希望被无声丢掉的东西。
## 受影响组件与验证
- `packages/web-shell/client/hooks/useComposerCore.ts`:粘贴判定、折叠卡片状态、
以及把正文移入编辑器的编辑事务。
- 一个新的输入框侧小模块:阈值、折叠判定、附件上限、卡片构造(名字、媒体类型、正文)。
- `packages/web-shell/client/components/ChatEditor.tsx`:卡片的标题、大小与它的动作,
与既有芯片并列。
- `packages/web-shell/client/utils/imageIngestion.ts`:复用既有的附件名清洗、去重与大小上限。
- `packages/web-shell/client/i18n.tsx`:新增的卡片动作文案,双语。
- 每个被改动的文件旁配单元测试,另有一份带 PR 门禁标签的浏览器用例。
验证:单元测试覆盖判定边界(199/200 行、7,999/8,000 字符、单个超长行、结尾无换行的
文本、恰好达到与超过附件上限)、卡片构造与命名(含第二次折叠的去重)、指向 surface 上
其它控件的粘贴、选区替换与 `!`/`/` 命令判断、shell 模式、输入框禁用与关闭附件的豁免、
折叠卡片优先于待确认的跟进建议、"移入编辑器"是否是一次可撤销的编辑、
卡片的 size 文案(无 size 的卡片不显示)、触摸端 textarea 上的折叠与移入、
以及折叠卡片是否以附件形式进入提交载荷。
浏览器验证覆盖粘贴到卡片、短粘贴仍内联、以及移入编辑器;发送链路在 actions 层验证,
因为浏览器侧的 mock daemon 没有附件存储。
命令与结果见 `.qwen/e2e-tests/web-shell-paste-as-attachment.md`。
没有待定的设计问题。上面记录的后续项(插入也携带附件)是刻意排除的范围,不是未决的决策。
@@ -2258,6 +2258,111 @@
flex-wrap: wrap;
}
/* Two classes so this wins over `.fileChip`'s fixed width, which is declared
later in this file: a text card sizes to its own content. */
.fileChip.fileChipText {
width: auto;
min-width: min(264px, 100%);
max-width: min(100%, 360px);
align-items: flex-start;
gap: 12px;
}
.fileChipIcon {
display: flex;
flex: 0 0 48px;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--muted);
color: var(--muted-foreground);
}
.fileChipBody {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 3px;
text-align: left;
}
.fileChipTitle {
overflow: hidden;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--foreground);
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 500;
line-height: 20px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileChipTitle:disabled {
cursor: default;
}
.fileChipTitle:not(:disabled):hover {
text-decoration: underline;
}
.fileChipTitle:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.fileChipMeta {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: var(--muted-foreground);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
/* The row is nowrap, so at the narrowest panes let the size drop to its own
line rather than escaping the card. */
flex-wrap: wrap;
}
.fileChipExpand {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
padding: 0;
border: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
font: inherit;
font-size: 12px;
line-height: 18px;
}
.fileChipExpand:not(:disabled):hover {
color: var(--foreground);
text-decoration: underline;
}
.fileChipExpand:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.fileChipExpand:disabled {
cursor: default;
opacity: 0.5;
}
.fileChip {
position: relative;
display: inline-flex;
@@ -23,6 +23,7 @@ import type {
import type { AtMentionWorkspaceActions } from '../hooks/useAtMentionSources';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { WebShellPortalRootContext } from '../portalRoot';
import { PASTE_TITLE_MAX_CHARS } from '../utils/largePaste';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -141,6 +142,7 @@ const composerCoreState = vi.hoisted(() => ({
getText: vi.fn(() => ''),
setText: vi.fn(),
insertText: vi.fn(),
expandPastedText: vi.fn(),
slashMenu: null as SlashMenuState | null,
focus: vi.fn(),
closeSlashMenu: vi.fn(),
@@ -262,6 +264,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
removeImage: vi.fn(),
pastedFiles: mockComposerCoreState.pastedFiles,
removeFile: vi.fn(),
expandPastedText: composerCoreState.expandPastedText,
composerTags: mockComposerCoreState.composerTags,
removeTopTag: mockComposerCoreState.removeTopTag,
addTags: composerCoreState.addTags,
@@ -342,6 +345,7 @@ afterEach(() => {
composerCoreState.getText.mockReset().mockReturnValue('');
composerCoreState.setText.mockReset();
composerCoreState.insertText.mockReset();
composerCoreState.expandPastedText.mockReset();
composerCoreState.focus.mockReset();
composerCoreState.closeSlashMenu.mockReset();
composerCoreState.mobileComposer = null;
@@ -380,7 +384,7 @@ interface ChatEditorRenderProps
pastedFiles?: Array<{
name: string;
media_type: string;
text: string;
text?: string;
size?: number;
}>;
gitBranch?: string;
@@ -1171,7 +1175,6 @@ describe('ChatEditor attachment reporting', () => {
{
name: 'report.html',
media_type: 'text/html',
text: '<h1>Report</h1>',
},
],
});
@@ -1192,6 +1195,216 @@ describe('ChatEditor attachment reporting', () => {
).not.toBeNull();
});
it('shows a folded paste with its size and expands it on request', () => {
const text = `${'line\n'.repeat(199)}line`;
const onAttachmentPreview = vi.fn();
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text,
size: 1000,
},
],
onAttachmentPreview,
});
const attachments = container.querySelector(
'[data-web-shell-composer-attachments]',
);
expect(attachments?.textContent).toContain('1000 B');
expect(attachments?.textContent).not.toContain('lines');
// The action leads the second line and the size follows it.
const meta = container.querySelector<HTMLElement>(
'[class*="fileChipMeta"]',
);
expect(meta?.firstElementChild?.className).toContain('fileChipExpand');
expect(meta?.lastElementChild?.textContent).toBe('1000 B');
const buttons = Array.from(attachments?.querySelectorAll('button') ?? []);
expect(buttons).toHaveLength(3);
act(() => {
buttons
.find((button) => button.className.includes('fileChipTitle'))!
.click();
});
expect(onAttachmentPreview).toHaveBeenCalledWith(
expect.objectContaining({ name: 'pasted-text.txt', text }),
);
const expand = buttons.find((button) =>
button.textContent?.includes('Show inline'),
)!;
expect(expand).toBeDefined();
act(() => {
expand.click();
});
expect(composerCoreState.expandPastedText).toHaveBeenCalledWith(0);
});
it('titles a folded paste with its first line instead of the file name', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: 'ERROR connection refused\nstack line\n'.repeat(100),
size: 4000,
},
],
});
const attachments = container.querySelector(
'[data-web-shell-composer-attachments]',
);
const title = container.querySelector<HTMLElement>(
'[class*="fileChipTitle"]',
);
expect(title?.textContent?.replace(/…$/, '')).toBe(
'ERROR connection refused stack line'.slice(0, PASTE_TITLE_MAX_CHARS),
);
expect(title?.getAttribute('title')).toBe(title?.textContent);
expect(attachments?.textContent).not.toContain('pasted-text.txt');
});
it('fills the title past a one-word first line', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: 'import\n { spawn }\n from child_process;\n'.repeat(50),
size: 2000,
},
],
});
const title =
container.querySelector<HTMLElement>('[class*="fileChipTitle"]')
?.textContent ?? '';
// The one-word first line is not the whole title: it runs into the next.
expect(title).toContain('import {');
expect(title.length).toBeLessThanOrEqual(PASTE_TITLE_MAX_CHARS + 1);
});
it('clips the title to a single line of bounded length', () => {
const firstLine = `head ${'x'.repeat(200)}`;
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: `${firstLine}\n${'tail\n'.repeat(200)}`,
size: 4000,
},
],
});
const title = container.querySelector<HTMLElement>(
'[class*="fileChipTitle"]',
);
expect(title?.textContent?.endsWith('…')).toBe(true);
expect(title?.textContent).not.toContain('tail');
expect(title?.textContent?.length ?? 0).toBeLessThanOrEqual(
PASTE_TITLE_MAX_CHARS + 1,
);
});
it('falls back to the file name when there is no content to show', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: '\n\n \n'.repeat(100),
size: 1200,
},
],
});
const attachments = container.querySelector(
'[data-web-shell-composer-attachments]',
);
expect(attachments?.textContent).toContain('pasted-text.txt');
});
it('leaves a pasted file without inline text without an expand action', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'photo.png',
media_type: 'image/png',
size: 2048,
},
],
});
const attachments = container.querySelector(
'[data-web-shell-composer-attachments]',
);
expect(attachments?.textContent).not.toContain('Show inline');
expect(attachments?.textContent).not.toContain('lines');
});
it('shows only the size, with no line count', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: 'x'.repeat(8000),
size: 8000,
},
],
});
const attachments = container.querySelector(
'[data-web-shell-composer-attachments]',
);
expect(attachments?.textContent).toContain('7.8 KB');
expect(attachments?.textContent).not.toContain('lines');
});
it('omits the size for a text card that carries none', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: '中文'.repeat(100),
},
],
});
const meta = container.querySelector<HTMLElement>(
'[class*="fileChipMeta"]',
);
expect(meta?.textContent).not.toContain(' B');
// The action survives on its own when there is no size to show.
expect(meta?.lastElementChild?.textContent).toBe('Show inline');
});
it('expands the card whose action was clicked', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'pasted-text.txt',
media_type: 'text/plain',
text: 'first\n'.repeat(200),
},
{
name: 'pasted-text (1).txt',
media_type: 'text/plain',
text: 'second\n'.repeat(200),
},
],
});
const expandButtons = Array.from(
container.querySelectorAll('button'),
).filter((button) => button.textContent?.includes('Show inline'));
expect(expandButtons).toHaveLength(2);
act(() => {
expandButtons[1]!.click();
});
expect(composerCoreState.expandPastedText).toHaveBeenCalledWith(1);
});
it('reports whether the composer has tags or pasted images', () => {
const onEmptyAttachmentsChange = vi.fn();
renderChatEditor({
@@ -1248,13 +1461,12 @@ describe('ChatEditor attachment reporting', () => {
expect(onImagePreview).toHaveBeenCalledWith('data:image/png;base64,abc');
});
it('opens a text attachment preview when its chip is clicked', () => {
it('opens the preview when a chip without inline text is clicked', () => {
const onAttachmentPreview = vi.fn();
const container = renderChatEditor({
pastedFiles: [
{
name: 'notes.txt',
text: 'hello attachment',
media_type: 'text/plain',
size: 16,
},
@@ -1273,7 +1485,6 @@ describe('ChatEditor attachment reporting', () => {
expect(onAttachmentPreview).toHaveBeenCalledWith(
expect.objectContaining({
name: 'notes.txt',
text: 'hello attachment',
}),
);
expect(container.textContent).not.toContain('16 B');
@@ -67,6 +67,7 @@ import { ModeIcon } from './ModeIcon';
import { planSlashSectionRows } from '../utils/slashSectionPlan';
import { getModelDisplayName } from '../utils/modelDisplay';
import { getContextUsageLevel } from '../utils/contextUsage';
import { pastedTextTitle } from '../utils/largePaste';
import type { ContextUsageControls } from '../hooks/useContextUsageControls';
import { ContextUsagePopover } from './ContextUsagePopover';
import { VoiceButton } from '../voice/VoiceButton';
@@ -3115,35 +3116,25 @@ export const ChatEditor = memo(
)}
{core.pastedFiles.length > 0 && (
<div className={styles.files}>
{core.pastedFiles.map((file, i) => (
<div
key={`${file.name}-${i}`}
className={`${styles.fileChip}${
onAttachmentPreview
? ` ${styles.fileChipPreviewable}`
: ''
}`}
>
<button
type="button"
className={styles.fileChipPreview}
disabled={!onAttachmentPreview}
onClick={() =>
onAttachmentPreview?.({
name: file.name,
mimeType: file.media_type,
...(file.data ? { data: file.data } : {}),
...(file.text !== undefined
? { text: file.text }
: {}),
})
}
>
<FileAttachmentContent
name={file.name}
mimeType={file.media_type}
/>
</button>
{core.pastedFiles.map((file, i) => {
const foldedText = file.text;
// A folded paste is named after its own title, so the
// card renders that title; the name is the fallback for a
// paste with nothing to derive one from.
const title =
foldedText === undefined
? file.name
: pastedTextTitle(foldedText, file.name);
const openPreview = () =>
onAttachmentPreview?.({
name: file.name,
mimeType: file.media_type,
...(file.data ? { data: file.data } : {}),
...(foldedText !== undefined
? { text: foldedText }
: {}),
});
const removeButton = (
<button
type="button"
className={styles.fileChipRemove}
@@ -3153,7 +3144,7 @@ export const ChatEditor = memo(
if (disabled) return;
core.removeFile(i);
}}
aria-label={`Remove ${file.name}`}
aria-label={`Remove ${title}`}
>
<svg
width="8"
@@ -3170,8 +3161,84 @@ export const ChatEditor = memo(
/>
</svg>
</button>
</div>
))}
);
if (foldedText === undefined) {
return (
<div
key={`${file.name}-${i}`}
className={`${styles.fileChip}${
onAttachmentPreview
? ` ${styles.fileChipPreviewable}`
: ''
}`}
>
<button
type="button"
className={styles.fileChipPreview}
disabled={!onAttachmentPreview}
onClick={openPreview}
>
<FileAttachmentContent
name={file.name}
mimeType={file.media_type}
/>
</button>
{removeButton}
</div>
);
}
// The size is omitted rather than derived from the text
// length, which would label characters as bytes.
const meta =
file.size === undefined
? undefined
: formatAttachmentSize(file.size);
return (
<div
key={`${file.name}-${i}`}
className={`${styles.fileChip} ${styles.fileChipText}`}
>
<span className={styles.fileChipIcon}>
<FileTypeIcon
name={file.name}
mimeType={file.media_type}
size={24}
aria-hidden="true"
/>
</span>
<span className={styles.fileChipBody}>
<button
type="button"
className={styles.fileChipTitle}
title={title}
disabled={!onAttachmentPreview}
onClick={openPreview}
>
{title}
</button>
<span className={styles.fileChipMeta}>
<button
type="button"
className={styles.fileChipExpand}
disabled={disabled}
onClick={() => {
if (disabled) return;
core.expandPastedText(i);
}}
>
{t('editor.pastedTextShowInEditor')}
<ChevronRightIcon
size={12}
aria-hidden="true"
/>
</button>
{meta === undefined ? null : <span>{meta}</span>}
</span>
</span>
{removeButton}
</div>
);
})}
</div>
)}
</div>
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// Oversized pastes fold into an attachment card. The card is exercised here in
// a real browser; the send path it feeds is covered at the actions layer, where
// the mock daemon has no attachment store.
import {
expect,
test,
type Locator,
type Page,
type TestInfo,
} from '@playwright/test';
import {
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
type MockDaemonController,
type WebShellDaemonScenario,
} from './utils/mockDaemon';
const COMPOSER_EDITOR = '[data-web-shell-composer-editor] .cm-content';
const COMPOSER_ATTACHMENTS = '[data-web-shell-composer-attachments]';
async function installScenario(
page: Page,
scenario: WebShellDaemonScenario,
testInfo: TestInfo,
): Promise<MockDaemonController> {
return installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
}
async function gotoSession(
page: Page,
scenario: WebShellDaemonScenario,
daemon: MockDaemonController,
): Promise<void> {
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
await expect(
page.locator('[data-web-shell-root]:not([data-web-shell-gate])'),
).toBeVisible();
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount: scenario.events.length,
}),
);
await expect(page.getByText('Loading...')).toHaveCount(0);
}
async function pasteText(editor: Locator, text: string): Promise<void> {
await editor.evaluate((element, pasted) => {
const clipboard = new DataTransfer();
clipboard.setData('text/plain', pasted);
element.dispatchEvent(
new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: clipboard,
}),
);
}, text);
}
function longText(lines: number): string {
return `${Array.from({ length: lines }, (_, i) => `line ${i}`).join('\n')}\n`;
}
test('folds a long paste into a card and keeps the editor empty @paste-fold @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const editor = page.locator(COMPOSER_EDITOR);
const text = longText(250);
await pasteText(editor, text);
const attachments = page.locator(COMPOSER_ATTACHMENTS);
await expect(attachments).toBeVisible();
// The card is titled by the content's first line; the attachment's name is
// that same title with a .txt suffix, which the card does not show, and only
// a size sits beside the action.
await expect(attachments).toContainText('line 0');
await expect(attachments).not.toContainText('.txt');
await expect(attachments).not.toContainText('lines');
await expect(attachments).toContainText(/\d+(\.\d+)? (B|KB|MB)/);
await expect(page.getByRole('button', { name: 'Show inline' })).toBeVisible();
// The placeholder lives inside `.cm-content`, so assert on the pasted text.
await expect(editor).not.toContainText('line 249');
});
test('keeps a short paste inline @paste-fold @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const editor = page.locator(COMPOSER_EDITOR);
await pasteText(editor, 'short line\nsecond line\n');
await expect(editor).toContainText('short line');
await expect(page.locator(COMPOSER_ATTACHMENTS)).toHaveCount(0);
});
test('moves the folded paste into the editor on request @paste-fold @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const editor = page.locator(COMPOSER_EDITOR);
const text = longText(250);
await pasteText(editor, text);
await expect(page.locator(COMPOSER_ATTACHMENTS)).toBeVisible();
await page.getByRole('button', { name: 'Show inline' }).click();
await expect(page.locator(COMPOSER_ATTACHMENTS)).toHaveCount(0);
await expect(editor).toContainText('line 249');
});
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { undo } from '@codemirror/commands';
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';
@@ -43,6 +44,7 @@ function Harness({
workspaceUploadBusy,
fileDragEnabled,
attachmentsEnabled,
disabled,
}: {
composerInput?: WebShellComposerInput;
onSubmit: ReturnType<typeof vi.fn>;
@@ -68,6 +70,7 @@ function Harness({
workspaceUploadBusy?: boolean;
fileDragEnabled?: UseComposerCoreOptions['fileDragEnabled'];
attachmentsEnabled?: UseComposerCoreOptions['attachmentsEnabled'];
disabled?: UseComposerCoreOptions['disabled'];
}) {
const composer = useComposerCore({
onSubmit,
@@ -92,12 +95,14 @@ function Harness({
workspaceUploadBusy,
fileDragEnabled,
attachmentsEnabled,
disabled,
});
latest = composer;
return (
<div data-web-shell-composer-surface {...composer.imageTransferHandlers}>
<div ref={composer.containerRef} />
<input data-testid="composer-surface-input" />
</div>
);
}
@@ -123,6 +128,7 @@ async function mount({
workspaceUploadBusy,
fileDragEnabled,
attachmentsEnabled,
disabled,
}: {
composerInput?: WebShellComposerInput;
onSubmit?: ReturnType<typeof vi.fn>;
@@ -148,6 +154,7 @@ async function mount({
workspaceUploadBusy?: boolean;
fileDragEnabled?: UseComposerCoreOptions['fileDragEnabled'];
attachmentsEnabled?: UseComposerCoreOptions['attachmentsEnabled'];
disabled?: UseComposerCoreOptions['disabled'];
} = {}) {
container = document.createElement('div');
document.body.append(container);
@@ -184,6 +191,7 @@ async function mount({
workspaceUploadBusy={workspaceUploadBusy}
fileDragEnabled={fileDragEnabled}
attachmentsEnabled={currentAttachmentsEnabled}
disabled={disabled}
/>
</I18nProvider>
</WebShellPortalRootContext.Provider>,
@@ -1104,7 +1112,7 @@ describe('useComposerCore paste', () => {
);
});
it('lets long plain text paste directly into the editor', async () => {
it('lets short plain text paste directly into the editor', async () => {
await mount();
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
@@ -1112,7 +1120,7 @@ describe('useComposerCore paste', () => {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => 'line\n'.repeat(200),
getData: () => 'line\n'.repeat(20),
},
});
@@ -1120,8 +1128,345 @@ describe('useComposerCore paste', () => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
expect(latest!.getText()).toBe('line\n'.repeat(200));
expect(latest!.getText()).not.toContain('Pasted Content');
expect(latest!.getText()).toBe('line\n'.repeat(20));
expect(latest!.pastedFiles).toEqual([]);
});
it.each([
{ draft: 'old draft', from: 0, to: 9, paste: 'x'.repeat(8000) },
{ draft: '', from: 0, to: 0, paste: '!echo ' + 'x'.repeat(8000) },
{ draft: '', from: 0, to: 0, paste: '/fork ' + 'x'.repeat(8000) },
{ draft: '/fork ', from: 6, to: 6, paste: 'x'.repeat(8000) },
{ draft: '/clear', from: 0, to: 0, paste: 'x'.repeat(8000) },
{ draft: '/fork do something', from: 0, to: 0, paste: 'x'.repeat(8000) },
{ draft: '!echo hi', from: 0, to: 0, paste: 'x'.repeat(8000) },
{ draft: ' !echo ', from: 8, to: 8, paste: 'x'.repeat(8000) },
])(
'keeps replacement and command pastes inline: $draft',
async ({ draft, from, to, paste }) => {
const { onSubmit } = await mount();
act(() => {
latest!.setText(draft);
latest!.viewRef.current!.dispatch({
selection: { anchor: from, head: to },
});
});
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { getData: () => paste },
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
const expected = draft.slice(0, from) + paste + draft.slice(to);
expect(latest!.getText()).toBe(expected);
expect(latest!.pastedFiles).toEqual([]);
act(() => latest!.submitText());
expect(onSubmit.mock.calls[0]![0]).toBe(expected.trim());
expect(onSubmit.mock.calls[0]![2]).toBeUndefined();
},
);
it('folds a long plain text paste into an attachment card', async () => {
await mount();
const text = 'line\n'.repeat(200);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
expect(latest!.getText()).toBe('');
expect(latest!.pastedFiles).toEqual([
{
name: 'line line line line line….txt',
media_type: 'text/plain',
text,
size: text.length,
},
]);
});
it('folds a single minified line into a card', async () => {
await mount();
const text = 'x'.repeat(8_000);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
expect.soft(latest!.getText()).toBe('');
expect.soft(latest!.pastedFiles).toHaveLength(1);
});
it('keeps inline when attachments are unavailable', async () => {
await mount({ attachmentsEnabled: false });
const text = 'line\n'.repeat(200);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
expect.soft(latest!.getText()).toBe(text);
expect.soft(latest!.pastedFiles).toEqual([]);
});
it('folds the text and skips the image when both are on the clipboard', async () => {
await mount();
const text = 'line\n'.repeat(200);
const image = new File(['png'], 'shot.png', { type: 'image/png' });
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [image],
items: [
{ kind: 'file', type: 'image/png', getAsFile: () => image },
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
],
types: ['Files', 'text/plain'],
getData: (type: string) => (type === 'text/plain' ? text : ''),
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
await waitForImageIngestion();
expect.soft(latest!.getText()).toBe('');
expect.soft(latest!.pastedImages).toEqual([]);
expect.soft(latest!.pastedFiles).toHaveLength(1);
});
it('submits a folded paste as an attachment alongside the typed text', async () => {
const { onSubmit } = await mount();
const text = 'line\n'.repeat(200);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
act(() => latest!.insertText('summarise this'));
act(() => latest!.submitText());
expect(onSubmit).toHaveBeenCalledWith(
'summarise this',
undefined,
[
{
name: 'line line line line line….txt',
media_type: 'text/plain',
text,
size: text.length,
},
],
expect.any(Function),
undefined,
);
});
it('submits the folded paste rather than a visible follow-up suggestion', async () => {
const { onSubmit } = await mount({
followupState: {
isVisible: true,
shownAt: Date.now(),
suggestion: 'inspect the orders table and summarize',
},
});
const text = 'line\n'.repeat(200);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
act(() => latest!.submitText());
expect(onSubmit).toHaveBeenCalledWith(
'',
undefined,
[
{
name: 'line line line line line….txt',
media_type: 'text/plain',
text,
size: text.length,
},
],
expect.any(Function),
undefined,
);
});
it('leaves a paste aimed at another control on the surface alone', async () => {
await mount();
const text = 'line\n'.repeat(250);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!
.querySelector('[data-testid="composer-surface-input"]')!
.dispatchEvent(event);
});
expect.soft(event.defaultPrevented).toBe(false);
expect.soft(latest!.pastedFiles).toEqual([]);
expect.soft(latest!.getText()).toBe('');
});
it('leaves a long paste inline in shell mode', async () => {
await mount();
act(() => latest!.toggleShellMode());
expect.soft(latest!.shellMode).toBe(true);
const text = 'line\n'.repeat(250);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
// CodeMirror preventDefaults its own pastes, so the behaviour is the
// oracle here: the command text stays in the editor and no card appears.
expect.soft(latest!.pastedFiles).toEqual([]);
expect(latest!.getText()).toBe(text);
});
it('leaves a long paste inline in a disabled composer', async () => {
await mount({ disabled: true });
const text = 'line\n'.repeat(250);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
// A disabled composer has no prompt to attach to, so nothing is folded.
expect(latest!.pastedFiles).toEqual([]);
});
it('deduplicates the name of a second folded paste', async () => {
await mount();
const paste = (text: string) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
};
// Two pastes of the same content share a title, so the second one still
// needs a name of its own.
paste('line\n'.repeat(200));
paste('line\n'.repeat(200));
expect(latest!.pastedFiles.map((file) => file.name)).toEqual([
'line line line line line….txt',
'line line line line line… (1).txt',
]);
});
it('moves a folded paste into the editor and undoes it in one step', async () => {
await mount();
act(() => latest!.insertText('prefix'));
const text = 'line\n'.repeat(200);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('.cm-content')!.dispatchEvent(event);
});
act(() => latest!.expandPastedText(0));
expect.soft(latest!.getText()).toBe('prefix' + text);
expect.soft(latest!.pastedFiles).toEqual([]);
act(() => {
undo(latest!.viewRef.current!);
});
expect(latest!.getText()).toBe('prefix');
});
it('ingests copied file references after a drop intent choice', async () => {
@@ -429,6 +429,85 @@ describe('useComposerCore mobile textarea backend', () => {
expect(latest!.pendingImageBatchCount).toBe(0);
});
it.each([
{ draft: 'old draft', from: 0, to: 9, text: 'x'.repeat(8000) },
{ draft: '', from: 0, to: 0, text: '!echo ' + 'x'.repeat(8000) },
{ draft: '', from: 0, to: 0, text: '/fork ' + 'x'.repeat(8000) },
{ draft: '/fork ', from: 6, to: 6, text: 'x'.repeat(8000) },
{ draft: '/clear', from: 0, to: 0, text: 'x'.repeat(8000) },
{ draft: '/fork do something', from: 0, to: 0, text: 'x'.repeat(8000) },
{ draft: '!echo hi', from: 0, to: 0, text: 'x'.repeat(8000) },
])(
'leaves replacement and command pastes to the textarea: $draft',
async ({ draft, from, to, text }) => {
mockTouchDevice();
await mount();
act(() => latest!.setText(draft));
const textarea = container!.querySelector('textarea')!;
textarea.setSelectionRange(from, to);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { getData: () => text },
});
act(() => textarea.dispatchEvent(event));
expect(event.defaultPrevented).toBe(false);
expect(latest!.pastedFiles).toEqual([]);
},
);
it('folds an oversized paste in the touch textarea', async () => {
mockTouchDevice();
await mount();
const preventDefault = vi.fn();
const text = 'line\n'.repeat(250);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
event.preventDefault = preventDefault;
container!.querySelector('textarea')!.dispatchEvent(event);
});
expect(preventDefault).toHaveBeenCalled();
expect(latest!.mobileComposer!.value).toBe('');
expect(latest!.pastedFiles).toHaveLength(1);
expect(latest!.pastedFiles[0].name).toBe('line line line line line….txt');
expect(latest!.pastedFiles[0].text).toBe(text);
});
it('moves a folded paste into the touch textarea on request', async () => {
mockTouchDevice();
await mount();
const text = 'line\n'.repeat(250);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
types: ['text/plain'],
getData: () => text,
},
});
act(() => {
container!.querySelector('textarea')!.dispatchEvent(event);
});
expect(latest!.pastedFiles).toHaveLength(1);
act(() => latest!.expandPastedText(0));
// The touch backend inserts the text as a plain controlled-value write and
// has no undo step of its own (see the design doc's Risks).
expect(latest!.pastedFiles).toEqual([]);
expect(latest!.mobileComposer!.value).toBe(text);
});
it('saves the draft immediately on blur before the debounce timer fires', async () => {
mockTouchDevice();
vi.useFakeTimers();
@@ -29,7 +29,12 @@ import {
StateEffect,
StateField,
} from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import {
defaultKeymap,
history,
historyKeymap,
isolateHistory,
} from '@codemirror/commands';
import {
acceptCompletion,
autocompletion,
@@ -125,6 +130,10 @@ import {
sanitizeAttachmentName,
type ExtractedFileTransfer,
} from '../utils/imageIngestion';
import {
createPastedTextFile,
shouldFoldPastedText,
} from '../utils/largePaste';
const TOOLTIP_STYLE_ID = 'web-shell-tooltip-styles';
const TOOLTIP_STYLES = `
@@ -1386,6 +1395,7 @@ export interface UseComposerCoreReturn {
removeImage: (index: number) => void;
pastedFiles: PromptFile[];
removeFile: (index: number) => void;
expandPastedText: (index: number) => void;
composerTags: WebShellComposerTag[];
removeTopTag: (id: string) => void;
addTags: (
@@ -1934,10 +1944,75 @@ export function useComposerCore(
(files: readonly File[]) => enqueueExtractedTransfer(extractFiles(files)),
[enqueueExtractedTransfer],
);
/**
* Whether a paste landed in the composer's own editor rather than in one of
* the other text controls that share the composer surface.
*/
const pastedInEditor = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof Node)) return false;
if (mobileTextareaRef.current === target) return true;
return viewRef.current?.dom.contains(target) ?? false;
}, []);
/**
* Folds an oversized paste into an attachment card. Returns whether the
* caller should stop the browser's own insertion: a short paste stays inline,
* and a disabled composer, a shell command, or a host with attachments turned
* off has no card to fold into, so each leaves today's behavior alone.
*/
const foldPastedTextIntoCard = useCallback((text: string): boolean => {
// The exemptions are checked before the measurement: sizing a paste costs a
// full UTF-8 encode, which a multi-megabyte paste in shell mode should not
// pay for something that is about to be discarded.
if (
disabledRef.current ||
shellModeRef.current ||
!attachmentsEnabledRef.current
) {
return false;
}
const view = viewRef.current;
const textarea = mobileTextareaRef.current;
if (
view?.state.selection.ranges.some((range) => !range.empty) ||
(textarea && textarea.selectionStart !== textarea.selectionEnd)
) {
return false;
}
const draft = view?.state.doc.toString() ?? mobileTextRef.current;
const caret =
view?.state.selection.main.from ?? textarea?.selectionStart ?? 0;
if (
/^[!/]/.test(draft.trimStart()) ||
/^[!/]/.test(
(draft.slice(0, caret) + text + draft.slice(caret)).trimStart(),
)
) {
return false;
}
if (!shouldFoldPastedText(text)) return false;
const taken = new Set(pastedFilesRef.current.map((file) => file.name));
const next = [...pastedFilesRef.current, createPastedTextFile(text, taken)];
pastedFilesRef.current = next;
setPastedFiles(next);
return true;
}, []);
const imageTransferHandlers = useMemo<ComposerImageTransferHandlers>(
() => ({
onPasteCapture: (event) => {
if (event.clipboardData.getData('text/plain')) return;
const pastedText = event.clipboardData.getData('text/plain');
if (pastedText) {
// These handlers live on the composer surface, which also contains
// controls that take text of their own (the history search box), so
// only a paste aimed at the editor may be folded.
if (
pastedInEditor(event.target) &&
foldPastedTextIntoCard(pastedText)
) {
event.preventDefault();
event.stopPropagation();
}
return;
}
if (enqueueImageTransfer(event.clipboardData, 'paste')) {
event.preventDefault();
event.stopPropagation();
@@ -1998,7 +2073,13 @@ export function useComposerCore(
}
},
}),
[clearImageDragState, enqueueImageTransfer, isTouchComposer],
[
clearImageDragState,
enqueueImageTransfer,
foldPastedTextIntoCard,
isTouchComposer,
pastedInEditor,
],
);
useEffect(() => {
if (!imageDragActive) return;
@@ -2685,10 +2766,17 @@ export function useComposerCore(
: [];
const editorText = view ? view.state.doc.toString() : mobileTextRef.current;
const followup = followupStateRef.current;
// A folded paste leaves the draft empty, which is the state the follow-up
// completion submits from, so the suggestion would become the message with
// the paste along as an attachment. The paste wins while a card waits.
const foldedPastePending = pastedFilesRef.current.some(
(file) => file.text !== undefined,
);
const followupCompletion =
textOverride === undefined &&
!suppressFollowupCompletion &&
inlineTags.length === 0 &&
!foldedPastePending &&
followup?.isVisible
? getFollowupCompletion(editorText, followup.suggestion)
: null;
@@ -4523,6 +4611,32 @@ export function useComposerCore(
setPastedFiles(next);
}, []);
/**
* Moves a folded paste into the editor: the card disappears and the text
* lands at the caret as one undoable edit. There is no way back, so this is
* the user's explicit choice rather than a toggle.
*/
const expandPastedText = useCallback(
(index: number) => {
const file = pastedFilesRef.current[index];
if (!file || file.text === undefined) return;
const view = viewRef.current;
if (view) {
view.dispatch({
...view.state.replaceSelection(file.text),
annotations: isolateHistory.of('full'),
scrollIntoView: true,
});
view.focus();
} else {
if (!mobileTextareaRef.current) return;
insertText(file.text);
}
removeFile(index);
},
[insertText, removeFile],
);
// ---- Computed ----
const canSubmit =
@@ -4653,6 +4767,7 @@ export function useComposerCore(
removeImage,
pastedFiles,
removeFile,
expandPastedText,
composerTags,
removeTopTag,
addTags,
+2
View File
@@ -1198,6 +1198,7 @@ const EN: Messages = {
`${v?.count ?? 0} file(s) could not be read.`,
'editor.imagesTooLarge': (v) =>
`${v?.count ?? 0} file(s) exceeded the attachment size limit.`,
'editor.pastedTextShowInEditor': 'Show inline',
'editor.connectionDisconnected':
'Connection interrupted. Please try again after it reconnects.',
'editor.sessionLoading': 'Session is still loading. Try again in a moment.',
@@ -5033,6 +5034,7 @@ const ZH: Messages = {
'editor.imagesSkipped': (v) => `已跳过 ${v?.count ?? 0} 个不可读取的文件。`,
'editor.imagesReadFailed': (v) => `${v?.count ?? 0} 个文件读取失败。`,
'editor.imagesTooLarge': (v) => `${v?.count ?? 0} 个文件超过附件大小限制。`,
'editor.pastedTextShowInEditor': '显示到输入框',
'editor.connectionDisconnected': '连接已中断,请在恢复后重试。',
'editor.sessionLoading': '会话正在加载,请稍后再发送。',
'editor.processing': '处理中。新消息会进入队列。',
@@ -0,0 +1,203 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { MAX_FILE_ATTACHMENT_DATA_BYTES } from './imageIngestion';
import {
countTextLines,
createPastedTextFile,
PASTE_FOLD_MIN_CHARS,
PASTE_FOLD_MIN_LINES,
PASTE_TITLE_MAX_CHARS,
pastedTextTitle,
shouldFoldPastedText,
} from './largePaste';
describe('countTextLines', () => {
it('counts a single unterminated line as one', () => {
expect(countTextLines('a')).toBe(1);
expect(countTextLines('')).toBe(1);
});
it('counts a trailing newline as opening a new line', () => {
expect(countTextLines('a\n')).toBe(2);
expect(countTextLines('a\nb')).toBe(2);
});
});
describe('shouldFoldPastedText', () => {
it('keeps content under both thresholds inline', () => {
expect(
shouldFoldPastedText('line\n'.repeat(PASTE_FOLD_MIN_LINES - 2) + 'line'),
).toBe(false);
expect(shouldFoldPastedText('x'.repeat(PASTE_FOLD_MIN_CHARS - 1))).toBe(
false,
);
});
it('folds exactly at the line threshold', () => {
const text = `${'line\n'.repeat(PASTE_FOLD_MIN_LINES - 1)}line`;
expect(countTextLines(text)).toBe(PASTE_FOLD_MIN_LINES);
expect(shouldFoldPastedText(text)).toBe(true);
});
it('folds a single long line at the character threshold', () => {
expect(shouldFoldPastedText('x'.repeat(PASTE_FOLD_MIN_CHARS))).toBe(true);
});
it('folds one minified line that carries megabytes', () => {
expect(shouldFoldPastedText('x'.repeat(5_000_000))).toBe(true);
});
it('keeps a paste above the attachment limit inline', () => {
const text = 'x'.repeat(MAX_FILE_ATTACHMENT_DATA_BYTES + 1);
expect(shouldFoldPastedText(text)).toBe(false);
});
it('measures the attachment limit in bytes, not characters', () => {
// Three bytes per character, so this is over the limit while its character
// count is not.
const text = '中'.repeat(Math.ceil(MAX_FILE_ATTACHMENT_DATA_BYTES / 3));
expect(text.length).toBeLessThan(MAX_FILE_ATTACHMENT_DATA_BYTES);
expect(shouldFoldPastedText(text)).toBe(false);
});
it('folds a paste exactly at the attachment limit', () => {
expect(
shouldFoldPastedText('x'.repeat(MAX_FILE_ATTACHMENT_DATA_BYTES)),
).toBe(true);
});
it('folds a paste that ends with a newline at the line threshold', () => {
expect(
shouldFoldPastedText('line\n'.repeat(PASTE_FOLD_MIN_LINES - 1)),
).toBe(true);
});
it('does not fold empty or whitespace-only input', () => {
expect(shouldFoldPastedText('')).toBe(false);
expect(shouldFoldPastedText(' \n ')).toBe(false);
});
});
describe('createPastedTextFile', () => {
it('builds a text card with the byte size of its content', () => {
const file = createPastedTextFile('hello\nworld', new Set());
expect(file).toEqual({
name: 'hello world.txt',
media_type: 'text/plain',
text: 'hello\nworld',
size: 11,
});
});
it('sizes multibyte content in bytes, not characters', () => {
expect(createPastedTextFile('中文', new Set()).size).toBe(6);
});
it('deduplicates a repeated name against the cards already present', () => {
const taken = new Set(['x.txt']);
expect(createPastedTextFile('x', taken).name).toBe('x (1).txt');
});
it('keeps the .txt extension so the daemon resolves the content as text', () => {
expect(createPastedTextFile('x', new Set()).name).toMatch(/\.txt$/);
});
it('falls back to the constant stem when there is no content to name it', () => {
expect(createPastedTextFile(' \n'.repeat(200), new Set()).name).toBe(
'pasted-text.txt',
);
});
});
describe('pastedTextTitle', () => {
it('uses the beginning of the content', () => {
expect(pastedTextTitle('ERROR refused\nstack\nmore', 'pasted.txt')).toBe(
'ERROR refused stack more',
);
});
it('continues past a one-word first line', () => {
const title = pastedTextTitle(
'import\n spawn from child_process\n',
'pasted.txt',
);
// The one-word first line is not the whole title: it runs into the next.
expect(title).toContain('import spawn');
expect(title).not.toContain('\n');
});
it('collapses runs of whitespace', () => {
expect(pastedTextTitle(' spaced \t\n\n out ', 'pasted.txt')).toBe(
'spaced out',
);
});
it('reaches past a gap narrower than the scan window', () => {
expect(pastedTextTitle('Summary\n\n\npayload', 'pasted.txt')).toBe(
'Summary payload',
);
});
it('marks a title the scan window cut short', () => {
// The gap is far wider than the window (and wider than any plausible
// retuning of it), so the content that resumes after it is out of reach:
// the title has to say it is not the whole beginning.
expect(
pastedTextTitle(`Summary${' '.repeat(2_000)}payload`, 'pasted.txt'),
).toBe('Summary…');
});
it('falls back to the name when there is no content to show', () => {
expect(pastedTextTitle('\n\n \n', 'pasted.txt')).toBe('pasted.txt');
expect(pastedTextTitle('', 'pasted.txt')).toBe('pasted.txt');
});
it('clips a long content run to the character budget', () => {
const title = pastedTextTitle('x'.repeat(500), 'pasted.txt');
expect(title).toBe(`${'x'.repeat(PASTE_TITLE_MAX_CHARS)}…`);
});
it('keeps content at the budget unclipped', () => {
const line = 'y'.repeat(PASTE_TITLE_MAX_CHARS);
expect(pastedTextTitle(line, 'pasted.txt')).toBe(line);
});
it('clips once the collapsed content passes the budget', () => {
const line = 'y'.repeat(PASTE_TITLE_MAX_CHARS);
expect(pastedTextTitle(`${line}\nmore`, 'pasted.txt')).toBe(`${line}…`);
});
it('does not split a surrogate pair when clipping', () => {
const title = pastedTextTitle('😀'.repeat(100), 'pasted.txt');
expect(title).toBe(`${'😀'.repeat(PASTE_TITLE_MAX_CHARS / 2)}…`);
// A clip that lands between the halves drops the stray half.
expect(pastedTextTitle(`a${'😀'.repeat(100)}`, 'pasted.txt')).toBe(
`a${'😀'.repeat(PASTE_TITLE_MAX_CHARS / 2 - 1)}…`,
);
});
it('leaves only characters the attachment sanitizer keeps', () => {
// The title is the card's attachment name, so a slash has to survive the
// sanitizer's leading-path rule and the rest its invalid-character rule.
expect(pastedTextTitle('src/utils/largePaste.ts', 'pasted.txt')).toBe(
'src utils largePaste.ts',
);
expect(pastedTextTitle('https://example.com/x', 'pasted.txt')).toBe(
'https example.com x',
);
});
it('drops a lone surrogate the scan window cut in half', () => {
// The window is measured in code units, so it can end between the halves of
// one astral character — and a lone half survives neither a file name nor
// the URI the attachment token is built from.
const title = pastedTextTitle(`a${' '.repeat(94)}😀b`, 'pasted.txt');
expect(title).toBe('a…');
expect(/[\uD800-\uDFFF]/u.test(title)).toBe(false);
});
});
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { PromptFile } from '../adapters/promptTypes';
import {
dedupeAttachmentName,
MAX_FILE_ATTACHMENT_DATA_BYTES,
sanitizeAttachmentName,
} from './imageIngestion';
/**
* The stem of a folded paste's attachment name when the paste carries nothing
* to derive one from. A name is otherwise the card's title, so the sent message
* and the card show the same string.
*/
const FOLDED_PASTE_NAME_STEM = 'pasted-text';
/**
* A paste at or above either threshold is folded into an attachment card
* instead of being inserted into the editor.
*
* The line count keeps the rule legible to a user; the character count keeps it
* honest, because one minified line can carry megabytes. A fragment left inline
* stays under the daemon's 16,384-character mid-turn insertion limit — that
* limit bounds one queued message, so a draft assembled from several sub-
* threshold pastes or from typing can still exceed it.
*
* These thresholds are deliberately larger than the terminal UI's
* `isLargePaste` thresholds (10 lines / 1,000 characters): the terminal folds
* into a placeholder inside a single-line prompt, while this folds into an
* attachment card. Neither consumer can import the other's module, so the two
* are separate policies by intent, not by oversight.
*/
export const PASTE_FOLD_MIN_LINES = 200;
export const PASTE_FOLD_MIN_CHARS = 8_000;
/** How much of the paste the card shows as its title. */
export const PASTE_TITLE_MAX_CHARS = 24;
/** Input window per title character, so collapsed whitespace still fills it. */
const TITLE_SCAN_FACTOR = 4;
/**
* The characters the attachment-name sanitizer would rewrite or strip, plus the
* two path separators its leading-path rule would cut at. A folded paste's name
* is its title, so the title has to already be usable as one.
*/
const TITLE_UNSAFE_RE = /[<>:"|?*\\/]|\p{Cc}|\p{Cf}|\p{Cs}/gu;
function pastedTextByteLength(text: string): number {
return new TextEncoder().encode(text).byteLength;
}
/**
* Titles a folded card with the beginning of the paste itself, and doubles as
* the attachment's name: line breaks and runs of whitespace collapse to single
* spaces, and characters a file name cannot carry become spaces too, so a paste
* whose first line is one word still yields a title that reads and a name the
* sanitizer will not rewrite. Falls back to the stem when there is no content.
*/
export function pastedTextTitle(text: string, fallbackName: string): string {
// The window is capped, so a paste that is one multi-megabyte line is sliced
// rather than copied whole; the leading scan is a single pass to the first
// non-whitespace character.
const start = text.search(/\S/);
if (start === -1) return fallbackName;
const window = text.slice(
start,
start + PASTE_TITLE_MAX_CHARS * TITLE_SCAN_FACTOR,
);
const flat = window
.replace(TITLE_UNSAFE_RE, ' ')
.replace(/\s+/gu, ' ')
.trim();
if (flat.length <= PASTE_TITLE_MAX_CHARS) {
// The window is capped, so a whitespace gap wider than it can end before
// the content resumes. Mark that instead of passing a shortened title off
// as the whole beginning.
return window.length < text.length - start ? `${flat}…` : flat;
}
const clipped = flat.slice(0, PASTE_TITLE_MAX_CHARS);
const safe = /[\uD800-\uDBFF]$/.test(clipped)
? clipped.slice(0, -1)
: clipped;
// The clip can land on a space, which reads as a gap before the ellipsis and
// would sit inside the file name this title becomes.
return `${safe.trimEnd()}…`;
}
export function countTextLines(text: string): number {
let lines = 1;
for (
let index = text.indexOf('\n');
index !== -1;
index = text.indexOf('\n', index + 1)
) {
lines += 1;
}
return lines;
}
export function shouldFoldPastedText(text: string): boolean {
const overThreshold =
text.length >= PASTE_FOLD_MIN_CHARS ||
countTextLines(text) >= PASTE_FOLD_MIN_LINES;
if (!overThreshold) return false;
// An attachment this large cannot be uploaded, so the paste keeps today's
// inline behavior rather than becoming a card that can never be sent.
return pastedTextByteLength(text) <= MAX_FILE_ATTACHMENT_DATA_BYTES;
}
/**
* Builds the card for a folded paste. The name is the card's title with a
* `.txt` suffix, so the sent message shows what the card showed; the suffix is
* load-bearing, not cosmetic — the daemon derives a stored attachment's type
* from its stored name, so a name without it resolves the content as an opaque
* blob instead of text.
*/
export function createPastedTextFile(
text: string,
taken: ReadonlySet<string>,
): PromptFile {
const title = pastedTextTitle(text, FOLDED_PASTE_NAME_STEM);
return {
name: dedupeAttachmentName(sanitizeAttachmentName(`${title}.txt`), taken),
media_type: 'text/plain',
text,
size: pastedTextByteLength(text),
};
}