✨ feat(vscode): 定义卡片底部带上那块问过 AI 的问答,点问题展开回答
This commit is contained in:
1 parent
fdf7a35b18
commit
b99659ea68
10 files changed
+336
-46
No files matched your search
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user