fix(web): dismiss tooltips when composer actions become disabled

This commit is contained in:
_Kerman committed 2026-09-08 17:21:13 +08:00
1 parent ef8f166ccc
commit f7c9170b3b
7 files changed
+37 -11

No files matched your search

+5 -1
View File
@@ -182,7 +182,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
const stopButton = page.getByRole('button', { name: 'Stop generating' })
await stopButton.hover()
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
await stopButton.click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
// Composer recovered; no streaming node lingers. The host settled first
@@ -190,6 +193,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// frozen-partial swap is eventually consistent, so poll rather than count.
await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
// Golden of the aborted end-state: the prompt bubble plus the frozen
// partial ('partial' is the hang entry's replayed prefix) and no more.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+5 -3
View File
@@ -227,15 +227,17 @@ describe('web e2e: queue row actions', () => {
{ timeout: 10_000 },
).toBe(2)
await page.getByRole('button', { name: 'Stop generating' }).click()
const stopButton = page.getByRole('button', { name: 'Stop generating' })
await stopButton.hover()
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
await stopButton.click()
await firstSettled
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
.toBe(0)
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
.toBe(2)
// Stop becomes Send under the pointer; capture the idle queue with tooltips dismissed.
await page.mouse.move(0, 0)
// The disabled Send button must dismiss the active Stop tooltip without mouseleave.
await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
@@ -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: e06270a339a23fa17fb9d51642317fcf2aec6c0f
README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024
README.md: 06667e15dd44c4bcaaea9212d52bb0eda8867c6e
README.zh.md: ce2b183e6fffcf00485856c8eed777368b446ac6
+1 -1
View File
@@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
<a id="temporary-composer-entries"></a>
## Temporary composer entries
+1 -1
View File
@@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
<a id="temporary-composer-entries"></a>
## 临时 composer entry
@@ -344,6 +344,8 @@ export const InputBar = memo(function InputBar({
// state keeps plain Send. A continuable child keeps Send primary and
// exposes Stop independently.
const primaryStops = running && subagent === null && (empty || blocked !== undefined)
// Disabled native buttons may omit mouseleave; their tooltip must close from state.
const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending
const interruptible = running && continuable
const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)
const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
@@ -526,7 +528,7 @@ export const InputBar = memo(function InputBar({
{sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{interruptible && (
<Tooltip label={t('input.stop')} side="top" delayMs={500}>
<Tooltip label={t('input.stop')} side="top" delayMs={500} disabled={stop === undefined}>
<button
type="button"
className={css.primary}
@@ -541,12 +543,12 @@ export const InputBar = memo(function InputBar({
</button>
</Tooltip>
)}
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<Tooltip label={primaryLabel} side="top" delayMs={500} disabled={primaryDisabled}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending}
disabled={primaryDisabled}
onMouseDown={keepFocus}
onClick={onPrimary}
>
@@ -738,6 +738,24 @@ describe('Enter semantics', () => {
})
describe('running and lock semantics', () => {
it('dismisses the Stop tooltip when an empty composer becomes idle', () => {
vi.useFakeTimers()
try {
const { button, view, session } = bench({ running: true })
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(500) })
expect(view.getByRole('tooltip').textContent).toBe('停止生成')
// Disabling a hovered native button need not deliver mouseleave.
act(() => { session.set(snapshotOf({ running: false })) })
expect(button.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(view.queryByRole('tooltip')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('running switches the primary between Stop and Queue Send with the draft', async () => {
const { textarea, button, stop, sink, shell } = bench({ running: true })
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')