From 55fc87a7a018f29d677c4509d6bee96910b18698 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 12:58:20 +0800 Subject: [PATCH] fix: cr --- ...-07-27-user-message-icon-actions.i18n.yaml | 4 +- .../2026-07-27-user-message-icon-actions.md | 4 +- ...2026-07-27-user-message-icon-actions.zh.md | 4 +- .../src/client/chat/MessageItem.module.css | 18 ++++--- .../src/client/chat/MessageItem.tsx | 7 ++- .../client/toolviews/bash-sample.module.css | 9 ++++ .../src/client/toolviews/bash-sample.tsx | 12 +++++ .../tests/coverage-tails.spec.tsx | 2 + .../ui-primitives/src/markdown/CodeBlock.tsx | 30 +++++++----- .../ui-primitives/tests/code-block.spec.tsx | 48 +++++++++++++------ 10 files changed, 99 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml index 52fa9a6cb3..e664ec81fb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.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 -2026-07-27-user-message-icon-actions.md: 45856e1ee093b1bfeaebfe67339aec7b6d2dd694 -2026-07-27-user-message-icon-actions.zh.md: ea87b8036ee91e8998f1e45dbea17f9ee76c244c +2026-07-27-user-message-icon-actions.md: 869e7a2518a3ec927c0689a10816a410dc5f0862 +2026-07-27-user-message-icon-actions.zh.md: 353e5ac765bb2fbab9932449cf2247768a1f412f diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md index 45856e1ee0..869e7a2518 100644 --- a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md @@ -10,7 +10,7 @@ The chat user bubble had no under-bubble action chrome. The Harness design (figm ## Decision -`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. The row stays `opacity: 0` until the user row is hovered or focus-within, per the [web styling](../../../../docs/web-styling.md) message action-bar rule. +`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. Actions stay visible by default; `@media (hover: hover)` hides them until the row is hovered or focus-within, so touch / `hover: none` devices keep discoverable controls (opacity alone still hit-tests). Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior. @@ -20,7 +20,7 @@ Steering bubbles keep the badge-only form and do not show these actions. **Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths. -**Always-visible actions (no hover fade).** Rejected against the standing action-bar rule; the figma node shows the resting chrome, not the idle-hidden state the style guide requires. +**Always hide with `opacity: 0` outside hover.** Rejected for touch: without `@media (hover: hover)`, idle opacity still hit-tests while looking empty. Hover-capable pointers keep the fade; others keep the actions visible. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md index ea87b8036e..353e5ac765 100644 --- a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。按 [Web 样式](../../../../docs/web-styling.md) 的消息操作栏规则,该行保持 `opacity: 0`,直到用户行被悬停或处于 focus-within 状态。 +仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。操作默认保持可见;`@media (hover: hover)` 下在悬停或 focus-within 前隐藏,以便触摸/`hover: none` 设备仍能发现控件(仅靠 opacity 仍会命中测试)。 复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。 @@ -20,7 +20,7 @@ steering(中途引导)气泡保持仅徽章形态,不展示这些操作。 **现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。 -**操作始终可见(无悬停淡入)。**与现行操作栏规则冲突,不予采纳;figma 节点展示的是静止态外观,而非样式指南要求的空闲隐藏状态。 +**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。 ## 后果 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 0d2331a199..22537f9cfe 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -25,14 +25,20 @@ align-items: center; gap: 10px; height: 28px; - /* Hidden until the row is hovered/focused (web-styling message action bar). */ - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); } -.userRow:hover .actions, -.userRow:focus-within .actions { - opacity: 1; +/* Hover-capable pointers: hide until the row is hovered/focused. Touch / + hover:none keeps actions visible (opacity:0 still hit-tests). */ +@media (hover: hover) { + .actions { + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); + } + + .userRow:hover .actions, + .userRow:focus-within .actions { + opacity: 1; + } } .action { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 67abe94ef6..4ecfdadf88 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -30,9 +30,14 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } +/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ async function writeClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) + try { + await navigator.clipboard.writeText(text) + } catch { + // Denied permissions / iframe policy. + } return } const exec = typeof document.execCommand === 'function' diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9b7116462f..9c42e69b59 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -61,3 +61,12 @@ line-height: 24px; color: var(--dsw-alias-label-tertiary); } + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 2503a6e71b..616eee5943 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -19,10 +19,21 @@ function leadingFor(state: ToolRowState) { } } +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + /** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) + const status = stateStatus(model.state) return (
{leadingFor(model.state)} + {status !== null && {status}} {isChild && scoped} {model.title} diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 084f73b4b7..18ac9a2891 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -113,9 +113,11 @@ describe('tails', () => { const errorView = render() expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(errorView.getByText('失败')).toBeTruthy() errorView.unmount() const stoppedView = render() expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() + expect(stoppedView.getByText('已停止')).toBeTruthy() }) }) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 33bcf7b80c..151af94e1c 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -18,16 +18,22 @@ export interface CodeBlockProps { className?: string | undefined } -async function writeClipboard(text: string): Promise { +/** @returns true only when the host accepted the write. */ +async function writeClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - return + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Denied permissions / iframe policy — do not claim success. + return false + } } // jsdom and older hosts: best-effort execCommand path when present. const exec = typeof document.execCommand === 'function' ? document.execCommand.bind(document) : undefined - if (exec === undefined) return + if (exec === undefined) return false const el = document.createElement('textarea') el.value = text el.setAttribute('readonly', '') @@ -36,12 +42,12 @@ async function writeClipboard(text: string): Promise { document.body.appendChild(el) el.select() try { - exec('copy') + return exec('copy') } catch { - // Clipboard unavailable (sandboxed iframe / denied permission); UI still - // flips to the ok label so the gesture is acknowledged. + return false + } finally { + el.remove() } - el.remove() } export function CodeBlock({ code, lang, className }: CodeBlockProps) { @@ -55,9 +61,11 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) { /* v8 ignore next -- both arms always mount a
; trimmed is the
        typed fallback if the DOM shape ever diverges. */
     const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
