✨ feat(vscode): 代码阅读对话记录加「复制回答」按钮

历史每条在删除左边、答完的当前这一轮在头部右边,点了把回答原样的 Markdown(带仓库链接、不带问题)放进剪贴板,成功图标换成对勾、提示「已复制」,失败提示「复制失败」,两秒后复原;复制文字并给反馈抽成 useCopyText 一份,日志弹窗也改用它,复制失败不再静默吞掉;两个头部按钮共用 cr-qa-action 样式
This commit is contained in:
cruldra committed 2026-10-14 22:32:50 +11:00
1 parent e81c810ca8
commit 76707adb13
5 files changed
+166 -27

No files matched your search

@@ -1,8 +1,13 @@
// @vitest-environment happy-dom
import type { Root } from 'react-dom/client'
import type { AskThreadProps } from './AskThread'
import type { Ask } from './qa'
import type { ProfileChoice, QaEntryView } from './types'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AskThread, REWRITE_THREAD } from './AskThread'
const NOW = new Date(2026, 8, 26, 15, 0).getTime()
@@ -159,3 +164,76 @@ describe('块下面的重写记录(样子和问答一样)', () => {
expect(html).not.toContain('aria-label="发送"')
})
})
describe('复制回答', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
async function mount(props: Partial<AskThreadProps>): Promise<void> {
await act(async () => root.render(<AskThread history={[]} asks={[]} open={false} profiles={PROFILES} now={NOW} onSelectProfile={() => {}} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />))
}
/** 点一下,等剪贴板答复、界面换成复制的结果 */
async function click(button: HTMLButtonElement): Promise<void> {
await act(async () => {
button.click()
await new Promise(resolve => setTimeout(resolve, 0))
})
}
const copyButtons = (): HTMLButtonElement[] => [...container.querySelectorAll<HTMLButtonElement>('.cr-qa-head button[aria-label="复制回答"]')]
it('历史每条在删除左边有「复制回答」,点了把原样的 Markdown 回答(不带问题)放进剪贴板,按钮变成「已复制」', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
const answer = '入口在 [CallTreeDialog](vscode/webview-ui/src/codeReader/CallTreeDialog.tsx#CallTreeDialog),**再往下**调后端。'
await mount({ history: [{ ...HISTORY[0]!, answer }, HISTORY[1]!] })
const [first] = container.querySelectorAll('[data-history]')
const buttons = [...first!.querySelectorAll<HTMLButtonElement>('.cr-qa-head button')]
expect(buttons.map(button => button.getAttribute('aria-label')).slice(-2)).toEqual(['复制回答', '已理解,删除'])
expect(copyButtons()).toHaveLength(2)
await click(buttons.at(-2)!)
expect(writeText).toHaveBeenCalledExactlyOnceWith(answer)
const copied = first!.querySelector('[aria-label="已复制"]')
expect(copied?.getAttribute('data-tooltip')).toBe('已复制')
expect(copied?.hasAttribute('data-failed')).toBe(false)
})
it('剪贴板写不进去:按钮变成「复制失败」,不悄悄吞掉', async () => {
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied'))
await mount({ history: HISTORY })
await click(copyButtons()[0]!)
const failed = container.querySelector('[aria-label="复制失败"]')
expect(failed?.getAttribute('data-tooltip')).toBe('复制失败')
expect(failed?.hasAttribute('data-failed')).toBe(true)
expect(container.querySelector('[aria-label="已复制"]')).toBeNull()
})
it('当前这一轮:在答、排着队、还没字时没有复制按钮;答完了有,复制的是这一轮的回答', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
await mount({ open: true, asks: [ask({ text: '答了一半' })] })
expect(copyButtons()).toHaveLength(0)
await mount({ open: true, asks: [ask({ status: 'queued' })] })
expect(copyButtons()).toHaveLength(0)
await mount({ open: true, asks: [ask({ status: 'done', text: '' })] })
expect(copyButtons()).toHaveLength(0)
await mount({ open: true, asks: [ask({ status: 'done', text: '找到了 **3 条**路径' })] })
const [button] = copyButtons()
expect(button?.closest('[data-current]')).not.toBeNull()
await click(button!)
expect(writeText).toHaveBeenCalledExactlyOnceWith('找到了 **3 条**路径')
})
})
+26 -4
View File
@@ -3,10 +3,12 @@ import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'rea
import type { Ask } from './qa'
import type { PendingImage } from './qaImages'
import type { ProfileChoice, QaEntryView } from './types'
import { ChevronDown, ChevronRight, CircleStop, Hourglass, LoaderCircle, MessageSquareText, Search, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
import type { CopyState } from '@/hooks/useCopyText'
import { Check, ChevronDown, ChevronRight, CircleStop, Copy, Hourglass, LoaderCircle, MessageSquareText, Search, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { SelectMenu } from '@/components/ui/select-menu'
import { useCopyText } from '@/hooks/useCopyText'
import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime, unfinished } from './qa'
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
@@ -109,8 +111,26 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
)
}
/** 复制按钮在每种复制结果下的图标和名字。 */
const COPY_LOOKS: Record<CopyState, { icon: LucideIcon, label: string }> = {
idle: { icon: Copy, label: '复制回答' },
copied: { icon: Check, label: '已复制' },
failed: { icon: TriangleAlert, label: '复制失败' },
}
/** 头部右边的「复制回答」:把 `answer` 原样的 Markdown 连同里面的仓库链接放进剪贴板,好拿到别的会话里接着用;点完图标和名字换成复制的结果,两秒后复原。 */
function CopyAnswerButton({ answer }: { answer: string }): ReactElement {
const { state, copy } = useCopyText()
const { icon: Icon, label } = COPY_LOOKS[state]
return (
<button type="button" className="cr-tool cr-qa-action" data-failed={state === 'failed' ? '' : undefined} data-tooltip={label} aria-label={label} onClick={() => void copy(answer)}>
<Icon size={13} strokeWidth={1.8} aria-hidden />
</button>
)
}
/**
* 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
* 说过的一次,灰字:说的话、时间、复制回答、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
* 重写记录没写进文件时,最下面写着原因
*/
function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: QaEntryView, wording: ThreadWording, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
@@ -130,7 +150,8 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
</time>
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
</span>
<button type="button" className="cr-tool cr-qa-delete" data-tooltip={wording.deleteLabel} aria-label={wording.deleteLabel} onClick={onDelete}>
<CopyAnswerButton answer={entry.answer} />
<button type="button" className="cr-tool cr-qa-action" data-tooltip={wording.deleteLabel} aria-label={wording.deleteLabel} onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
@@ -152,7 +173,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
)
}
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
/** 当前这一轮说的一次,照常颜色:说的话(答完了右边能复制回答),回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement {
const running = ask.status === 'running'
const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering)
@@ -162,6 +183,7 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW
<div className="cr-qa-head">
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
{!unfinished(ask) && ask.text !== '' && <CopyAnswerButton answer={ask.text} />}
</div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
{ask.text !== '' && (
@@ -769,7 +769,8 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
font-size: 11px;
line-height: 16px;
}
.cr-qa-delete {
/* 头部最右边的小图标按钮(复制回答、删除),平时淡色,悬停变亮;复制失败时是错误色 */
.cr-qa-action {
display: flex;
width: 22px;
height: 18px;
@@ -779,9 +780,12 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
border-radius: 4px;
color: var(--qa-dim);
}
.cr-qa-delete:hover {
.cr-qa-action:hover {
color: var(--vscode-foreground);
}
.cr-qa-action[data-failed] {
color: var(--vscode-errorForeground);
}
/* 回答的前两行,和回答正文一样往右缩到问题下面 */
.cr-qa-preview {
display: -webkit-box;
+9 -20
View File
@@ -14,7 +14,8 @@
import type { ReactElement } from 'react'
import type { LogEntry } from '../lib/messages'
import { Copy, Trash2, X } from 'lucide-react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { useCopyText } from '../hooks/useCopyText'
export interface LogModalProps {
open: boolean
@@ -52,7 +53,7 @@ function formatPlain(entries: LogEntry[]): string {
export function LogModal({ open, entries, onClose, onClear }: LogModalProps): ReactElement | null {
const scrollRef = useRef<HTMLDivElement | null>(null)
const pinnedRef = useRef(true)
const [copied, setCopied] = useState(false)
const { state: copyState, copy, reset: resetCopy } = useCopyText()
// ESC to close.
useEffect(() => {
@@ -94,27 +95,15 @@ export function LogModal({ open, entries, onClose, onClear }: LogModalProps): Re
el.scrollTop = el.scrollHeight
}, [entries.length, open])
// Reset the "copied" flag whenever the modal opens.
// 每次打开都不带上一次复制的结果
useEffect(() => {
if (open)
setCopied(false)
}, [open])
resetCopy()
}, [open, resetCopy])
if (!open)
return null
async function handleCopy(): Promise<void> {
try {
await navigator.clipboard.writeText(formatPlain(entries))
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
catch {
// Clipboard might be unavailable in some VS Code webview contexts —
// swallow silently, the user can still read logs on screen.
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div
@@ -192,14 +181,14 @@ export function LogModal({ open, entries, onClose, onClear }: LogModalProps): Re
</button>
<button
type="button"
onClick={handleCopy}
onClick={() => void copy(formatPlain(entries))}
className="inline-flex items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:border-[var(--vscode-focusBorder)]"
>
<Copy className="size-4" />
复制全部
</button>
{copied && (
<span className="text-xs opacity-70">已复制</span>
{copyState !== 'idle' && (
<span className="text-xs opacity-70" role="status">{copyState === 'copied' ? '已复制' : '复制失败'}</span>
)}
<button
type="button"
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/** 复制的结果:`idle` 还没复制或已经复原,`copied` 刚复制成功,`failed` 刚复制失败。 */
export type CopyState = 'idle' | 'copied' | 'failed'
/** 复制的结果留在界面上多久再复原。 */
const FEEDBACK_MS = 2000
export interface UseCopyTextResult {
state: CopyState
/** 把 `text` 放进剪贴板,结果写进 `state` */
copy: (text: string) => Promise<void>
/** 不等两秒,马上回到 `idle` */
reset: () => void
}
/**
* 把文字放进剪贴板,成功还是失败都在界面上留两秒,因为 webview 里剪贴板可能用不了,失败不说出来人会以为已经复制了。
*/
export function useCopyText(): UseCopyTextResult {
const [state, setState] = useState<CopyState>('idle')
const timer = useRef<number | undefined>(undefined)
const show = useCallback((next: CopyState): void => {
window.clearTimeout(timer.current)
setState(next)
if (next !== 'idle')
timer.current = window.setTimeout(() => setState('idle'), FEEDBACK_MS)
}, [])
useEffect(() => () => window.clearTimeout(timer.current), [])
const copy = useCallback(async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text)
show('copied')
}
catch {
show('failed')
}
}, [show])
const reset = useCallback(() => show('idle'), [show])
return { state, copy, reset }
}