fix: cr
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-27-user-message-icon-actions.md: 45856e1ee093b1bfeaebfe67339aec7b6d2dd694
|
2026-07-27-user-message-icon-actions.md: 869e7a2518a3ec927c0689a10816a410dc5f0862
|
||||||
2026-07-27-user-message-icon-actions.zh.md: ea87b8036ee91e8998f1e45dbea17f9ee76c244c
|
2026-07-27-user-message-icon-actions.zh.md: 353e5ac765bb2fbab9932449cf2247768a1f412f
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The chat user bubble had no under-bubble action chrome. The Harness design (figm
|
|||||||
|
|
||||||
## Decision
|
## 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.
|
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.
|
**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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -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 或编辑重提交流程。
|
复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ steering(中途引导)气泡保持仅徽章形态,不展示这些操作。
|
|||||||
|
|
||||||
**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。
|
**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。
|
||||||
|
|
||||||
**操作始终可见(无悬停淡入)。**与现行操作栏规则冲突,不予采纳;figma 节点展示的是静止态外观,而非样式指南要求的空闲隐藏状态。
|
**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。
|
||||||
|
|
||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 28px;
|
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,
|
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
|
||||||
.userRow:focus-within .actions {
|
hover:none keeps actions visible (opacity:0 still hit-tests). */
|
||||||
opacity: 1;
|
@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 {
|
.action {
|
||||||
|
|||||||
@@ -30,9 +30,14 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
|||||||
return { text: texts.join(''), rest }
|
return { text: texts.join(''), rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||||
async function writeClipboard(text: string): Promise<void> {
|
async function writeClipboard(text: string): Promise<void> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text)
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
// Denied permissions / iframe policy.
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const exec = typeof document.execCommand === 'function'
|
const exec = typeof document.execCommand === 'function'
|
||||||
|
|||||||
@@ -61,3 +61,12 @@
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visuallyHidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. */
|
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||||
const model = toolRowModel(toolName, block)
|
const model = toolRowModel(toolName, block)
|
||||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||||
|
const status = stateStatus(model.state)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css.root}
|
className={css.root}
|
||||||
@@ -33,6 +44,7 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
|
|||||||
onClick={openDetails}
|
onClick={openDetails}
|
||||||
>
|
>
|
||||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||||
|
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||||
<span className={css.title}>{model.title}</span>
|
<span className={css.title}>{model.title}</span>
|
||||||
<span className={css.sep} aria-hidden />
|
<span className={css.sep} aria-hidden />
|
||||||
|
|||||||
@@ -113,9 +113,11 @@ describe('tails', () => {
|
|||||||
const errorView = render(<BashRow {...props(errorResult)} />)
|
const errorView = render(<BashRow {...props(errorResult)} />)
|
||||||
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||||
|
expect(errorView.getByText('失败')).toBeTruthy()
|
||||||
errorView.unmount()
|
errorView.unmount()
|
||||||
|
|
||||||
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
|
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
|
||||||
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||||
|
expect(stoppedView.getByText('已停止')).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,16 +18,22 @@ export interface CodeBlockProps {
|
|||||||
className?: string | undefined
|
className?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeClipboard(text: string): Promise<void> {
|
/** @returns true only when the host accepted the write. */
|
||||||
|
async function writeClipboard(text: string): Promise<boolean> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text)
|
try {
|
||||||
return
|
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.
|
// jsdom and older hosts: best-effort execCommand path when present.
|
||||||
const exec = typeof document.execCommand === 'function'
|
const exec = typeof document.execCommand === 'function'
|
||||||
? document.execCommand.bind(document)
|
? document.execCommand.bind(document)
|
||||||
: undefined
|
: undefined
|
||||||
if (exec === undefined) return
|
if (exec === undefined) return false
|
||||||
const el = document.createElement('textarea')
|
const el = document.createElement('textarea')
|
||||||
el.value = text
|
el.value = text
|
||||||
el.setAttribute('readonly', '')
|
el.setAttribute('readonly', '')
|
||||||
@@ -36,12 +42,12 @@ async function writeClipboard(text: string): Promise<void> {
|
|||||||
document.body.appendChild(el)
|
document.body.appendChild(el)
|
||||||
el.select()
|
el.select()
|
||||||
try {
|
try {
|
||||||
exec('copy')
|
return exec('copy')
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard unavailable (sandboxed iframe / denied permission); UI still
|
return false
|
||||||
// flips to the ok label so the gesture is acknowledged.
|
} finally {
|
||||||
|
el.remove()
|
||||||
}
|
}
|
||||||
el.remove()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
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 <pre>; trimmed is the
|
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
|
||||||
typed fallback if the DOM shape ever diverges. */
|
typed fallback if the DOM shape ever diverges. */
|
||||||
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
|
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
|
||||||
void writeClipboard(text)
|
void writeClipboard(text).then((ok) => {
|
||||||
setCopied(true)
|
if (!ok) return
|
||||||
window.setTimeout(() => setCopied(false), 1000)
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1000)
|
||||||
|
})
|
||||||
}, [copied, trimmed])
|
}, [copied, trimmed])
|
||||||
|
|
||||||
const body = html === undefined
|
const body = html === undefined
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// alongside the rest of the markdown family.
|
// alongside the rest of the markdown family.
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 { CodeBlock } from '../src/markdown/CodeBlock.tsx'
|
||||||
import { highlightToHtml } from '../src/markdown/highlight.ts'
|
import { highlightToHtml } from '../src/markdown/highlight.ts'
|
||||||
|
|
||||||
@@ -65,6 +65,10 @@ describe('CodeBlock', () => {
|
|||||||
expect(screen.getByText('ts')).toBeTruthy()
|
expect(screen.getByText('ts')).toBeTruthy()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||||
expect(writeText).toHaveBeenCalledWith('const a = 1')
|
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()
|
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||||
// While the ok label is showing, further clicks are no-ops.
|
// While the ok label is showing, further clicks are no-ops.
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||||
@@ -73,7 +77,22 @@ describe('CodeBlock', () => {
|
|||||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
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(<CodeBlock code="plain body" />)
|
||||||
|
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', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -86,9 +105,10 @@ describe('CodeBlock', () => {
|
|||||||
render(<CodeBlock code="plain body" />)
|
render(<CodeBlock code="plain body" />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||||
expect(exec).toHaveBeenCalledWith('copy')
|
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', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -99,22 +119,20 @@ describe('CodeBlock', () => {
|
|||||||
throw new Error('denied')
|
throw new Error('denied')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
render(<CodeBlock code="plain body" />)
|
const denied = render(<CodeBlock code="plain body" />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
fireEvent.click(denied.getByRole('button', { name: '复制' }))
|
||||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
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', {
|
Object.defineProperty(document, 'execCommand', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: undefined,
|
value: undefined,
|
||||||
})
|
})
|
||||||
render(<CodeBlock code="plain body" />)
|
const absent = render(<CodeBlock code="plain body" />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
fireEvent.click(absent.getByRole('button', { name: '复制' }))
|
||||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
await Promise.resolve()
|
||||||
|
expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||||
|
expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user