diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 580cd64..bf4f5d0 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -112,6 +112,8 @@ export interface DefinitionPeek { line: number block: PageBlock names: ShownName[] + /** 那块自己下面问过 AI 的各次,同那块下面的对话、按时间先后(子块上问的不算);没问过时没有 */ + qa?: QaEntryView[] } export interface DocumentMessage { diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 6349eef..2b0a14d 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -4,8 +4,8 @@ import type { ProfileChoice } from '../qa/askProfiles' import type { RewriteRequest } from '../qa/codeQa' import type { SymbolNode } from '../symbolLinks' import type { CodeColors } from '../theme/codeColors' -import type { ExtensionToWebview, WebviewToExtension } from './messages' -import { describe, expect, it, vi } from 'vitest' +import type { ExtensionToWebview, QaEntryView, QaThreadView, WebviewToExtension } from './messages' +import { afterEach, describe, expect, it, vi } from 'vitest' import { commands, Range, Uri, window, workspace } from 'vscode' import { createPythonAdapter } from '../languages/python' import { runtime, WASM_DIR } from '../testSupport' @@ -92,7 +92,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise 'file:///workspace/web/a.ts' } const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 } vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never) - const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) } + const qa = { imageRoots: () => [], onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) } const provider = new PreviewEditorProvider(...[ {}, { onDidChange: subscribe }, @@ -223,12 +223,14 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, co const subscribe = () => ({ dispose: () => {} }) const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined) const qa = { - imageRoot: () => undefined, + imageRoots: () => [Uri.file('/workspace/.spx/code-qa'), Uri.file('/srv/app/.spx/code-qa')], onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles, view: () => ({ type: 'qa' }), + /** 默认哪块都没问过 */ + blockThreads: vi.fn((_uri: Uri, _imageUrl: (file: Uri) => string): QaThreadView[] => []), } const provider = new PreviewEditorProvider(...[ {}, @@ -246,7 +248,7 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, co ] as unknown as ConstructorParameters) const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' } const document = { uri, version: 1, languageId: 'typescript', fileName: '/workspace/web/a.ts', getText: () => SOURCE.join('\n') } - return { provider, document, diffBase } + return { provider, document, diffBase, qa } } /** 一个标签页:收发消息;切走、切回来时页面留着,VS Code 只报一声可见性变了 */ @@ -390,6 +392,8 @@ describe('页面能读哪些本地目录', () => { const page = tab() await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) expect(readableRoots(page)).toContain('/workspace') + // 各工作区文件夹的问答图片目录:定义卡片会带来别的工作区文件夹里的文件的问答图片 + expect(readableRoots(page)).toEqual(expect.arrayContaining(['/workspace/.spx/code-qa', '/srv/app/.spx/code-qa'])) const options = page.panel.webview.options await page.send({ type: 'ready' }) @@ -577,17 +581,18 @@ describe('问 AI 和重写注释的 profile 下拉各管各的', () => { }) }) +const HELPER = 'function helper' + +/** 一块有名字的代码:名字在源码里的下标是 at */ +function declared(anchor: string, at: number, children: BlockView[] = []): BlockView { + return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] } +} +function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode { + return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children } +} + describe('注释里不带路径的引用按它写在哪块的注释里找', () => { const METHOD = 'class AgentService > function get_default_agent' - const HELPER = 'function helper' - - /** 一块有名字的代码:名字在源码里的下标是 at */ - function declared(anchor: string, at: number, children: BlockView[] = []): BlockView { - return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] } - } - function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode { - return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children } - } it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => { const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)] @@ -739,6 +744,111 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () = }) }) +describe('定义卡片带上那块自己问过 AI 的问答', () => { + const LOAD = 'function load' + const EVENTS = 'function useShellEvents' + const SPAWN = 'function spawn' + const OTHER = '/workspace/web/b.ts' + /** ⌘ 点进去的库源码,在工作区外 */ + const LIBRARY = '/home/me/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.40.0/src/task/spawn.rs' + /** 本文件引用 spawn 的那条导入:在源码第 7 个字符,语言扩展答它定义在库源码里 */ + const SPAWN_IMPORT = 7 + + const textDocument = (fsPath: string, languageId: string) => ({ uri: Uri.file(fsPath), version: 1, languageId, fileName: fsPath, getText: () => '', positionAt: (offset: number) => ({ line: 0, character: offset }), offsetAt: (position: { character: number }) => position.character }) + const read = (blocks: BlockView[], imports: unknown[] = []) => ({ + message: { type: 'document', version: 1, languageId: 'typescript', blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }, + displayNames: [], + symbols: [], + imports, + kinds: [], + bindings: [], + }) + /** 问过的一次,回答照问题编 */ + const asked = (question: string, images?: string[]): QaEntryView => ({ id: question, question, answer: `**答**:${question}`, createdAt: '2026-10-01T08:00:00.000Z', ...(images && { images }) }) + /** 一块有名字的代码连同它下面一个没名字的子块(记号同 BlockAnchors:外层的记号 + 种类 + 源码哈希) */ + const withBranch = (anchor: string, at: number): BlockView => declared(anchor, at, [declared(`${anchor} > if @3fa1c2d0`, at + 20)]) + + /** + * 三份文件:本文件(a.ts,有 load)、工作区里的别的文件(b.ts,有 useShellEvents)、工作区外的库源码(spawn.rs,有 spawn), + * 每份文件里那块和它的子块各有一块。`threads` 给每份文件块下面的问答(按文件路径),返回悬停引用时回的卡片。 + */ + async function peeking(threads: (fsPath: string, imageUrl: (file: Uri) => string) => QaThreadView[]) { + const other = textDocument(OTHER, 'typescript') + const library = textDocument(LIBRARY, 'rust') + vi.mocked(documentMessage).mockImplementation(async input => (input.fileName === OTHER + ? read([withBranch(EVENTS, 300)]) + : input.fileName === LIBRARY + ? read([withBranch(SPAWN, 6)]) + : read([withBranch(LOAD, 100), declared(HELPER, 950)], [{ name: 'spawn', offset: SPAWN_IMPORT, range: { startIndex: 0, endIndex: 20 } }])) as never) + vi.mocked(documentSymbolNodes).mockReset().mockImplementation(async uri => uri === other.uri.toString() + ? [symbol('useShellEvents', 300, 600)] + : [symbol('load', 100, 200), symbol('helper', 950, 990)]) + vi.mocked(languageDefinitions).mockReset().mockImplementation((async (_uri: unknown, position: { character: number }) => + position.character === SPAWN_IMPORT ? [{ uri: library.uri, range: new Range(0, 6, 0, 11) }] : []) as never) + const { provider, document, qa } = opening() + qa.blockThreads.mockImplementation((uri, imageUrl) => threads(uri.fsPath, imageUrl)) + Object.assign(document, { positionAt: (offset: number) => ({ line: 0, character: offset }), offsetAt: (position: { character: number }) => position.character }) + Object.assign(workspace, { textDocuments: [document, other, library] }) + vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { toString: () => string }) => + [other, library].find(doc => doc.uri.toString() === uri.toString()) ?? document) as never) + const page = tab() + provider.resolveCustomTextEditor(document as never, page.panel) + await page.send({ type: 'ready' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document' }))) + let id = 0 + return async (link: { path?: string, symbol: string, block?: string }) => { + id += 1 + await page.send({ type: 'peekLink', id, ...link }) + const answer = page.posted().find(message => message.type === 'definitionPeeked' && message.id === id) + return answer?.type === 'definitionPeeked' ? answer.peek : undefined + } + } + + const original = vi.mocked(documentMessage).getMockImplementation()! + afterEach(() => { + vi.mocked(documentMessage).mockImplementation(original) + vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([]) + vi.mocked(languageDefinitions).mockReset().mockResolvedValue([]) + Object.assign(workspace, { textDocuments: [] }) + vi.mocked(workspace.openTextDocument).mockReset() + }) + + it('本文件的定义:带上那块自己的问答,按块下面的先后,图片换成页面能用的地址;子块上问的不带', async () => { + const peek = await peeking((fsPath, imageUrl) => fsPath === '/workspace/web/a.ts' + ? [ + { anchor: LOAD, entries: [asked('load 读什么'), asked('为什么要缓存', [imageUrl(Uri.file('/workspace/.spx/code-qa/q2-1.png'))])] }, + { anchor: `${LOAD} > if @3fa1c2d0`, entries: [asked('这个条件什么时候成立')] }, + ] + : []) + const card = await peek({ symbol: 'load', block: HELPER }) + expect(card?.block.anchor).toBe(LOAD) + expect(card?.qa).toEqual([ + asked('load 读什么'), + asked('为什么要缓存', ['file:///workspace/.spx/code-qa/q2-1.png']), + ]) + }) + + it('别的文件、工作区外的库源码:按卡片那份文件和那块在它自己块树里的记号找,子块上问的不带', async () => { + const peek = await peeking(fsPath => [ + { anchor: EVENTS, entries: [asked(`${fsPath} 的 useShellEvents`)] }, + { anchor: SPAWN, entries: [asked(`${fsPath} 的 spawn`)] }, + { anchor: `${EVENTS} > if @3fa1c2d0`, entries: [asked('子块')] }, + { anchor: `${SPAWN} > if @3fa1c2d0`, entries: [asked('子块')] }, + ]) + const other = await peek({ path: 'web/b.ts', symbol: 'useShellEvents' }) + expect(other).toMatchObject({ uri: Uri.file(OTHER).toString(), qa: [asked(`${OTHER} 的 useShellEvents`)] }) + const library = await peek({ symbol: 'spawn', block: HELPER }) + expect(library).toMatchObject({ uri: Uri.file(LIBRARY).toString(), qa: [asked(`${LIBRARY} 的 spawn`)] }) + }) + + it('那块没问过(只有子块问过、别的块问过):卡片不带问答', async () => { + const peek = await peeking(() => [{ anchor: `${LOAD} > if @3fa1c2d0`, entries: [asked('子块')] }, { anchor: HELPER, entries: [asked('别的块')] }]) + const card = await peek({ symbol: 'load', block: HELPER }) + expect(card?.block.anchor).toBe(LOAD) + expect(card).not.toHaveProperty('qa') + }) +}) + describe('点了带几行的仓库链接', () => { /** 文件按名字认语言,第 n 行(0 起)有 n 个字,一共 20 行 */ function textDocument(fsPath: string) { diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index c8a1194..80847d0 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,11 +1,12 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' +import type { BlockView } from '../blockView' import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract' import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase' import type { OldDocument, SentDocument } from '../diff/previewDiff' import type { WorkspaceEntries } from '../entries/workspaceEntries' -import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' +import type { DefinitionStart, IndexedName, NameSources, ShownName } from '../nameResolution' import type { CodeQa, TopRequest } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' import type { LookupSources } from '../ruleLookups' @@ -196,14 +197,13 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { } /** - * 预览页面能读的本地目录:页面自己的脚本和样式;问答带的图片所在的目录(工作区文件夹的 .spx/code-qa/),历史里才画得出缩略图; - * 注释、回答里的仓库图片所在的各个根(见 [linkBaseOf]),根的真实位置和这个路径不一样时两个都放,读不到真实位置就只放这个路径。 + * 预览页面能读的本地目录:页面自己的脚本和样式;问答带的图片所在的目录(各工作区文件夹的 `.spx/code-qa/`,见 [CodeQa.imageRoots]), + * 历史和定义卡片里才画得出缩略图;注释、回答里的仓库图片所在的各个根(见 [linkBaseOf]),根的真实位置和这个路径不一样时两个都放,读不到真实位置就只放这个路径。 */ private async readableRoots(uri: Uri): Promise { - const imageRoot = this.qa.imageRoot(topSubjectOf(uri).uri) const { roots } = await linkBaseOf(uri) const repoRoots = await Promise.all(roots.map(async root => [root, await realpath(root).catch(() => root)])) - return [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : []), ...[...new Set(repoRoots.flat())].map(root => Uri.file(root))] + return [webviewUiRoot(this.extensionUri), ...this.qa.imageRoots(), ...[...new Set(repoRoots.flat())].map(root => Uri.file(root))] } /** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */ @@ -659,7 +659,12 @@ class Preview { if (!this.ready) return const subject = this.subject() - this.post(this.owner.qa.view(subject.uri, file => this.panel.webview.asWebviewUri(file).toString(), subject.kind)) + this.post(this.owner.qa.view(subject.uri, file => this.webviewUrl(file), subject.kind)) + } + + /** 本地文件 `file` 在这个预览页面里能直接用的地址(`webview.asWebviewUri`)。 */ + private webviewUrl(file: Uri): string { + return this.panel.webview.asWebviewUri(file).toString() } /** 这一页顶部问的是什么(见 topSubjectOf)。 */ @@ -926,18 +931,16 @@ class Preview { try { const target = await workspace.openTextDocument(uri) const line = target.positionAt(offset).line - const path = workspace.asRelativePath(uri) - const file = uri.toString() - if (file === this.document.uri.toString()) { + if (uri.toString() === this.document.uri.toString()) { const { sent } = this if (sent?.version !== target.version) return undefined const block = peekBlock(sent.blocks, offset, line) - return block && { path, uri: file, line, block: pagePeekBlock(block, sent.source), names: namesOnCard(block, this.names.known(target.version)) } + return block && this.peekCard(uri, line, block, sent.source, namesOnCard(block, this.names.known(target.version))) } const { message, displayNames } = await this.owner.readElsewhere(target) const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined - return block && { path, uri: file, line, block: pagePeekBlock(block, message.source), names: namesOnCard(block, definitionSiteNames(displayNames)) } + return block && this.peekCard(uri, line, block, message.source, namesOnCard(block, definitionSiteNames(displayNames))) } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` }) @@ -945,6 +948,15 @@ class Preview { } } + /** + * `uri` 第 `line` 行的定义所在那块 `block`(`source` 是那份文件的源码)发给页面的卡片,带上 `names` 和这块自己下面问过 AI 的各次。 + * 问答按那份文件和这块在那份文件块树里的记号(`block.anchor`,同那个文件的预览挂对话用的)找,子块上问的记号不同,不带。 + */ + private peekCard(uri: Uri, line: number, block: BlockView, source: string, names: ShownName[]): DefinitionPeek { + const qa = this.owner.qa.blockThreads(uri, file => this.webviewUrl(file)).find(thread => thread.anchor === block.anchor)?.entries + return { path: workspace.asRelativePath(uri), uri: uri.toString(), line, block: pagePeekBlock(block, source), names, ...(qa && { qa }) } + } + /** * 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(新开的不比,已经开着的照旧),给了 spot 就滚过去、闪这处; * 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置, @@ -1002,7 +1014,7 @@ class Preview { const entry = await resolveRepoEntry(place.root, place.rel) if (!entry || entry.isDirectory) return { path: rel } - const src = this.panel.webview.asWebviewUri(Uri.file(entry.fsPath)).toString() + const src = this.webviewUrl(Uri.file(entry.fsPath)) return { path: rel, src } })) this.post({ type: 'repoImagesChecked', id, images }) diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts index 057698a..436232c 100644 --- a/vscode/src/codeReader/qa/codeQa.test.ts +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -29,6 +29,8 @@ vi.mock('vscode', async () => { Disposable: { from: () => ({ dispose: () => {} }) }, Uri: { joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ fsPath: [base.fsPath, ...parts].join('/') }) }, window: { showErrorMessage: vi.fn() }, + /** 打开着的工作区文件夹(只有算问答图片目录时用;问答记在哪由下面 locateQa 的替身定) */ + workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }, { uri: { fsPath: '/srv/app' } }] }, } }) @@ -494,6 +496,13 @@ describe('工作区外的库文件(⌘ 点进去的标准库源码)', () => }) }) +describe('预览要能读到的问答图片目录', () => { + it('每个工作区文件夹的 .spx/code-qa/ 都放行:定义卡片会带来别的工作区文件夹里的文件的问答图片', () => { + const { qa } = service(context()) + expect(qa.imageRoots().map(root => root.fsPath)).toEqual(['/workspace/.spx/code-qa', '/srv/app/.spx/code-qa']) + }) +}) + describe('没答完的那一轮也记着会话:接着说「继续」就接着它做', () => { type RunLinesOptions = Parameters[0] /** 假的 claude 每次起来干什么:吐出这些行(给了 error 再抛它),或一直不出声、等人叫停 */ diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 0efdcb1..45dedc8 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -8,7 +8,7 @@ import type { AskEnd, RunningAsk } from './asks' import type { Answer, AskRunner, CommentAnswer, CommentAsk } from './claudeAsk' import type { ChatEntry, QaEntry, QaThread, RewriteEntry } from './qaMap' import { randomUUID } from 'node:crypto' -import { Disposable, EventEmitter, Uri, window } from 'vscode' +import { Disposable, EventEmitter, Uri, window, workspace } from 'vscode' import { getSettings, onDidChangeSettings } from '../../settings/store' import { pathQaLocation, topQaLocation } from '../editor/pathPreview' import { FolderStore, locate, locateQa } from '../folderStore' @@ -96,6 +96,11 @@ function viewEntry(root: Uri | undefined, imageUrl: (file: Uri) => string, entry } } +/** 问过的一次发给预览的样子:带着回答,图片地址同 [viewEntry]。 */ +function qaEntryView(root: Uri | undefined, imageUrl: (file: Uri) => string, entry: QaEntry): QaEntryView { + return { ...viewEntry(root, imageUrl, entry), answer: entry.answer } +} + /** 答完的一轮共有的几项:起 id、把带的图片写进 `/.spx/code-qa/`。图片写不进时抛错。 */ async function chatEntry(rootPath: string, question: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise { const id = randomUUID() @@ -174,41 +179,50 @@ export class CodeQa implements Disposable { this.rewrites = new QaAsks(rewriter, REWRITE_POLICY) } - /** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */ - imageRoot(uri: Uri): Uri | undefined { - const at = topQaLocation(uri) - return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/')) + /** + * 预览要能读到的问答图片目录:每个工作区文件夹的 `.spx/code-qa/`。问答带的图片都存在某个工作区文件夹里, + * 一页上除了这份文件的,还画得出定义卡片带来的别的文件(可能在别的工作区文件夹)的问答图片。 + */ + imageRoots(): Uri[] { + return (workspace.workspaceFolders ?? []).map(folder => Uri.joinPath(folder.uri, ...QA_IMAGE_DIR.split('/'))) } /** - * 发给预览的问答和重写记录。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。 + * 发给预览的问答和重写记录。块下面的按块的记号(见 [blockThreads]);顶部的按这条路径,subject 说是文件还是文件夹。 * 问答记在哪见 [locateQa]:工作区外的库文件也能问,没打开工作区文件夹时问 AI 不可用([QaMessage.available])。 * 重写注释会改文件本身,只给工作区里的文件([QaMessage.rewritable])。 * 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。 */ view(uri: Uri, imageUrl: (file: Uri) => string, subject: TopSubject): QaMessage { - const at = locateQa(uri) const rewriteAt = locate(uri) const top = topQaLocation(uri) - const threads = at ? this.store.get(at.folder, at.key) ?? [] : [] const rewrites = rewriteAt ? this.rewriteStore.get(rewriteAt.folder, rewriteAt.key) ?? [] : [] const pathEntries = top ? pathQaEntries(this.pathMap(), top.key) : [] const root = this.imageRoot(uri) - const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer }) const showRewrite = (entry: RewriteEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.body ?? '', ...(entry.error && { error: entry.error }) }) return { type: 'qa', available: top !== undefined, rewritable: rewriteAt !== undefined, subject, - threads: threadViews(threads, show), - path: pathEntries.map(show), + threads: this.blockThreads(uri, imageUrl), + path: pathEntries.map(entry => qaEntryView(root, imageUrl, entry)), current: this.asks.currentFor(uri.toString()), rewrites: threadViews(rewrites, showRewrite), rewriteCurrent: this.rewrites.currentFor(uri.toString()), } } + /** + * `uri` 这份文件块下面问过 AI 的,按块的记号,块下面的按时间先后:一页上画在各块下面,定义卡片里带上卡片那块的。 + * 记在哪见 [locateQa](工作区外的库文件记在第一个工作区文件夹),没地方存时是空的;带的图片换成预览里能用的地址(`imageUrl`)。 + */ + blockThreads(uri: Uri, imageUrl: (file: Uri) => string): QaThreadView[] { + const at = locateQa(uri) + const root = this.imageRoot(uri) + return threadViews(at ? this.store.get(at.folder, at.key) ?? [] : [], entry => qaEntryView(root, imageUrl, entry)) + } + /** * 开始问;问不了时返回原因(给人看的话)。在问答记着的工作区文件夹里跑 claude(见 [locateQa]),它读得到整个项目; * 问的是工作区外的库文件时另外放行读库文件所在的目录。 @@ -472,6 +486,12 @@ export class CodeQa implements Disposable { this.rewriteStore.dispose() } + /** `uri` 这份文件或文件夹的问答带的图片放在哪个目录;不在工作区文件夹里时没有。 */ + private imageRoot(uri: Uri): Uri | undefined { + const at = topQaLocation(uri) + return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/')) + } + private pathMap() { return parsePathQa(this.paths.get(PATH_QA_STATE_KEY)) } diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 40cb385..79ba681 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -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(null) useEffect(() => { const opener = document.activeElement @@ -101,15 +104,15 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R onClose() } } - return createPortal( + const lightbox = (
-
, - document.body, + ) + 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 +} + /** * 说过的一次,灰字:说的话、时间、复制回答、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。 * 重写记录没写进文件时,最下面写着原因 @@ -158,7 +166,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa {expanded - ? + ? : ( + + {expanded && ( + <> + + + + )} + + ) +} + /** 当前这一轮说的一次,照常颜色:说的话(答完了右边能复制回答),回答逐字长出来(按 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 = '这块代码' ) } + +/** + * 只读的问答列表(定义卡片底部那一节):一行「问过 AI(N)」,下面按 `entries` 的先后只列问题,点一条展开 / 收起它的回答;不能删、不能接着问。 + * 看大图画在列表自己里面(见 [ImagePreview] 的 `inPlace`),没问过时什么都不画。 + */ +export function AskedList({ entries }: { entries: QaEntryView[] }): ReactElement | null { + /** 正在看大图的那张 */ + const [preview, setPreview] = useState() + if (entries.length === 0) + return null + return ( +
+
+ + {`问过 AI(${entries.length})`} +
+ {entries.map(entry => )} + {preview !== undefined && setPreview(undefined)} />} +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx index d3031f3..ffd7bb5 100644 --- a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx @@ -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): Promise { + 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( {}} />)) + await check() + } + finally { + await act(async () => root.unmount()) + anchor.remove() + vi.unstubAllGlobals() + } + } + const section = (): HTMLElement | null => document.querySelector('.cr-peek section[aria-label="问过 AI"]') + const questions = (): HTMLButtonElement[] => [...section()!.querySelectorAll('.cr-qa-question')] + const click = (element: HTMLElement): Promise => 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('

Send 约束没满足。

\n') + expect(section()!.querySelector('.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('拿不到结果') + 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('.cr-qa-thumb-open')!) + const lightbox = document.querySelector('.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) diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx index 481dedb..9b2959e 100644 --- a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx @@ -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{where}
+ {peek.qa && }
, document.body, diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 6560ec4..3327eef 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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; diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 84abc50..1757bb5 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -264,6 +264,8 @@ export interface DefinitionPeek { line: number block: BlockView names: ShownName[] + /** 那块自己下面问过 AI 的各次,同那块下面的对话、按时间先后(子块上问的不算);没问过时没有 */ + qa?: QaEntryView[] } /**