diff --git a/docs/design/web-shell/web-shell-paste-as-attachment.md b/docs/design/web-shell/web-shell-paste-as-attachment.md
new file mode 100644
index 00000000000..69d7661886b
--- /dev/null
+++ b/docs/design/web-shell/web-shell-paste-as-attachment.md
@@ -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.
diff --git a/docs/design/web-shell/web-shell-paste-as-attachment.zh-CN.md b/docs/design/web-shell/web-shell-paste-as-attachment.zh-CN.md
new file mode 100644
index 00000000000..bc84cf52542
--- /dev/null
+++ b/docs/design/web-shell/web-shell-paste-as-attachment.zh-CN.md
@@ -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`。
+
+没有待定的设计问题。上面记录的后续项(插入也携带附件)是刻意排除的范围,不是未决的决策。
diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css
index 8401bd3c236..15977392c43 100644
--- a/packages/web-shell/client/components/ChatEditor.module.css
+++ b/packages/web-shell/client/components/ChatEditor.module.css
@@ -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;
diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx
index ad828cf6ecc..5b48a3a562a 100644
--- a/packages/web-shell/client/components/ChatEditor.test.tsx
+++ b/packages/web-shell/client/components/ChatEditor.test.tsx
@@ -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: '
Report
',
},
],
});
@@ -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(
+ '[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(
+ '[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('[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(
+ '[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(
+ '[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');
diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx
index c375b1af1ba..37ffeadbd62 100644
--- a/packages/web-shell/client/components/ChatEditor.tsx
+++ b/packages/web-shell/client/components/ChatEditor.tsx
@@ -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 && (
- {core.pastedFiles.map((file, i) => (
-
-
+ {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 = (
-
+ );
+ }
+ // 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 (
+