Merge pull request #2504 from deepseek-harness/fix/safari-textarea-reflow

fix(web): repair Safari textarea soft-wrap shrink
This commit is contained in:
Tianyi Cui
2026-08-17 13:54:31 +08:00
committed by GitHub
13 changed files with 410 additions and 7 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 是更靠前的前提。

View File

@@ -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<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(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

View File

@@ -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<HTMLElement>('[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
}

View File

@@ -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<HTMLElement>('[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<HTMLElement>('[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,

View File

@@ -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()
})
})