✨ feat(vscode): 定义卡片底部带上那块问过 AI 的问答,点问题展开回答

This commit is contained in:
cruldra committed 2026-10-15 19:59:51 +11:00
1 parent fdf7a35b18
commit b99659ea68
10 files changed
+336 -46

No files matched your search

+56 -6
View File
@@ -82,8 +82,11 @@ function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?:
)
}
/** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */
function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement {
/**
* 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。
* 平时 portal 到 body;`inPlace` 时就画在原处(定义卡片浮窗里:指针在大图上也算在浮窗里,浮窗不收)。
*/
function ImagePreview({ url, inPlace = false, onClose }: { url: string, inPlace?: boolean, onClose: () => void }): ReactElement {
const close = useRef<HTMLButtonElement>(null)
useEffect(() => {
const opener = document.activeElement
@@ -101,15 +104,15 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
onClose()
}
}
return createPortal(
const lightbox = (
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
<img src={url} alt="" />
<button ref={close} type="button" className="cr-qa-lightbox-close" data-tooltip="关闭(Esc)" aria-label="关闭大图">
<X size={16} strokeWidth={1.8} aria-hidden />
</button>
</div>,
document.body,
</div>
)
return inPlace ? lightbox : createPortal(lightbox, document.body)
}
/** 复制按钮在每种复制结果下的图标和名字。 */
@@ -130,6 +133,11 @@ function CopyAnswerButton({ answer }: { answer: string }): ReactElement {
)
}
/** 说过的一次展开后的整段回答:按 Markdown 排,在历史里是灰字。 */
function HistoryAnswer({ answer }: { answer: string }): ReactElement {
return <MarkdownPreview html={renderMarkdown(answer, 'answer')} className="cr-qa-answer" />
}
/**
* 说过的一次,灰字:说的话、时间、复制回答、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
* 重写记录没写进文件时,最下面写着原因
@@ -158,7 +166,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
</div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded
? <MarkdownPreview html={renderMarkdown(entry.answer, 'answer')} className="cr-qa-answer" />
? <HistoryAnswer answer={entry.answer} />
: (
<button type="button" className="cr-qa-preview" onClick={toggle}>
{answerPreview(entry.answer)}
@@ -174,6 +182,27 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
)
}
/** 只读列表里问过的一次,灰字:只有问题一行,点了在下面展开带的图片和整段回答(同 [HistoryItem] 展开后的),再点收起。 */
function AskedItem({ entry, onOpenImage }: { entry: QaEntryView, onOpenImage: (url: string) => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
return (
<div className="cr-qa-entry" data-history="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开回答'} onClick={() => setExpanded(value => !value)}>
{entry.question}
</button>
</div>
{expanded && (
<>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
<HistoryAnswer answer={entry.answer} />
</>
)}
</div>
)
}
/** 当前这一轮说的一次,照常颜色:说的话(答完了右边能复制回答),回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement {
const running = ask.status === 'running'
@@ -425,3 +454,24 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
</section>
)
}
/**
* 只读的问答列表(定义卡片底部那一节):一行「问过 AI(N)」,下面按 `entries` 的先后只列问题,点一条展开 / 收起它的回答;不能删、不能接着问。
* 看大图画在列表自己里面(见 [ImagePreview] 的 `inPlace`),没问过时什么都不画。
*/
export function AskedList({ entries }: { entries: QaEntryView[] }): ReactElement | null {
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
if (entries.length === 0)
return null
return (
<section className="cr-qa" aria-label="问过 AI">
<div className="cr-qa-title">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{`问过 AI(${entries.length})`}
</div>
{entries.map(entry => <AskedItem key={entry.id} entry={entry} onOpenImage={setPreview} />)}
{preview !== undefined && <ImagePreview url={preview} inPlace onClose={() => setPreview(undefined)} />}
</section>
)
}
@@ -1,10 +1,11 @@
// @vitest-environment happy-dom
import type { BlockView, DefinitionPeek } from './types'
import type { BlockView, DefinitionPeek, QaEntryView } from './types'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { inPeek } from './definitionPeek'
import { DefinitionPeekPopup, PeekTree } from './DefinitionPeekPopup'
import { commentLinkOf } from './repoLink'
import { NO_SPAN } from './testBlocks'
@@ -69,6 +70,82 @@ describe('定义卡片浮窗里的卡片', () => {
})
})
describe('定义卡片底部的「问过 AI」', () => {
const QA: QaEntryView[] = [
{ id: 'e1', question: 'spawn 返回的 JoinHandle 不 await 会怎样?', answer: '任务照样跑,只是**拿不到结果**。', createdAt: '2026-10-01T08:00:00.000Z' },
{ id: 'e2', question: '这张图里的报错是什么意思?', answer: '`Send` 约束没满足。', createdAt: '2026-10-02T08:00:00.000Z', images: ['https://webview/q2-1.png'] },
]
/** 弹出 peek 的浮窗,交给 check 看、点;看完收掉 */
async function popup(peek: DefinitionPeek, check: () => Promise<void>): Promise<void> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
const anchor = document.createElement('span')
document.body.append(anchor)
const root = createRoot(document.createElement('div'))
try {
await act(async () => root.render(<DefinitionPeekPopup shown={{ anchor, peek, jump: { type: 'openRepoLink', symbol: NAME } }} onJump={() => {}} />))
await check()
}
finally {
await act(async () => root.unmount())
anchor.remove()
vi.unstubAllGlobals()
}
}
const section = (): HTMLElement | null => document.querySelector<HTMLElement>('.cr-peek section[aria-label="问过 AI"]')
const questions = (): HTMLButtonElement[] => [...section()!.querySelectorAll<HTMLButtonElement>('.cr-qa-question')]
const click = (element: HTMLElement): Promise<void> => act(async () => element.click())
it('卡片下面一节「问过 AI(N)」:按块下面的先后只列问题,回答收着;只读,没有输入框、删除', async () => {
await popup({ ...PEEK, qa: QA }, async () => {
expect(section()!.textContent).toMatch(/^问过 AI(2)/)
expect(questions().map(button => button.textContent)).toEqual([QA[0].question, QA[1].question])
expect(questions().every(button => button.getAttribute('aria-expanded') === 'false')).toBe(true)
expect(section()!.querySelector('.cr-qa-answer')).toBeNull()
expect(section()!.querySelector('img')).toBeNull()
expect(section()!.querySelector('textarea')).toBeNull()
expect(section()!.querySelector('[aria-label="已理解,删除"]')).toBeNull()
// 卡片在上、问答在下
expect(document.querySelector('.cr-peek-body')!.lastElementChild).toBe(section())
})
})
it('点一条展开它的回答(同对话页:按 Markdown 排、带的图片),再点收起', async () => {
await popup({ ...PEEK, qa: QA }, async () => {
await click(questions()[1])
expect(questions()[1].getAttribute('aria-expanded')).toBe('true')
expect(section()!.querySelector('.cr-qa-answer')!.innerHTML).toBe('<p><code>Send</code> 约束没满足。</p>\n')
expect(section()!.querySelector<HTMLImageElement>('.cr-qa-thumb img')!.getAttribute('src')).toBe('https://webview/q2-1.png')
await click(questions()[0])
expect(section()!.querySelectorAll('.cr-qa-answer')).toHaveLength(2)
expect(section()!.querySelector('.cr-qa-answer')!.innerHTML).toContain('<strong>拿不到结果</strong>')
await click(questions()[1])
expect(questions()[1].getAttribute('aria-expanded')).toBe('false')
expect(section()!.querySelectorAll('.cr-qa-answer')).toHaveLength(1)
expect(section()!.querySelector('img')).toBeNull()
})
})
it('点缩略图看大图:大图画在浮窗里,指针移上去、点它都算在浮窗里,浮窗不收', async () => {
await popup({ ...PEEK, qa: QA }, async () => {
await click(questions()[1])
await click(section()!.querySelector<HTMLButtonElement>('.cr-qa-thumb-open')!)
const lightbox = document.querySelector<HTMLElement>('.cr-qa-lightbox')!
expect(inPeek(lightbox)).toBe(true)
await click(lightbox)
expect(document.querySelector('.cr-qa-lightbox')).toBeNull()
})
})
it('那块没问过:没有这一节', async () => {
await popup(PEEK, async () => {
expect(document.querySelector('.cr-peek')).not.toBeNull()
expect(section()).toBeNull()
expect(document.querySelector('.cr-peek')!.textContent).not.toContain('问过 AI')
})
})
})
describe('定义卡片浮窗', () => {
it('浮窗根上记着卡片那份文件:卡片注释里不带路径的引用带着它和写在的那块,扩展端按那份文件找', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
@@ -4,6 +4,7 @@ import type { ShownPeek } from './definitionPeek'
import type { DefinitionPeek } from './types'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AskedList } from './AskThread'
import { BlockTree, treeState } from './BlockTree'
import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek'
import { nameMap } from './displayNames'
@@ -48,7 +49,7 @@ export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement {
}
/**
* 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,太大时在浮窗里滚。
* 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,卡片底下是那块自己问过 AI 的([AskedList],只读),太大时一起在浮窗里滚。
* 外框用 VS Code 悬停框的颜色;portal 到 body,先在左上角量出大小,再落到名字下方或上方(placePeek)。
* 根上记着卡片那份文件([PEEK_ATTRIBUTE]),卡片注释里不带路径的引用点了、标坏都按那份文件找。
*/
@@ -87,6 +88,7 @@ export function DefinitionPeekPopup({ shown, onJump }: { shown: ShownPeek<HTMLEl
<button type="button" className="cr-peek-head" data-tooltip="跳到定义" onClick={onJump}>{where}</button>
<div className="cr-peek-body">
<PeekTree key={`${where}#${peek.block.id}`} peek={peek} />
{peek.qa && <AskedList key={`${where}#${peek.block.id}`} entries={peek.qa} />}
</div>
</div>,
document.body,
@@ -373,6 +373,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
overflow: auto;
padding: 6px 8px 6px 0;
}
/* 卡片底下那块问过 AI 的:同块下面的问答区(左边一条竖线、灰字),离卡片空一点,左边不贴着浮窗边 */
.cr-peek-body > .cr-qa {
margin: 6px 0 0 8px;
}
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
.cr-code [data-link] {
@@ -1017,7 +1021,9 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.cr-qa-summary {
/* 「问过 N 次」那一行能点着收起 / 摊开;只读列表的标题「问过 AI(N)」同样子,不能点 */
.cr-qa-summary,
.cr-qa-title {
display: inline-flex;
width: fit-content;
align-items: center;
@@ -264,6 +264,8 @@ export interface DefinitionPeek {
line: number
block: BlockView
names: ShownName[]
/** 那块自己下面问过 AI 的各次,同那块下面的对话、按时间先后(子块上问的不算);没问过时没有 */
qa?: QaEntryView[]
}
/**