Merge pull request #4945 from deepseek-harness/fix/4931-composer-focus

fix(client): preserve cursor and selection on composer whitespace clicks
This commit is contained in:
Ruilin Geng authored and GitHub committed 2026-10-09 16:22:17 +08:00
commit 5f3c7b98e2
9 files changed
+256 -3

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
/** Browser selection and focus after presses in the composer's unused space. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
} from './scaffold.ts'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-focus', import.meta.url))
const EXPECTED = join(SNAPSHOT_DIR, 'focus.expected.md')
/**
* Observe focus and both ends of the browser selection, including its direction.
* @param page - Page containing the active composer.
* @returns The editor's focus, draft and DOM selection.
*/
async function selectionState(page: Page) {
return await page.locator('[data-composer-input]').evaluate((input) => {
const selection = window.getSelection()
return {
focused: document.activeElement === input,
draft: input.textContent,
text: selection?.toString(),
anchor: selection?.anchorOffset,
focus: selection?.focusOffset,
}
})
}
/**
* Format observed browser focus and selection for the golden file.
* @param state - Current browser selection.
* @returns Focus, selected text and directed offsets.
*/
function describeSelection(state: Awaited<ReturnType<typeof selectionState>>): string {
return `${state.focused ? 'focused' : 'unfocused'}; selection ${JSON.stringify(state.text)} (${state.anchor} → ${state.focus})`
}
/**
* Exercise the card's top, inter-row gap, toolbar gap and bottom padding.
* @param page - Page containing an editable composer.
* @returns Stable behavior observations for the expected-output file.
*/
async function exerciseComposer(page: Page): Promise<string[]> {
const input = page.locator('[data-composer-input][contenteditable="true"]')
await input.click()
await page.keyboard.insertText('alpha beta gamma')
await page.keyboard.press('Home')
for (let index = 0; index < 10; index++) await page.keyboard.press('ArrowRight')
for (let index = 0; index < 4; index++) await page.keyboard.press('Shift+ArrowLeft')
const selected = { focused: true, draft: 'alpha beta gamma', text: 'beta', anchor: 10, focus: 6 }
await expect.poll(() => selectionState(page)).toEqual(selected)
const points = await page.locator('[data-composer-card]').evaluate((card) => {
const box = card.getBoundingClientRect()
const scroll = card.querySelector('[data-input-scroll]')!.getBoundingClientRect()
const rowElement = card.querySelector('[data-input-scroll]')!.nextElementSibling!
const row = rowElement.getBoundingClientRect()
const tools = rowElement.firstElementChild!.getBoundingClientRect()
const trailing = rowElement.lastElementChild!.getBoundingClientRect()
if (trailing.left <= tools.right) throw new Error('Composer toolbar has no unused gap')
return [
{ name: 'top padding', x: box.x + box.width / 2, y: (box.top + scroll.top) / 2 },
{ name: 'row gap', x: box.x + box.width / 2, y: (scroll.bottom + row.top) / 2 },
{ name: 'toolbar gap', x: (tools.right + trailing.left) / 2, y: row.top + row.height / 2 },
{ name: 'bottom padding', x: box.x + box.width / 2, y: box.bottom - 2 },
]
})
const observations: string[] = []
for (const point of points) {
await page.mouse.click(point.x, point.y)
await expect.poll(() => selectionState(page)).toEqual(selected)
observations.push(`- ${point.name}: ${describeSelection(await selectionState(page))}`)
}
const top = points[0]!
const cardBox = await page.locator('[data-composer-card]').boundingBox()
await page.mouse.click(cardBox!.x - 12, top.y)
await expect.poll(async () => (await selectionState(page)).focused).toBe(false)
await page.mouse.click(top.x, top.y)
await expect.poll(() => selectionState(page)).toEqual(selected)
observations.push(`- return from outside: ${describeSelection(await selectionState(page))}`)
await page.keyboard.insertText('BETA')
await expect.poll(() => input.textContent()).toBe('alpha BETA gamma')
await page.keyboard.press('ArrowLeft')
const caret = { focused: true, draft: 'alpha BETA gamma', text: '', anchor: 9, focus: 9 }
await expect.poll(() => selectionState(page)).toEqual(caret)
await page.mouse.click(top.x, top.y)
await expect.poll(() => selectionState(page)).toEqual(caret)
await page.keyboard.insertText('!')
await expect.poll(() => input.textContent()).toBe('alpha BET!A gamma')
observations.push(`- typing replaces the restored selection and resumes at the restored caret: ${await input.textContent()}`)
return observations
}
it('restores the previous caret or selection in fresh and existing sessions', async () => {
const scaffold = await launchWebScaffold({})
const browserPromise = chromium.launch()
onTestFinished(async () => {
try { await (await browserPromise).close() }
finally { await scaffold.close() }
})
const browser = await browserPromise
const fixture = createChatScrollFixture({ markerPrefix: 'COMPOSER_FOCUS', title: 'Composer focus', turns: 1 })
await seedSession(scaffold, fixture.log, 'composer-focus-web-e2e')
const page = await newEnglishPage(browser)
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-focus'))
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-focus')
const hero = await exerciseComposer(page)
await page.getByRole('button', { name: 'Search sessions' }).click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
await result.click()
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
const docked = await exerciseComposer(page)
await compareOrRefreshGolden(EXPECTED, [
'# Composer whitespace focus', '', '## Fresh session', '', ...hero,
'', '## Existing session', '', ...docked,
].join('\n'), webSnapshotMode())
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md'])
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 180_000)
@@ -0,0 +1,19 @@
# Composer whitespace focus
## Fresh session
- top padding: focused; selection "beta" (10 → 6)
- row gap: focused; selection "beta" (10 → 6)
- toolbar gap: focused; selection "beta" (10 → 6)
- bottom padding: focused; selection "beta" (10 → 6)
- return from outside: focused; selection "beta" (10 → 6)
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
## Existing session
- top padding: focused; selection "beta" (10 → 6)
- row gap: focused; selection "beta" (10 → 6)
- toolbar gap: focused; selection "beta" (10 → 6)
- bottom padding: focused; selection "beta" (10 → 6)
- return from outside: focused; selection "beta" (10 → 6)
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
+1
View File
@@ -135,6 +135,7 @@
"tests/present.e2e.ts",
"tests/present-svg.e2e.ts",
"tests/composer-draft-scroll.e2e.ts",
"tests/composer-focus.e2e.ts",
"tests/composer-placeholder.e2e.ts",
"tests/context-meter.e2e.ts",
"tests/cordis-tool-round.e2e.ts",
@@ -18,8 +18,8 @@
en: 1d2a3a7f98c2cc9b
zh: f895dd5e9f91e0a9
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 50b5e00a5fb7c2e5
zh: 91333db3947f395d
en: 51c7c9e2537aa4be
zh: c4084541a776689e
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
@@ -86,6 +86,8 @@ The resident composer survives no-Session and Session transitions. Whitespace hi
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
Clicking unused space inside an editable composer preserves its current caret or selection when focused, or restores its previous caret or selection when unfocused. Text selection, scrollbars, controls, and embedded overlays keep their own pointer behavior.
Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. They skip local echoes because their transport does not preserve the browser request id.
@@ -86,6 +86,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
点击可编辑输入框内的留白时,已聚焦的输入框保留当前光标或选区,未聚焦的输入框恢复上次的光标或选区。文本选择、滚动条、控件和内嵌浮层保持各自的指针交互行为。
每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们不创建本地回显,因为其 transport 不保留浏览器 request id。
@@ -277,6 +277,20 @@ export const InputBar = memo(function InputBar({
keepDraftFocus(e, editor)
}
const onCardMouseDown = (e: MouseEvent<HTMLDivElement>): void => {
if (!editable || editor === null || e.button !== 0 || e.defaultPrevented) return
const target = e.target
if (!(target instanceof Element) || !e.currentTarget.contains(target)) return
if (target.closest('[data-composer-overlay]') !== null) return
if (scrollRef.current?.contains(target)) return
const control = target.closest('button, a, input, select, textarea, [role="button"], [contenteditable], [tabindex]')
if (control !== null && e.currentTarget.contains(control)) return
e.preventDefault()
// Lexical's stored selection can lag behind native caret moves, so calling
// editor.focus() on a focused editor can restore an older caret.
if (document.activeElement !== editor.getRootElement()) focusDraftEditor(editor, revealSelection)
}
const onToggleCommandMenu = (): void => {
if (keyboard === undefined) return
// The menu is a combobox over the editor, so the keyboard has to be there
@@ -384,9 +398,10 @@ export const InputBar = memo(function InputBar({
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
onMouseDown={onCardMouseDown}
>
{sessionId !== undefined && (
<div className={css.overlayAnchor}>{renderSlot('conversation.input.overlay', {})}</div>
<div className={css.overlayAnchor} data-composer-overlay>{renderSlot('conversation.input.overlay', {})}</div>
)}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{renderSlot('conversation.input.attachments', {
@@ -13,6 +13,7 @@ import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { createPortal } from 'react-dom'
import { $getRoot, $isTextNode } from 'lexical'
import {
bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError,
@@ -283,6 +284,86 @@ function writeDraft(shell: SessionInputShell, text: string): void {
}
describe('composer focus handoff', () => {
it.each(['hero', 'composer'] as const)('focuses the %s editor when its card background is pressed', (variant) => {
const { view, shell, textarea } = bench({ variant })
writeDraft(shell, 'draft text')
textarea.blur()
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
const card = view.container.querySelector('[data-composer-card]')!
expect(fireEvent.mouseDown(card, { button: 0 })).toBe(false)
expect(document.activeElement).toBe(textarea)
expect(lexicalFocus).toHaveBeenCalled()
})
it('leaves editor selection, scrollport gestures, controls and canceled presses to their owners', () => {
const { view, shell, textarea } = bench({
modelEntry: <button><span>Model</span></button>,
leftItems: <span onMouseDown={(event) => { event.preventDefault() }}>Canceled</span>,
})
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
const scrollport = view.container.querySelector('[data-input-scroll]')!
const card = view.container.querySelector('[data-composer-card]')!
expect(fireEvent.mouseDown(textarea, { button: 0 })).toBe(true)
expect(fireEvent.mouseDown(scrollport, { button: 0 })).toBe(true)
expect(fireEvent.mouseDown(view.getByText('Model'), { button: 0 })).toBe(true)
expect(fireEvent.mouseDown(view.getByText('Canceled'), { button: 0 })).toBe(false)
expect(fireEvent.mouseDown(card, { button: 2 })).toBe(true)
expect(lexicalFocus).not.toHaveBeenCalled()
})
it('leaves embedded overlay options, status text and scrollbars with the focused search field', () => {
const { view, shell } = bench({
overlay: <div>
<input aria-label="Filter models" />
<div role="listbox"><div role="option" aria-selected={false}>Model option</div></div>
<div>Loading models</div>
</div>,
})
const search = view.getByRole('textbox', { name: 'Filter models' })
act(() => { search.focus() })
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
for (const target of [
view.getByRole('option'), view.getByRole('listbox'), view.getByText('Loading models'),
view.container.querySelector('[data-composer-overlay]')!,
]) {
expect(fireEvent.mouseDown(target, { button: 0 })).toBe(true)
expect(document.activeElement).toBe(search)
}
expect(lexicalFocus).not.toHaveBeenCalled()
})
it('keeps a focused editor\'s native caret without restoring an older Lexical selection', () => {
const { view, shell, textarea } = bench()
act(() => { shell.focus() })
expect(document.activeElement).toBe(textarea)
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
expect(fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })).toBe(false)
expect(document.activeElement).toBe(textarea)
expect(lexicalFocus).not.toHaveBeenCalled()
})
it('leaves portaled menu background presses outside the card alone', () => {
const { view, shell } = bench({ modelEntry: createPortal(<div>Menu background</div>, document.body) })
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
expect(fireEvent.mouseDown(view.getByText('Menu background'), { button: 0 })).toBe(true)
expect(lexicalFocus).not.toHaveBeenCalled()
})
it.each([{ inert: true }, { disabled: true }, { blocked: { reason: 'unavailable' } }])(
'does not focus an unavailable composer: %j', (options) => {
const { view, shell } = bench(options)
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
expect(fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })).toBe(true)
expect(lexicalFocus).not.toHaveBeenCalled()
},
)
it('focus() returns the keyboard to the editor through Lexical, not a bare DOM focus', () => {
const { shell, textarea } = bench()
writeDraft(shell, 'draft text')
+1
View File
@@ -127,6 +127,7 @@
"apps/web/tests/present.e2e.ts",
"apps/web/tests/present-svg.e2e.ts",
"apps/web/tests/composer-draft-scroll.e2e.ts",
"apps/web/tests/composer-focus.e2e.ts",
"apps/web/tests/composer-placeholder.e2e.ts",
"apps/web/tests/context-meter.e2e.ts",
"apps/web/tests/cordis-tool-round.e2e.ts",