From 4edbdd443ecb22543ead13205df585d20537bed1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:54:13 +0800 Subject: [PATCH] fix(web): repair Safari textarea soft-wrap shrink --- ...text-layers-share-one-scrollport.i18n.yaml | 4 +- ...mposer-text-layers-share-one-scrollport.md | 2 + ...ser-text-layers-share-one-scrollport.zh.md | 2 + ...safari-textarea-soft-wrap-reflow.i18n.yaml | 6 + ...-08-13-safari-textarea-soft-wrap-reflow.md | 47 +++++++ ...-13-safari-textarea-soft-wrap-reflow.zh.md | 47 +++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/skeleton/InputBar.tsx | 18 ++- .../src/client/skeleton/safari.ts | 42 ++++++ .../tests/input-bar.client.spec.tsx | 110 +++++++++++++++ .../tests/safari.client.spec.ts | 131 ++++++++++++++++++ 13 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md create mode 100644 packages/client/ui-conversation/src/client/skeleton/safari.ts create mode 100644 packages/client/ui-conversation/tests/safari.client.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index 92dcd28a7e..4c5e88d55e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: 6097779529f86e6d994296ae396f108c63f01abc -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 753d67d538d0c17512444639d60b7b5c8ff80e9a +2026-07-31-composer-text-layers-share-one-scrollport.md: d01231f706a7d3850ce1b3770ef351cb7e211384 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 3ce8cc05dc57b05bb8c0bd903a0b64b12d4a6963 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index 6097779529..d01231f706 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -24,6 +24,8 @@ One scrolling box, holding both layers. The browser then applies one offset to both layers, in the same frame, on the same compositor. The caret is bound to its glyphs by construction rather than by upkeep: there is no code to run, no event to wait for, and no state that can be one frame stale. The wheel-chaining handler stays, retargeted from the textarea to the scrollport, and remains the only listener on the box. +Safari's native text control has one engine exception: deleting across a soft-wrap threshold can retain the former line layout after the mirror shrinks. The [Safari soft-wrap recovery](2026-08-13-safari-textarea-soft-wrap-reflow.md) restores the zero-overflow invariant before paint without changing the one-scrollport design. + Two things the previous mechanism needed are gone with it: **The backdrop's trailing-line sentinel.** It existed to keep the two boxes' scroll extents equal — a textarea reserves a line box for the caret after a final newline while `white-space: pre-wrap` collapses a text node's trailing newline, so a draft ending in a newline made the backdrop one line shorter and clamped the mirrored offset a line above the caret. With one scrollport the backdrop's own extent decides nothing: the mirror div sizes the stack for both layers, both start at the same top, and a layer whose content ends earlier simply paints nothing on the last line. The shape is worth keeping in mind rather than the mechanism: it is the one that measured 628 against 652 when the two boxes had to agree on a height. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 753d67d538..3ce8cc05dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -24,6 +24,8 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl 于是浏览器在同一帧、同一个合成器上,把同一个偏移施加给两层。光标与字形的绑定来自结构本身,而不是来自持续维护:没有代码要跑,没有事件要等,也没有任何状态可能落后一帧。滚轮接力处理器保留,只是从 textarea 改挂到滚动容器上,并且仍是这个盒子上唯一的监听。 +Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的删除可能在镜像层收缩后仍保留原先的行布局。[Safari 软换行恢复](2026-08-13-safari-textarea-soft-wrap-reflow.md)会在绘制前恢复零溢出不变量,而不改变单滚动容器设计。 + 上一版机制所需要的两样东西随它一起消失: **backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行,因此以换行结尾的草稿会让 backdrop 少一行,把镜像偏移钳制在光标上方一行。改为单一滚动容器后,backdrop 自身的范围不再决定任何事:镜像层为两层统一定高,两层顶端对齐,内容更早结束的那一层只是在最后一行什么都不画。值得记住的是这类草稿形状而不是那套机制:正是它在「两个盒子必须就高度达成一致」的时代量出了 628 对 652。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml new file mode 100644 index 0000000000..f7fde5254a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md +2026-08-13-safari-textarea-soft-wrap-reflow.md: fb264a8e6fbe24369584f2427bbb0c462b450ecf +2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: 7f55a5260e825059e1f9a08db03f13e19484d14e diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md new file mode 100644 index 0000000000..fb264a8e6f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md @@ -0,0 +1,47 @@ +# Agent Note: Safari textarea soft-wrap shrink recovery + +Status: implemented + +English | [中文](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md) + +## Problem + +The composer keeps the caret and selection in a transparent native textarea while the backdrop paints visible glyphs and the hidden mirror determines the full draft height. The [single-scrollport decision](2026-07-31-composer-text-layers-share-one-scrollport.md) therefore depends on the textarea owning no scrollable overflow: after every draft commit, its `scrollHeight` and `clientHeight` are equal and its `scrollTop` is zero. + +Safari 26.5.2 can retain the textarea's former native line layout when Backspace moves a draft across a soft-wrap threshold at the same time that React updates the mirror. In the reproduced two-line-to-one-line transition, the mirror, backdrop, grow stack, and textarea box all become 28px high, but the textarea still reports `scrollHeight=52` and `scrollTop=20`. The caret remains in the stale native line while the backdrop correctly paints one line. + +The `color` declaration is not a layout input. Changing its inline style changes the computed color but leaves the stale `52/28/20` state intact. Editing the stylesheet rule happens to trigger broader rule invalidation and clears the state to `28/28/0`, which explains why Web Inspector makes the declaration appear causal. + +## Decision + +`InputBar` detects Safari once from the Apple vendor and the `Version/... Safari/...` user-agent form, while rejecting known alternate iOS browser tokens such as `CriOS`, `FxiOS`, `EdgiOS`, and `OPiOS`. A browser shell indistinguishable through these identity fields still has to violate the textarea overflow invariant before the recovery mutates layout. + +The native textarea change handler records whether an edit shortens the controlled draft. After that draft commits, a layout effect returns without reading geometry unless both the cached Safari identity and the native-shrink signal are present. It then checks the single-scrollport invariant: equal `scrollHeight` and `clientHeight` are settled and trigger no forced layout. A mismatch first changes the textarea's real height by one pixel, forces layout, restores the owned height, and forces layout again. This rebuilds Safari's native text-control layout without changing the value, selection, IME state, or undo transaction. + +The temporary native overflow can leave the draft scrollport's auto height at the former line count even after the textarea is correct. The recovery therefore repeats the one-pixel invalidation on `[data-input-scroll]` after repairing the textarea. Both elements return to their owned styles before paint; the settled one-line state is `scrollHeight=clientHeight=28`, `scrollTop=0`, and a 28px scrollport. + +## Verification + +Component tests synthesize Safari's stale metrics, assert the textarea-then-scrollport invalidation order, preserve selection, and prove that a growing native draft reads no geometry. Browser-identity tests cover desktop and mobile Safari, desktop Chromium, Chrome, Edge, and Opera on iOS, and an Apple web view. + +The assembled package is also exercised in Safari 26.5.2 through the native 51-character-to-50-character Backspace path. Playwright WebKit 26.5 settles correctly without the workaround in both the assembled app and a reduced page, so the repository's Chromium browser lane cannot reproduce this Safari application defect; the focused component test pins the engine state until an automatable Safari lane exists. + +## Alternatives considered + +**Change `color` or use `-webkit-text-fill-color`.** Rejected because inline color changes and transparent text fill leave the stale native geometry unchanged. Stylesheet-rule editing works only because its invalidation scope is broader than the declaration's paint semantics. + +**Set `scrollTop=0`.** Rejected because it moves the stale native content without rebuilding its two-line `scrollHeight`; the caret can become clipped instead of aligned. + +**Rewrite the textarea value.** Clearing and restoring the value rebuilds Safari's text control, but it mutates the editing state that owns IME composition and selection. The height invalidation leaves the value untouched. + +**Use `field-sizing: content`.** Rejected because Safari reproduces the stale two-line intrinsic height after the same deletion, and the composer still needs the mirror as the caret ruler and backdrop metric peer. + +**Invalidate only the textarea or only the scrollport.** Rejected because the textarea-only recovery clears `52/28/20` but can leave the scrollport at 52px, while the scrollport-only recovery leaves the textarea's native overflow untouched. The ordered pair is the smallest complete recovery. + +**Check geometry after every Safari draft commit.** Rejected because reading `scrollHeight` or `clientHeight` after React changes the mirror can synchronously lay out even a healthy growing draft. A native shortening signal limits the invariant read to edits that can produce the observed shrink defect. + +**Run the recovery in every browser.** Rejected because Chromium, Playwright WebKit, and Firefox maintain the invariant without forced layouts. The Safari identity and observed mismatch jointly bound the synchronous work. + +## Consequences + +Non-Safari browsers, programmatic draft updates, and native edits that do not shorten the draft perform no geometry read. A native Safari shortening reads the overflow invariant and pays the four forced layouts only when the textarea violates it. The exceptional path accepts rare local work before paint to preserve caret alignment, native editing semantics, and the single scrolling box. An equivalent stale state caused only by resize or sidebar width changes has not been observed and is outside this recovery trigger. The browser test gap remains explicit: real Safari evidence owns the engine defect, while deterministic component coverage owns the recovery and its browser gate. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md new file mode 100644 index 0000000000..7f55a5260e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Safari textarea 软换行收缩恢复 + +Status: implemented + +[English](2026-08-13-safari-textarea-soft-wrap-reflow.md) | 中文 + +## 问题 + +composer 把光标与选区留在透明的原生 textarea 中,由 backdrop 绘制可见字形,并由隐藏的镜像层决定完整草稿高度。因此,[单滚动容器决策](2026-07-31-composer-text-layers-share-one-scrollport.md)依赖 textarea 不持有可滚动溢出:每次草稿提交后,它的 `scrollHeight` 与 `clientHeight` 相等,`scrollTop` 为零。 + +当 Backspace 让草稿跨过软换行阈值,同时 React 更新镜像层时,Safari 26.5.2 可能保留 textarea 原先的原生行布局。在复现出的两行变一行转换中,镜像层、backdrop、自增高栈和 textarea 盒都变为 28px 高,但 textarea 仍报告 `scrollHeight=52` 与 `scrollTop=20`。光标留在陈旧的原生行中,而 backdrop 已正确绘制为一行。 + +`color` 声明不是布局输入。修改 inline style 会改变计算后的颜色,却让陈旧的 `52/28/20` 状态保持不变。编辑样式表规则会碰巧触发范围更广的规则失效,并把状态清为 `28/28/0`,这正是 Web Inspector 让该声明显得像成因的原因。 + +## 决策 + +`InputBar` 通过 Apple vendor 与 `Version/... Safari/...` 形式的 user agent 一次性识别 Safari,同时排除 `CriOS`、`FxiOS`、`EdgiOS`、`OPiOS` 等已知的 iOS 其他浏览器 token。仅凭这些 identity 字段无法区分的浏览器壳仍必须先违反 textarea 溢出不变量,恢复逻辑才会修改布局。 + +原生 textarea change handler 会记录本次编辑是否缩短受控草稿。草稿提交后,除非同时存在已缓存的 Safari identity 与原生缩短信号,否则 layout effect 会在读取几何前直接返回。随后它才检查单滚动容器不变量:`scrollHeight` 与 `clientHeight` 相等即为稳定态,不会触发强制布局。出现差异时,逻辑先把 textarea 的实际高度改变一个像素,强制布局,再恢复其自有高度并再次强制布局。这样无需改变值、选区、输入法组合状态或撤销事务,即可重建 Safari 的原生文本控件布局。 + +即使 textarea 已正确恢复,临时的原生溢出仍可能让草稿滚动容器的 auto 高度停在原行数。因此,恢复逻辑会在修复 textarea 后,对 `[data-input-scroll]` 重复一次单像素失效。两个元素都会在绘制前恢复各自拥有的样式;稳定的一行状态为 `scrollHeight=clientHeight=28`、`scrollTop=0`,滚动容器高度为 28px。 + +## 验证 + +组件测试会合成 Safari 的陈旧度量,断言先 textarea 后滚动容器的失效顺序,保留选区,并证明原生草稿增长不会读取几何。浏览器 identity 测试覆盖桌面与移动 Safari、桌面 Chromium、iOS Chrome/Edge/Opera 和 Apple web view。 + +组装后的包还会在 Safari 26.5.2 中通过原生的 51 字符到 50 字符 Backspace 路径验证。Playwright WebKit 26.5 在组装应用与最小化页面中都无需本绕法即可正确稳定,因此仓库的 Chromium 浏览器泳道无法复现这个 Safari 应用缺陷;在可自动化的 Safari 泳道出现之前,由聚焦组件测试固定该引擎状态。 + +## 备选方案 + +**修改 `color` 或使用 `-webkit-text-fill-color`。** 被否决,因为 inline color 修改与透明 text fill 都不会改变陈旧的原生几何。编辑样式表规则之所以有效,只是因为其失效范围比该声明的绘制语义更广。 + +**设置 `scrollTop=0`。** 被否决,因为这只会移动陈旧的原生内容,不会重建其两行 `scrollHeight`;光标可能从错位变为被裁剪。 + +**重写 textarea 的值。** 清空再恢复值能够重建 Safari 文本控件,但会改动拥有输入法组合与选区的编辑状态。高度失效不会触碰值。 + +**使用 `field-sizing: content`。** 被否决,因为相同删除后 Safari 的两行固有高度仍会陈旧,并且 composer 仍需要镜像层充当光标标尺与 backdrop 的度量对端。 + +**只让 textarea 或滚动容器失效。** 被否决,因为只恢复 textarea 虽能清除 `52/28/20`,却可能把滚动容器留在 52px;只恢复滚动容器则不会改变 textarea 的原生溢出。这个有序二元操作是最小的完整恢复。 + +**每次 Safari 草稿提交后都检查几何。** 被否决,因为 React 改变镜像层后读取 `scrollHeight` 或 `clientHeight`,即使草稿健康增长也可能同步执行布局。原生缩短信号把不变量读取限制在可能产生已观测收缩缺陷的编辑中。 + +**在所有浏览器中运行恢复逻辑。** 被否决,因为 Chromium、Playwright WebKit 与 Firefox 无需强制布局即可维持该不变量。Safari identity 与已观测到的差异共同限定同步工作范围。 + +## 影响 + +非 Safari 浏览器、程序化草稿更新,以及不会缩短草稿的原生编辑都不会读取几何。Safari 的原生缩短会读取溢出不变量,并且仅在 textarea 违反不变量时承担四次强制布局。例外路径以绘制前的罕见局部工作换取光标对齐、原生编辑语义与单一滚动盒。尚未观测到仅由 resize 或侧栏宽度变化引发的同类陈旧状态,本恢复触发器也不覆盖它。浏览器测试缺口保持显式:真实 Safari 证据负责引擎缺陷,确定性的组件覆盖负责恢复逻辑与浏览器门控。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 954ba5d7af..6d866e05e7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: ecf463da619662fe158511079e7773ee3c627ac8 -README.zh.md: f8922b160cdc6feca94dea998163c25d803a5535 +README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf +README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ecf463da61..d1a265b578 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. -The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f8922b160c..3f303391d3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。 别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9585677ae3..000174f513 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -6,7 +6,7 @@ * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { @@ -31,6 +31,7 @@ import { } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' +import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ @@ -106,6 +107,8 @@ export function InputBar({ const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) + const safari = useMemo(() => isSafariBrowser(navigator), []) + const safariNativeShrinkRef = useRef(false) // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. const composingRef = useRef(false) @@ -154,6 +157,18 @@ export function InputBar({ } }, [attachments, input?.imageIds, inputActions]) + // A native Safari edit that shortens the draft may leave the previous + // soft-wrap layout behind after the mirror shrinks. The native-change signal + // keeps ordinary typing and programmatic draft updates from reading layout; + // the helper then repairs only measured overflow before paint while + // preserving native editing state. See + // .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md. + useLayoutEffect(() => { + const nativeShrink = safariNativeShrinkRef.current + safariNativeShrinkRef.current = false + if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current) + }, [draft, safari]) + useEffect(() => { if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) }, [attachments, preview]) @@ -343,6 +358,7 @@ export function InputBar({ if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value + safariNativeShrinkRef.current = safari && next.length < draft.length keyboard.setDraft(next) // selectionStart is number|null in lib.dom; the type-aware lint program narrows it. // oxlint-disable-next-line typescript/no-unnecessary-condition diff --git a/packages/client/ui-conversation/src/client/skeleton/safari.ts b/packages/client/ui-conversation/src/client/skeleton/safari.ts new file mode 100644 index 0000000000..d563b25e6c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/safari.ts @@ -0,0 +1,42 @@ +/** Safari-specific textarea layout recovery for the conversation composer. */ + +/** Browser identity fields needed to distinguish Safari from other WebKit-based browsers. */ +export interface BrowserIdentity { + readonly userAgent: string + readonly vendor: string +} + +const ALTERNATE_IOS_BROWSER = /\b(?:CriOS|FxiOS|EdgiOS|OPiOS|OPT|DuckDuckGo|Brave)(?:\/|\b)/ + +/** + * Detect Safari's `Version/... Safari/...` form while excluding known alternate iOS browser tokens. + * @param identity - Browser user-agent and vendor values. + * @returns Whether the identity should use the Safari-specific recovery. + */ +export function isSafariBrowser(identity: BrowserIdentity): boolean { + return identity.vendor === 'Apple Computer, Inc.' + && /\bVersion\/[\d.]+.*\bSafari\/[\d.]+/.test(identity.userAgent) + && !ALTERNATE_IOS_BROWSER.test(identity.userAgent) +} + +/** + * Repair Safari's stale native textarea layout and the scrollport auto height it can contaminate. + * @param input - Composer textarea whose own scrollable overflow must stay zero. + */ +export function repairSafariTextareaLayout(input: HTMLTextAreaElement | null): void { + if (input === null || input.scrollHeight <= input.clientHeight) return + const scrollport = input.closest('[data-input-scroll]') + if (scrollport === null) return + + const inputHeight = input.style.height + input.style.height = `${String(input.clientHeight + 1)}px` + void input.offsetHeight + input.style.height = inputHeight + void input.offsetHeight + + const scrollportHeight = scrollport.style.height + scrollport.style.height = `${String(scrollport.clientHeight + 1)}px` + void scrollport.offsetHeight + scrollport.style.height = scrollportHeight + void scrollport.offsetHeight +} diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 94354bd316..f7d5e02a7f 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -824,6 +824,116 @@ describe('running and lock semantics', () => { expect(backdrop.textContent).toBe('line\n'.repeat(40)) }) + it('repairs Safari native overflow after the mirror shrinks the draft', () => { + const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.') + const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + ) + onTestFinished(() => { + vendor.mockRestore() + userAgent.mockRestore() + }) + const { textarea } = bench({ draft: 'two wrapped lines' }) + const scrollport = textarea.closest('[data-input-scroll]')! + let inputRepaired = false + let scrollportRepaired = false + const inputLayouts: string[] = [] + const scrollportLayouts: string[] = [] + Object.defineProperty(textarea, 'clientHeight', { + configurable: true, + get: () => textarea.style.height === '29px' ? 29 : 28, + }) + Object.defineProperty(textarea, 'scrollHeight', { + configurable: true, + get: () => inputRepaired ? 28 : 52, + }) + Object.defineProperty(textarea, 'offsetHeight', { + configurable: true, + get: () => { + inputLayouts.push(textarea.style.height) + if (textarea.style.height === '') inputRepaired = true + return textarea.clientHeight + }, + }) + Object.defineProperty(scrollport, 'clientHeight', { + configurable: true, + get: () => { + if (scrollport.style.height === '53px') return 53 + if (inputRepaired && !scrollportRepaired) return 52 + return 28 + }, + }) + Object.defineProperty(scrollport, 'offsetHeight', { + configurable: true, + get: () => { + scrollportLayouts.push(scrollport.style.height) + if (scrollport.style.height === '') scrollportRepaired = true + return scrollport.clientHeight + }, + }) + textarea.setSelectionRange(5, 5) + + fireEvent.change(textarea, { target: { value: 'one line' } }) + + expect(inputLayouts).toEqual(['29px', '']) + expect(scrollportLayouts).toEqual(['53px', '']) + expect(textarea.style.height).toBe('') + expect(scrollport.style.height).toBe('') + expect(textarea.scrollHeight).toBe(textarea.clientHeight) + expect(scrollport.clientHeight).toBe(28) + }) + + it('does not force the Safari recovery for another iOS browser', () => { + const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.') + const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1', + ) + onTestFinished(() => { + vendor.mockRestore() + userAgent.mockRestore() + }) + const { textarea } = bench({ draft: 'two wrapped lines' }) + const scrollport = textarea.closest('[data-input-scroll]')! + Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 }) + Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 }) + Object.defineProperty(textarea, 'offsetHeight', { + configurable: true, + get: () => { throw new Error('non-Safari browser must not force textarea layout') }, + }) + Object.defineProperty(scrollport, 'offsetHeight', { + configurable: true, + get: () => { throw new Error('non-Safari browser must not force scrollport layout') }, + }) + + fireEvent.change(textarea, { target: { value: 'one line' } }) + + expect(scrollport.style.height).toBe('') + }) + + it('does not read Safari layout while a native edit grows the draft', () => { + const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.') + const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + ) + onTestFinished(() => { + vendor.mockRestore() + userAgent.mockRestore() + }) + const { textarea, shell } = bench({ draft: 'one line' }) + Object.defineProperty(textarea, 'clientHeight', { + configurable: true, + get: () => { throw new Error('growing Safari input must not read layout') }, + }) + Object.defineProperty(textarea, 'scrollHeight', { + configurable: true, + get: () => { throw new Error('growing Safari input must not read layout') }, + }) + + fireEvent.change(textarea, { target: { value: 'one line grows' } }) + + expect(shell.snapshot.draft).toBe('one line grows') + }) + it('an edit the composer performs itself scrolls the caret back into view', async () => { // Paste and cut suppress the native edit, so no engine reveals the caret // for them. jsdom has no layout: the rects are stubbed, diff --git a/packages/client/ui-conversation/tests/safari.client.spec.ts b/packages/client/ui-conversation/tests/safari.client.spec.ts new file mode 100644 index 0000000000..895680d693 --- /dev/null +++ b/packages/client/ui-conversation/tests/safari.client.spec.ts @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { isSafariBrowser, repairSafariTextareaLayout } from '../src/client/skeleton/safari.ts' + +describe('Safari browser detection', () => { + it.each([ + { + name: 'desktop Safari', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + expected: true, + }, + { + name: 'mobile Safari', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1', + expected: true, + }, + { + name: 'desktop Chromium', + vendor: 'Google Inc.', + userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + expected: false, + }, + { + name: 'Chrome on iOS', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1', + expected: false, + }, + { + name: 'Edge on iOS with Safari tokens', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 EdgiOS/140.0 Mobile/15E148 Safari/604.1', + expected: false, + }, + { + name: 'Opera on iOS with Safari tokens', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 OPiOS/6.0 Mobile/15E148 Safari/604.1', + expected: false, + }, + { + name: 'Apple web view', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148', + expected: false, + }, + ])('identifies $name', ({ vendor, userAgent, expected }) => { + expect(isSafariBrowser({ vendor, userAgent })).toBe(expected) + }) +}) + +describe('Safari textarea layout recovery', () => { + it('does nothing while the textarea owns no scrollable overflow', () => { + const input = document.createElement('textarea') + Object.defineProperty(input, 'clientHeight', { value: 28 }) + Object.defineProperty(input, 'scrollHeight', { value: 28 }) + + repairSafariTextareaLayout(input) + + expect(input.style.height).toBe('') + }) + + it('invalidates a stale native layout and restores the owned height', () => { + const input = document.createElement('textarea') + const scrollport = document.createElement('div') + scrollport.setAttribute('data-input-scroll', '') + scrollport.appendChild(input) + input.value = 'abcdef' + input.setSelectionRange(3, 3) + input.style.height = '100%' + scrollport.style.height = '100%' + let inputRepaired = false + let scrollportRepaired = false + const inputLayouts: string[] = [] + const scrollportLayouts: string[] = [] + Object.defineProperty(input, 'clientHeight', { + get: () => input.style.height === '29px' ? 29 : 28, + }) + Object.defineProperty(input, 'scrollHeight', { + get: () => inputRepaired ? 28 : 52, + }) + Object.defineProperty(input, 'offsetHeight', { + get: () => { + inputLayouts.push(input.style.height) + if (input.style.height === '100%') inputRepaired = true + return input.clientHeight + }, + }) + Object.defineProperty(scrollport, 'clientHeight', { + get: () => { + if (scrollport.style.height === '53px') return 53 + if (inputRepaired && !scrollportRepaired) return 52 + return 28 + }, + }) + Object.defineProperty(scrollport, 'offsetHeight', { + get: () => { + scrollportLayouts.push(scrollport.style.height) + if (scrollport.style.height === '100%') scrollportRepaired = true + return scrollport.clientHeight + }, + }) + + repairSafariTextareaLayout(input) + + expect(inputLayouts).toEqual(['29px', '100%']) + expect(scrollportLayouts).toEqual(['53px', '100%']) + expect(input.style.height).toBe('100%') + expect(scrollport.style.height).toBe('100%') + expect(input.scrollHeight).toBe(input.clientHeight) + expect(scrollport.clientHeight).toBe(28) + expect([input.selectionStart, input.selectionEnd]).toEqual([3, 3]) + }) + + it('does nothing outside the composer scrollport', () => { + const input = document.createElement('textarea') + Object.defineProperty(input, 'clientHeight', { value: 28 }) + Object.defineProperty(input, 'scrollHeight', { value: 52 }) + + repairSafariTextareaLayout(input) + + expect(input.style.height).toBe('') + }) + + it('accepts an absent textarea during teardown', () => { + expect(() => { repairSafariTextareaLayout(null) }).not.toThrow() + }) +})