From 5e8e7b65ee9a5666544da5f1115b127954ef48d9 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 2 Oct 2026 01:59:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=97=B6=E5=9C=A8=E5=8D=A1=E7=89=87=E4=B8=8B?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9E=8B=E8=BF=87=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/editor/messages.ts | 8 +- vscode/src/codeReader/editor/previewEditor.ts | 137 ++++++++++++------ vscode/webview-ui/src/codeReader/App.tsx | 24 +-- .../webview-ui/src/codeReader/AskThread.tsx | 33 ++++- .../src/codeReader/BlockTools.test.tsx | 5 +- .../webview-ui/src/codeReader/BlockTools.tsx | 8 +- .../src/codeReader/BlockTree.test.tsx | 3 +- .../webview-ui/src/codeReader/BlockTree.tsx | 19 ++- .../src/codeReader/RewriteThread.test.tsx | 41 ++++++ .../webview-ui/src/codeReader/codeReader.css | 9 +- .../src/codeReader/rewriteRun.test.ts | 38 +++++ .../webview-ui/src/codeReader/rewriteRun.ts | 58 ++++++++ vscode/webview-ui/src/codeReader/types.ts | 8 +- vscode/webview-ui/src/codeReader/useQa.ts | 2 +- .../webview-ui/src/codeReader/useRewrite.ts | 59 ++++++++ 15 files changed, 369 insertions(+), 83 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/RewriteThread.test.tsx create mode 100644 vscode/webview-ui/src/codeReader/rewriteRun.test.ts create mode 100644 vscode/webview-ui/src/codeReader/rewriteRun.ts create mode 100644 vscode/webview-ui/src/codeReader/useRewrite.ts diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 56968d8..ba7e2c3 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -89,6 +89,8 @@ export interface QaMessage { * askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 + * rewriteProgress:anchor 这段注释正在按规范重写。question 是发给模型的要求,text 是到现在为止的全文,activity 是正在读什么 + * rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发, @@ -106,7 +108,8 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } - | { type: 'commentRewritten', anchor: string } + | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } + | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } | { type: 'diff', diff: DocumentDiff | null } /** @@ -120,6 +123,8 @@ export type ExtensionToWebview * setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览 * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile; * images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存 + * rewriteComment:按注释规范重写 anchor 这段注释。过程用 rewriteProgress / rewriteEnd 发回页面,不写入问答 + * stopRewrite:叫停 anchor 这段正在进行的重写,不写文件 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 @@ -132,6 +137,7 @@ export type WebviewToExtension | { type: 'setOnlyChanges', on: boolean } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'rewriteComment', anchor: string } + | { type: 'stopRewrite', anchor: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } | { type: 'probeDefinition', id: number, version: number, offset: number } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index d1df1ef..24e3a0f 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -129,6 +129,12 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { } } +/** 一段注释重写的结果:`done` 已经写进文件,`failed` 没写并带给人看的原因,`stopped` 是被叫停、没写。 */ +type RewriteOutcome + = | { status: 'done' } + | { status: 'stopped' } + | { status: 'failed', error: string } + class Preview { /** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */ private ready = false @@ -144,6 +150,8 @@ class Preview { private readonly rewriting = new Set() /** 预览关掉时停掉还在跑的重写 */ private readonly rewriteAbort = new AbortController() + /** 每段正在重写的注释各自的停止开关;预览关掉时 [rewriteAbort] 会把它们都停掉 */ + private readonly rewriteStops = new Map() /** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */ private sent: SentDocument | undefined /** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */ @@ -464,6 +472,9 @@ class Preview { case 'rewriteComment': await this.rewriteComment(msg.anchor) return + case 'stopRewrite': + this.rewriteStops.get(msg.anchor)?.abort() + return case 'selectTranslator': await this.registry.selectTranslator(this.languageId, msg.id) this.owner.refresh(this.languageId) @@ -555,12 +566,19 @@ class Preview { if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted) return this.rewriting.add(anchor) + const stop = new AbortController() + const onDispose = (): void => stop.abort() + this.rewriteAbort.signal.addEventListener('abort', onDispose) + this.rewriteStops.set(anchor, stop) try { - await this.rewriteCommentNow(anchor) + const outcome = await this.rewriteCommentNow(anchor, stop.signal) + if (!this.rewriteAbort.signal.aborted) + this.post({ type: 'rewriteEnd', anchor, status: outcome.status, ...(outcome.status === 'failed' && { error: outcome.error }) }) } finally { + this.rewriteAbort.signal.removeEventListener('abort', onDispose) + this.rewriteStops.delete(anchor) this.rewriting.delete(anchor) - this.post({ type: 'commentRewritten', anchor }) } } @@ -568,18 +586,18 @@ class Preview { void window.showErrorMessage(`代码阅读:${message}`) } - private async rewriteCommentNow(anchor: string): Promise { - const found = this.sent && findPlaced(this.sent.blocks, anchor) - if (!found || found.block.kind !== 'comment') { - this.failRewrite('这块注释刚改过,找不到了。等预览刷新后再重写。') - return + private async rewriteCommentNow(anchor: string, signal: AbortSignal): Promise { + const failed = (message: string): RewriteOutcome => { + this.failRewrite(message) + return { status: 'failed', error: message } } + const found = this.sent && findPlaced(this.sent.blocks, anchor) + if (!found || found.block.kind !== 'comment') + return failed('这块注释刚改过,找不到了。等预览刷新后再重写。') const { block, siblings, index } = found const { document } = this - if (block.startLine < 0 || block.endLine < block.startLine || block.endLine >= document.lineCount) { - this.failRewrite('这块注释的位置已经变了,没有写回。') - return - } + if (block.startLine < 0 || block.endLine < block.startLine || block.endLine >= document.lineCount) + return failed('这块注释的位置已经变了,没有写回。') const raw = Array.from({ length: block.endLine - block.startLine + 1 }, (_, i) => document.lineAt(block.startLine + i).text) const snapshot = raw.join('\n') const comment = commentSource(raw) @@ -596,65 +614,92 @@ class Preview { earlierKinds: siblings.slice(0, index).map(item => item.kind), ...(following !== undefined && { following }), }) - if (!prompt) { - this.failRewrite('这段注释的定界符认不出来,没有改文件。') - return - } + if (!prompt) + return failed('这段注释的定界符认不出来,没有改文件。') const at = locate(document.uri) + let text = '' + let activity: string | undefined + const publish = (): void => { + this.post({ + type: 'rewriteProgress', + anchor, + question: prompt, + text, + ...(activity !== undefined && { activity }), + }) + } + publish() const profile = await this.owner.qa.profiles.current() - let text: string + if (signal.aborted) + return { status: 'stopped' } + let answerText: string try { const answer = await askClaude({ prompt, images: [], ...(profile && { profilePath: profile.path }), cwd: at?.folder.uri.fsPath ?? dirname(document.uri.fsPath), - signal: this.rewriteAbort.signal, - onEvent: () => {}, + signal, + onEvent: (event) => { + if (event.kind === 'text') { + text = event.text + activity = undefined + } + else if (event.kind === 'activity') { + activity = event.label + } + else { + return + } + publish() + }, }) - text = answer.text + answerText = answer.text } catch (err) { - if (!this.rewriteAbort.signal.aborted) - this.failRewrite(askFailure(err)) - return - } - if (this.rewriteAbort.signal.aborted) - return - const body = modelCommentBody(text) - if (!body) { - this.failRewrite('模型没有给出注释正文,没有改文件。') - return - } - if (block.endLine >= document.lineCount) { - this.failRewrite('这段注释在重写期间变了,没有写回。') - return + if (signal.aborted) + return { status: 'stopped' } + return failed(askFailure(err)) } + if (signal.aborted) + return { status: 'stopped' } + const body = modelCommentBody(answerText) + if (!body) + return failed('模型没有给出注释正文,没有改文件。') + if (block.endLine >= document.lineCount) + return failed('这段注释在重写期间变了,没有写回。') const current = Array.from({ length: block.endLine - block.startLine + 1 }, (_, i) => document.lineAt(block.startLine + i).text).join('\n') - if (current !== snapshot) { - this.failRewrite('这段注释在重写期间变了,没有写回。') - return - } + if (current !== snapshot) + return failed('这段注释在重写期间变了,没有写回。') const filled = refillComment(comment, body) - if (!filled) { - this.failRewrite('这段注释的定界符认不出来,没有改文件。') - return - } - await this.applyEdit(block.startLine, block.endLine, filled) + if (!filled) + return failed('这段注释的定界符认不出来,没有改文件。') + if (signal.aborted) + return { status: 'stopped' } + const wrote = await this.applyEdit(block.startLine, block.endLine, filled) + if (wrote === 'written' || wrote === 'unchanged') + return { status: 'done' } + if (wrote === 'rejected') + return { status: 'failed', error: `没能把修改写回 ${workspace.asRelativePath(document.uri)}` } + return failed('这段注释在重写期间变了,没有写回。') } - private async applyEdit(startLine: number, endLine: number, text: string): Promise { + /** 把这几行换成 [text]。`written` 写进去了,`unchanged` 跟文件里一样不用写,`missing` 行号已经不在,`rejected` 编辑器没接受。 */ + private async applyEdit(startLine: number, endLine: number, text: string): Promise<'written' | 'unchanged' | 'missing' | 'rejected'> { const { document } = this const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) if (!replacement) { logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` }) - return + return 'missing' } if (replacement.unchanged) - return + return 'unchanged' const edit = new WorkspaceEdit() edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text) - if (!await workspace.applyEdit(edit)) + if (!await workspace.applyEdit(edit)) { void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) + return 'rejected' + } + return 'written' } } diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 8a9d3a7..b8b83ea 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -19,6 +19,7 @@ import { useDefinitionLinks } from './useDefinitionLinks' import { HighlightContext, useOccurrences } from './useOccurrences' import { useQa } from './useQa' import { useReveal } from './useReveal' +import { useRewrite } from './useRewrite' import { useScrollPosition } from './useScrollPosition' import { collapsedIds } from './viewState' import { onMessage, postMessage, viewState } from './vscode' @@ -36,10 +37,9 @@ function savedBlockModes(): BlockModes { export function App(): ReactElement | null { // 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答 const qa = useQa() + /** 按注释规范重写的过程,只在这一页上 */ + const rewrites = useRewrite() const [doc, setDoc] = useState(null) - /** 正在按注释规范重写的块(记号)。点下去立刻记上,扩展端写完或失败再发 commentRewritten */ - const rewritingRef = useRef(new Set()) - const [rewriting, setRewriting] = useState>(NONE) const [view, setView] = useState(savedBlockModes) const [focusId, setFocusId] = useState(null) /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ @@ -117,10 +117,6 @@ export function App(): ReactElement | null { return case 'diff': setDiff(msg.diff) - return - case 'commentRewritten': - rewritingRef.current.delete(msg.anchor) - setRewriting(new Set(rewritingRef.current)) } }) postMessage({ type: 'ready' }) @@ -133,13 +129,6 @@ export function App(): ReactElement | null { }, [view]) const onFocused = useCallback(() => setFocusId(null), []) - const onRewriteComment = useCallback((anchor: string) => { - if (rewritingRef.current.has(anchor)) - return - rewritingRef.current.add(anchor) - setRewriting(new Set(rewritingRef.current)) - postMessage({ type: 'rewriteComment', anchor }) - }, []) const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc]) const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames]) const tree = useMemo((): BlockTreeState | null => doc && { @@ -161,13 +150,14 @@ export function App(): ReactElement | null { }, onToggleFold: id => setFolded([id], !collapsed.has(id)), onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })), - rewriting, - onRewriteComment, + rewrites: rewrites.runs, + onRewriteComment: rewrites.onRewrite, + onStopRewrite: rewrites.onStop, ...(qa.state.available && { qa }), ...(changes && { diff: changes }), ...(folded && { folded }), onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])), - }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewriting, onRewriteComment, qa, changes, folded]) + }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded]) if (!doc || !tree) return null diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index ec50fe8..c300040 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -1,6 +1,7 @@ import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' import type { PendingImage } from './qaImages' +import type { RewriteRun } from './rewriteRun' import type { ProfileChoice, QaEntryView } from './types' import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' @@ -97,8 +98,9 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView } /** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */ -function CurrentItem({ ask, onOpenImage }: { ask: Ask, onOpenImage: (url: string) => void }): ReactElement { +function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url: string) => void, onStop?: () => void }): ReactElement { const running = ask.status === 'running' + const label = ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…') return (
@@ -116,7 +118,12 @@ function CurrentItem({ ask, onOpenImage }: { ask: Ask, onOpenImage: (url: string {running && (
- {ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…')} + {onStop ? {label} : label} + {onStop && ( + + )}
)} {ask.status === 'failed' && ( @@ -297,3 +304,25 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS ) } + +/** + * 注释块下面的重写过程,样子和展开的「问 AI」一样:左边竖线、问题、流出来的回答、正在读哪个文件。 + * + * 问题是发给模型的那句固定要求。没有输入框,不记历史。写进文件后由调用方拿掉;失败或停下就留在这里。 + */ +export function RewriteThread({ run, onStop }: { run: RewriteRun, onStop: () => void }): ReactElement { + const ask: Ask = { + askId: 'rewrite', + anchor: '', + question: run.question, + text: run.text, + ...(run.activity !== undefined && { activity: run.activity }), + status: run.status, + ...(run.error !== undefined && { error: run.error }), + } + return ( +
+ {}} onStop={run.status === 'running' ? onStop : undefined} /> +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx index cae8784..7397336 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx @@ -39,7 +39,7 @@ describe('块右上角的图标按钮', () => { expect(buttons[1]).toContain('data-tooltip="编辑"') }) - it('「按注释规范重写」排在「问 AI」左边;正在重写时禁用并转圈,不用原生 title', () => { + it('「按注释规范重写」排在「问 AI」左边;正在重写时禁用,不用原生 title,按钮上不转圈', () => { const html = render(false, { asking: false }, { rewriting: false }) expect(visibleText(html)).toBe('') expect(html).not.toContain(' title=') @@ -51,6 +51,7 @@ describe('块右上角的图标按钮', () => { expect(buttons[2]).toContain('data-tooltip="编辑"') const busy = render(false, { asking: false }, { rewriting: true }) expect(busy).toContain('disabled=""') - expect(busy).toContain('cr-qa-spin') + expect(busy).toContain('aria-busy="true"') + expect(busy).not.toContain('cr-qa-spin') }) }) diff --git a/vscode/webview-ui/src/codeReader/BlockTools.tsx b/vscode/webview-ui/src/codeReader/BlockTools.tsx index 8150226..3e6e82e 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.tsx @@ -1,9 +1,9 @@ import type { ReactElement } from 'react' -import { Eye, LoaderCircle, Pencil, RefreshCw, Sparkles } from 'lucide-react' +import { Eye, MessageSquareText, Pencil, Sparkles } from 'lucide-react' /** * 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(data-tooltip)和读屏用的 aria-label 里。 - * 「按注释规范重写」:只给注释块。点了用写注释的技能重写这一段并写回文件;正在重写时转圈,不能再点。 + * 「按注释规范重写」:只给注释块。点了用写注释的技能重写这一段并写回文件;正在重写时不能再点。图标是注释。 * 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。 * 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。 */ @@ -33,9 +33,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewriti disabled={rewriting === true} onClick={onRewrite} > - {rewriting - ? - : } + )} {onToggleAsk && ( diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 2ad4e54..5b33586 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -67,8 +67,9 @@ function render(folds: Record): string { onToggleEdit: () => {}, onToggleFold: () => {}, onSetFolds: () => {}, - rewriting: new Set(), + rewrites: new Map(), onRewriteComment: () => {}, + onStopRewrite: () => {}, onOpenRun: () => {}, } return renderToStaticMarkup() diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index b5064c9..617e35c 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -3,10 +3,11 @@ import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView' import type { GridSlots } from './PropertyGrid' import type { QaState } from './qa' import type { PendingImage } from './qaImages' +import type { RewriteRun } from './rewriteRun' import type { BlockMarks, BlockView } from './types' import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react' import { Fragment, useState } from 'react' -import { AskThread } from './AskThread' +import { AskThread, RewriteThread } from './AskThread' import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' import { DiffText } from './DiffText' @@ -37,9 +38,11 @@ export interface BlockTreeState { onToggleFold: (id: string) => void /** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */ onSetFolds: (folds: Record) => void - /** 正在按注释规范重写的注释块(记号) */ - rewriting: ReadonlySet + /** 按注释规范重写的过程,按块的记号。只在这一页上 */ + rewrites: ReadonlyMap onRewriteComment: (anchor: string) => void + /** 叫停这块正在进行的重写,不写文件 */ + onStopRewrite: (anchor: string) => void /** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */ qa?: QaTree /** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */ @@ -149,6 +152,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B {tree.qa && } + {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( )} @@ -180,6 +184,13 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement { ) } +function BlockRewrite({ anchor, tree }: { anchor: string, tree: BlockTreeState }): ReactElement | null { + const run = tree.rewrites.get(anchor) + if (!run) + return null + return tree.onStopRewrite(anchor)} /> +} + /** * 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文; * 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 @@ -279,7 +290,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): onToggleEdit={() => tree.onToggleEdit(block.id)} asking={tree.qa?.state.open.includes(block.anchor)} onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))} - rewriting={tree.rewriting.has(block.anchor)} + rewriting={tree.rewrites.get(block.anchor)?.status === 'running'} onRewrite={block.kind === 'comment' ? () => tree.onRewriteComment(block.anchor) : undefined} />
diff --git a/vscode/webview-ui/src/codeReader/RewriteThread.test.tsx b/vscode/webview-ui/src/codeReader/RewriteThread.test.tsx new file mode 100644 index 0000000..fe320d1 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/RewriteThread.test.tsx @@ -0,0 +1,41 @@ +import type { RewriteRun } from './rewriteRun' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { RewriteThread } from './AskThread' + +const QUESTION = '/ecc:writing-code-comments\n\n只按这个技能重写这一段注释。' + +function render(run: RewriteRun): string { + return renderToStaticMarkup( {}} />) +} + +const text = (html: string): string => html.replace(/<[^>]*>/g, '') + +describe('按注释规范重写的过程', () => { + const running: RewriteRun = { question: QUESTION, text: '新的**注释**', activity: '读取 routes.py', status: 'running' } + + it('进行中画出固定的要求、流出来的回答和正在读的文件,能停;没有输入框、没有「问过 N 次」', () => { + const html = render(running) + expect(html).toContain('aria-label="按注释规范重写"') + expect(html).toContain('class="cr-qa"') + expect(text(html)).toContain('/ecc:writing-code-comments') + expect(html).toContain('data-streaming=""') + expect(text(html)).toContain('新的注释') + expect(text(html)).toContain('读取 routes.py') + expect(html).toContain('aria-label="停止"') + expect(html).not.toContain(' { + const html = render({ ...running, text: '一半', status: 'failed', error: '模型没有给出注释正文,没有改文件。' }) + expect(html).toContain('role="alert"') + expect(text(html)).toContain('模型没有给出注释正文,没有改文件。') + expect(text(html)).toContain('/ecc:writing-code-comments') + expect(html).not.toContain('问过') + expect(html).not.toContain(' * > .cr-guide, -.cr-block:hover + .cr-qa + .cr-level > * > .cr-guide { +.cr-block:hover + .cr-qa + .cr-level > * > .cr-guide, +.cr-block:hover + .cr-qa + .cr-qa + .cr-level > * > .cr-guide { background: var(--kind); } @@ -953,10 +954,12 @@ body { .cr-change-bar[data-change="del"] { background: var(--cr-diff-del); } /* 卡片包了一层色条以后不再和子层相邻:悬停卡片时子层竖线换色改按外层认;问答区往右缩到和卡片对齐 */ .cr-change-row:has(> div > .cr-block:hover) + .cr-level > * > .cr-guide, -.cr-change-row:has(> div > .cr-block:hover) + .cr-qa + .cr-level > * > .cr-guide { +.cr-change-row:has(> div > .cr-block:hover) + .cr-qa + .cr-level > * > .cr-guide, +.cr-change-row:has(> div > .cr-block:hover) + .cr-qa + .cr-qa + .cr-level > * > .cr-guide { background: var(--kind); } -.cr-change-row + .cr-qa { +.cr-change-row + .cr-qa, +.cr-change-row + .cr-qa + .cr-qa { margin-left: 8px; } diff --git a/vscode/webview-ui/src/codeReader/rewriteRun.test.ts b/vscode/webview-ui/src/codeReader/rewriteRun.test.ts new file mode 100644 index 0000000..1b55834 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/rewriteRun.test.ts @@ -0,0 +1,38 @@ +import type { RewriteRuns } from './rewriteRun' +import { describe, expect, it } from 'vitest' +import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun' + +const ANCHOR = 'c1' +const QUESTION = '/ecc:writing-code-comments\n\n只按这个技能重写这一段注释。' + +describe('重写过程只留在页面上', () => { + it('进行中换上全文和正在读的文件;新的一段正文来了就不再标着上一个文件', () => { + const started = startRewrite(new Map(), ANCHOR) + const reading = rewriteProgress(started, ANCHOR, QUESTION, '', '读取 routes.py') + expect(reading.get(ANCHOR)).toEqual({ question: QUESTION, text: '', status: 'running', activity: '读取 routes.py' }) + const writing = rewriteProgress(reading, ANCHOR, QUESTION, '新的注释', undefined) + expect(writing.get(ANCHOR)).toEqual({ question: QUESTION, text: '新的注释', status: 'running' }) + }) + + it('写进文件就拿掉;没写成把原因留在这块上;叫停也留着,不再进行', () => { + const running = rewriteProgress(startRewrite(new Map(), ANCHOR), ANCHOR, QUESTION, '一半', '读取 a.py') + expect(rewriteEnd(running, ANCHOR, 'done').has(ANCHOR)).toBe(false) + const failed = rewriteEnd(running, ANCHOR, 'failed', '模型没有给出注释正文,没有改文件。') + expect(failed.get(ANCHOR)).toMatchObject({ question: QUESTION, text: '一半', status: 'failed', error: '模型没有给出注释正文,没有改文件。' }) + expect(rewriteEnd(running, ANCHOR, 'stopped').get(ANCHOR)?.status).toBe('stopped') + }) + + it('已经结束的,迟到的字不再把过程画出来;正在进行时再点一次也不重开', () => { + const running = startRewrite(new Map(), ANCHOR) + const done = rewriteEnd(running, ANCHOR, 'done') + expect(rewriteProgress(done, ANCHOR, QUESTION, '迟到了').has(ANCHOR)).toBe(false) + expect(startRewrite(running, ANCHOR)).toBe(running) + const failed = rewriteEnd(running, ANCHOR, 'failed', '没写成') + expect(startRewrite(failed, ANCHOR).get(ANCHOR)?.status).toBe('running') + }) + + it('这一页上没有的结束消息不动原来的', () => { + const runs: RewriteRuns = new Map() + expect(rewriteEnd(runs, ANCHOR, 'failed', '没有')).toBe(runs) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/rewriteRun.ts b/vscode/webview-ui/src/codeReader/rewriteRun.ts new file mode 100644 index 0000000..07daff6 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/rewriteRun.ts @@ -0,0 +1,58 @@ +/** 一段注释正在按规范重写、或没写成时留在页面上的过程。只在这一页上,刷新或关掉预览就没了,不写入问答。 */ +export interface RewriteRun { + /** 发给模型的要求,以 `/ecc:writing-code-comments` 开头;还没从扩展端来时是空的 */ + question: string + /** 回答到现在为止的全文(Markdown) */ + text: string + /** 正在做的事,例如「读取 routes.py」 */ + activity?: string + status: 'running' | 'failed' | 'stopped' + /** [status] 是 `failed` 时给人看的原因 */ + error?: string +} + +/** 各段注释的重写过程,按块的记号 */ +export type RewriteRuns = ReadonlyMap + +/** 点了重写:这块标成正在进行。已经在进行就不动。 */ +export function startRewrite(runs: RewriteRuns, anchor: string): RewriteRuns { + if (runs.get(anchor)?.status === 'running') + return runs + const next = new Map(runs) + next.set(anchor, { question: '', text: '', status: 'running' }) + return next +} + +/** + * 模型又吐出一段,或换了正在读的文件。 + * + * 这块不在进行中(已经结束,或这一页上没有)就丢掉,避免写完之后迟到的字再把过程画出来。 + */ +export function rewriteProgress(runs: RewriteRuns, anchor: string, question: string, text: string, activity?: string): RewriteRuns { + if (runs.get(anchor)?.status !== 'running') + return runs + const next = new Map(runs) + next.set(anchor, { question, text, status: 'running', ...(activity !== undefined && { activity }) }) + return next +} + +/** 结束:正文写进文件了就拿掉这段过程;没写成或被叫停就留着,失败的原因写在上面。 */ +export function rewriteEnd(runs: RewriteRuns, anchor: string, status: 'done' | 'failed' | 'stopped', error?: string): RewriteRuns { + const current = runs.get(anchor) + if (current === undefined) + return runs + if (status === 'done') { + const next = new Map(runs) + next.delete(anchor) + return next + } + const next = new Map(runs) + next.set(anchor, { + question: current.question, + text: current.text, + status, + ...(current.activity !== undefined && { activity: current.activity }), + ...(error !== undefined && { error }), + }) + return next +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index f60ea7f..effd2be 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -390,6 +390,8 @@ export interface DocumentDiff { * askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了,见 AnswerEnd + * rewriteProgress:anchor 这段注释正在按规范重写。question 是发给模型的要求,text 是到现在为止的全文,activity 是正在读什么 + * rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了 @@ -406,7 +408,8 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | AnswerEnd - | { type: 'commentRewritten', anchor: string } + | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } + | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } | { type: 'diff', diff: DocumentDiff | null } /** @@ -420,6 +423,8 @@ export type ExtensionToWebview * setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示 * ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile; * images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存 + * rewriteComment:按注释规范重写 anchor 这段注释。过程用 rewriteProgress / rewriteEnd 回来,不写入问答 + * stopRewrite:叫停 anchor 这段正在进行的重写,不写文件 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示 @@ -431,6 +436,7 @@ export type WebviewToExtension | { type: 'setOnlyChanges', on: boolean } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'rewriteComment', anchor: string } + | { type: 'stopRewrite', anchor: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } | { type: 'probeDefinition', id: number, version: number, offset: number } diff --git a/vscode/webview-ui/src/codeReader/useQa.ts b/vscode/webview-ui/src/codeReader/useQa.ts index da5f38d..4488c28 100644 --- a/vscode/webview-ui/src/codeReader/useQa.ts +++ b/vscode/webview-ui/src/codeReader/useQa.ts @@ -6,7 +6,7 @@ import { toAskImage } from './qaImages' import { onMessage, postMessage } from './vscode' /** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */ -const ANSWER_FRAME_MS = 80 +export const ANSWER_FRAME_MS = 80 /** 问 AI:收扩展端的问答、正在答的进展,给块树用的状态和动作(问、停、删、展开 / 收起)。 */ export function useQa(): QaTree { diff --git a/vscode/webview-ui/src/codeReader/useRewrite.ts b/vscode/webview-ui/src/codeReader/useRewrite.ts new file mode 100644 index 0000000..f72848b --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useRewrite.ts @@ -0,0 +1,59 @@ +import type { RewriteRuns } from './rewriteRun' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun' +import { ANSWER_FRAME_MS } from './useQa' +import { onMessage, postMessage } from './vscode' + +/** 按注释规范重写:过程只在这一页上,按块的记号记。不写入问答,刷新或关掉预览就没了。 */ +export function useRewrite(): { runs: RewriteRuns, onRewrite: (anchor: string) => void, onStop: (anchor: string) => void } { + const [runs, setRuns] = useState(() => new Map()) + /** 动作里读眼下的过程(这块是不是已经在重写) */ + const current = useRef(runs) + current.current = runs + + useEffect(() => { + /** 攒着没画的进展:anchor → 最新的一份 */ + const pending = new Map() + let timer: ReturnType | undefined + const flush = (): void => { + timer = undefined + setRuns((prev) => { + let next = prev + for (const [anchor, progress] of pending) + next = rewriteProgress(next, anchor, progress.question, progress.text, progress.activity) + return next + }) + pending.clear() + } + const off = onMessage((msg) => { + if (msg.type === 'rewriteProgress') { + pending.set(msg.anchor, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) }) + timer ??= setTimeout(flush, ANSWER_FRAME_MS) + return + } + if (msg.type === 'rewriteEnd') { + pending.delete(msg.anchor) + if (pending.size === 0) { + clearTimeout(timer) + timer = undefined + } + setRuns(prev => rewriteEnd(prev, msg.anchor, msg.status, msg.error)) + } + }) + return () => { + clearTimeout(timer) + off() + } + }, []) + + const onRewrite = useCallback((anchor: string) => { + if (current.current.get(anchor)?.status === 'running') + return + setRuns(prev => startRewrite(prev, anchor)) + postMessage({ type: 'rewriteComment', anchor }) + }, []) + const onStop = useCallback((anchor: string) => { + postMessage({ type: 'stopRewrite', anchor }) + }, []) + return useMemo(() => ({ runs, onRewrite, onStop }), [runs, onRewrite, onStop]) +}