diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx index 329186a..42d3d4d 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -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): Promise { + await act(async () => root.render( {}} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />)) + } + + /** 点一下,等剪贴板答复、界面换成复制的结果 */ + async function click(button: HTMLButtonElement): Promise { + await act(async () => { + button.click() + await new Promise(resolve => setTimeout(resolve, 0)) + }) + } + + const copyButtons = (): HTMLButtonElement[] => [...container.querySelectorAll('.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('.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 条**路径') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 68e98a9..e9b306c 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -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 = { + 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 ( + + ) +} + /** - * 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 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 {entry.profile && {` · ${entry.profile}`}} - @@ -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
{ask.question} + {!unfinished(ask) && ask.text !== '' && }
{ask.text !== '' && ( diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index c240161..6560ec4 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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; diff --git a/vscode/webview-ui/src/components/LogModal.tsx b/vscode/webview-ui/src/components/LogModal.tsx index 68c3bc6..fe074ad 100644 --- a/vscode/webview-ui/src/components/LogModal.tsx +++ b/vscode/webview-ui/src/components/LogModal.tsx @@ -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(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 { - 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 (
- {copied && ( - 已复制 + {copyState !== 'idle' && ( + {copyState === 'copied' ? '已复制' : '复制失败'} )}