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 = ( -
- ))} + ); + if (foldedText === undefined) { + return ( +
+ + {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 ( +
+ + + + + + + {meta === undefined ? null : {meta}} + + + {removeButton} +
+ ); + })}
)} diff --git a/packages/web-shell/client/e2e/web-shell.paste-fold.spec.ts b/packages/web-shell/client/e2e/web-shell.paste-fold.spec.ts new file mode 100644 index 00000000000..f37ea6f7c73 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.paste-fold.spec.ts @@ -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 { + return installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); +} + +async function gotoSession( + page: Page, + scenario: WebShellDaemonScenario, + daemon: MockDaemonController, +): Promise { + 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 { + 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'); +}); diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 22bfde003e1..6ca8e0d08c9 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -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; @@ -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 (
+
); } @@ -123,6 +128,7 @@ async function mount({ workspaceUploadBusy, fileDragEnabled, attachmentsEnabled, + disabled, }: { composerInput?: WebShellComposerInput; onSubmit?: ReturnType; @@ -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} /> , @@ -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 () => { diff --git a/packages/web-shell/client/hooks/useComposerCore.mobile.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.mobile.dom.test.tsx index b386df5f8a3..017822bb05a 100644 --- a/packages/web-shell/client/hooks/useComposerCore.mobile.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.mobile.dom.test.tsx @@ -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(); diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 2a5e80a12c2..5c880343bf7 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -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( () => ({ 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, diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index c88ebecb631..e7af155ac64 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -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': '处理中。新消息会进入队列。', diff --git a/packages/web-shell/client/utils/largePaste.test.ts b/packages/web-shell/client/utils/largePaste.test.ts new file mode 100644 index 00000000000..d70a5300cbc --- /dev/null +++ b/packages/web-shell/client/utils/largePaste.test.ts @@ -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); + }); +}); diff --git a/packages/web-shell/client/utils/largePaste.ts b/packages/web-shell/client/utils/largePaste.ts new file mode 100644 index 00000000000..f17f358c1ad --- /dev/null +++ b/packages/web-shell/client/utils/largePaste.ts @@ -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, +): PromptFile { + const title = pastedTextTitle(text, FOLDED_PASTE_NAME_STEM); + return { + name: dedupeAttachmentName(sanitizeAttachmentName(`${title}.txt`), taken), + media_type: 'text/plain', + text, + size: pastedTextByteLength(text), + }; +}