This commit is contained in:
07akioni
2026-07-27 12:58:20 +08:00
parent 3ee2982f85
commit 55fc87a7a0
10 changed files with 99 additions and 39 deletions

View File

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

View File

@@ -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<void> {
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'

View File

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

View File

@@ -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 (
<div
className={css.root}
@@ -33,6 +44,7 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
onClick={openDetails}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />

View File

@@ -113,9 +113,11 @@ describe('tails', () => {
const errorView = render(<BashRow {...props(errorResult)} />)
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(<BashRow {...props(stoppedResult)} />)
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stoppedView.getByText('已停止')).toBeTruthy()
})
})

View File

@@ -18,16 +18,22 @@ export interface CodeBlockProps {
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) {
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<void> {
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 <pre>; 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

View File

@@ -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(<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', {
configurable: true,
value: undefined,
@@ -86,9 +105,10 @@ describe('CodeBlock', () => {
render(<CodeBlock code="plain body" />)
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(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
})
const denied = render(<CodeBlock code="plain body" />)
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(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
const absent = render(<CodeBlock code="plain body" />)
fireEvent.click(absent.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
})
})