-    void writeClipboard(text)
-    setCopied(true)
-    window.setTimeout(() => setCopied(false), 1000)
+    void writeClipboard(text).then((ok) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => setCopied(false), 1000)
+    })
   }, [copied, trimmed])
 
   const body = html === undefined
diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx
index b6fdc866f9..47b0ad24fb 100644
--- a/packages/client/ui-primitives/tests/code-block.spec.tsx
+++ b/packages/client/ui-primitives/tests/code-block.spec.tsx
@@ -6,7 +6,7 @@
 // alongside the rest of the markdown family.
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
 import { highlightToHtml } from '../src/markdown/highlight.ts'
 
@@ -65,6 +65,10 @@ describe('CodeBlock', () => {
     expect(screen.getByText('ts')).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: '复制' }))
     expect(writeText).toHaveBeenCalledWith('const a = 1')
+    // Flush the clipboard promise under fake timers before asserting the label.
+    await act(async () => {
+      await Promise.resolve()
+    })
     expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
     // While the ok label is showing, further clicks are no-ops.
     fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
@@ -73,7 +77,22 @@ describe('CodeBlock', () => {
     expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
   })
 
-  it('falls back to execCommand when clipboard.writeText is unavailable', () => {
+  it('does not claim success when clipboard.writeText rejects', async () => {
+    const writeText = vi.fn().mockRejectedValue(new Error('denied'))
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText },
+    })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
+  })
+
+  it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
     Object.defineProperty(navigator, 'clipboard', {
       configurable: true,
       value: undefined,
@@ -86,9 +105,10 @@ describe('CodeBlock', () => {
     render()
     fireEvent.click(screen.getByRole('button', { name: '复制' }))
     expect(exec).toHaveBeenCalledWith('copy')
+    expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
   })
 
-  it('still acknowledges copy when execCommand throws', () => {
+  it('does not claim success when execCommand throws or is absent', async () => {
     Object.defineProperty(navigator, 'clipboard', {
       configurable: true,
       value: undefined,
@@ -99,22 +119,20 @@ describe('CodeBlock', () => {
         throw new Error('denied')
       },
     })
-    render()
-    fireEvent.click(screen.getByRole('button', { name: '复制' }))
-    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
-  })
+    const denied = render()
+    fireEvent.click(denied.getByRole('button', { name: '复制' }))
+    await Promise.resolve()
+    expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
+    denied.unmount()
 
-  it('acknowledges copy when neither clipboard API nor execCommand exists', () => {
-    Object.defineProperty(navigator, 'clipboard', {
-      configurable: true,
-      value: undefined,
-    })
     Object.defineProperty(document, 'execCommand', {
       configurable: true,
       value: undefined,
     })
-    render()
-    fireEvent.click(screen.getByRole('button', { name: '复制' }))
-    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
+    const absent = render()
+    fireEvent.click(absent.getByRole('button', { name: '复制' }))
+    await Promise.resolve()
+    expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
+    expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
   })
 })