✨ feat(vscode): 代码阅读对话记录加「复制回答」按钮
历史每条在删除左边、答完的当前这一轮在头部右边,点了把回答原样的 Markdown(带仓库链接、不带问题)放进剪贴板,成功图标换成对勾、提示「已复制」,失败提示「复制失败」,两秒后复原;复制文字并给反馈抽成 useCopyText 一份,日志弹窗也改用它,复制失败不再静默吞掉;两个头部按钮共用 cr-qa-action 样式
This commit is contained in:
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 条**路径')
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user