From aecc4d1ab5ebd035ef62b1932e86c8a9d8c2cec4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 12 Oct 2026 15:11:22 +1100 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=8E=92=E6=88=90=E4=B8=80=E9=98=9F=EF=BC=8C?= =?UTF-8?q?=E6=95=B4=E4=B8=AA=E6=89=A9=E5=B1=95=E4=B8=80=E6=AC=A1=E5=8F=AA?= =?UTF-8?q?=E8=B7=91=E4=B8=80=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenPencil 同一时间只能开一个画板,两段同时画会互相掐断。所有预览、所有文件的重写(第一轮和接着说)排成一队,前一段写成、出错、被叫停后按先后自动开始下一段;问 AI 照旧最多同时 3 个。 排着的那一轮记在扩展端(rewriteCurrent 的 queued),块下面写「排队中」、能取消排队,切标签页、页面重新加载后照样显示。 --- docs/superpowers/specs/code-reader/spec.md | 7 +- vscode/src/codeReader/editor/messages.ts | 6 +- vscode/src/codeReader/editor/previewEditor.ts | 6 +- vscode/src/codeReader/qa/asks.test.ts | 102 +++++++++-- vscode/src/codeReader/qa/asks.ts | 160 ++++++++++++------ vscode/src/codeReader/qa/codeQa.test.ts | 40 ++++- vscode/src/codeReader/qa/codeQa.ts | 4 +- .../src/codeReader/AskThread.test.tsx | 9 + .../webview-ui/src/codeReader/AskThread.tsx | 30 ++-- .../src/codeReader/BlockTree.test.tsx | 14 +- .../webview-ui/src/codeReader/BlockTree.tsx | 6 +- vscode/webview-ui/src/codeReader/qa.test.ts | 19 +++ vscode/webview-ui/src/codeReader/qa.ts | 20 ++- vscode/webview-ui/src/codeReader/types.ts | 8 +- .../webview-ui/src/codeReader/useRewrite.ts | 12 +- 15 files changed, 329 insertions(+), 114 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index ba1c5a4..a322f8d 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -644,10 +644,11 @@ - 输入框里说的话:`--resume` 这块最近一次重写的会话(和问答的会话分开,各接各的),发「块现在在哪(`<路径>:<起>-<止>`,写回注释后行号会变,按记号从最新的块树里找)、空一行、用户的话」,带上贴的图。会话找不到了(过期、被清理)就开新会话,发技能命令后面空一行跟着这句话。 - 能做的事(`claudeAsk.ts` 的 `rewriteArgs`):`--restricted` 不读用户、项目、本地的设置文件(那里放行的命令进不来,比如用户设置里的 `git -C`),文件工具只认工作目录;写注释的插件单独加载(`--plugin-dir`,目录按 `claude plugin list --json` 里启用着的 ecc 找),它的目录加进工作目录(`--add-dir`),技能读得到它的参考文档。原型照技能的约定存在仓库里:`docs/prototype/<功能>/openpencil/` 下的 `build.js`、`<功能>.op`,截图在它的 `exports/` 里,注释链到截图。工具只给读、搜、Bash、写文件(`--tools Read,Grep,Glob,Bash,Edit,Write`),不用再批的只有读搜、OpenPencil 的 `op`(`Bash(/Applications/OpenPencil.app/Contents/MacOS/op *)`)、建原型的截图目录(`Bash(mkdir -p docs/prototype/*/openpencil/exports)`)、改原型目录里的文件(`Edit(/docs/prototype/*/openpencil/**)`,开头的 `/` 是 claude 的工作目录,`*` 只配一层目录;Edit 规则也管 Write)。Bash 规则里的 `*` 能跨空格,建目录时后面多跟一个路径(`mkdir -p docs/prototype/x/openpencil/exports src/y`)也对得上,所以另外拒掉 `Bash(mkdir -p docs/prototype/* *)`(`--disallowedTools`)。别的一律拒绝、不等人批(`--permission-mode dontAsk`),不加载 MCP(`--strict-mcp-config`):改不了源码、跑不了别的命令,写回归扩展。本机 claude 2.1.296 用这组参数试过:写、改 `docs/prototype/x/openpencil/build.js`,建它的 `exports/`,`op --version` / `op status` 都行;写、改源码,写 `docs/prototype` 之外和 `docs/prototype/x/` 下 `openpencil/` 之外的文件,在别处建目录(`docs/other/exports`、`src/openpencil/exports`、仓库外的绝对路径、带 `..` 的路径、合法路径后面多跟一个),`rm`、`git -C`、`node -e`、重定向写文件都被拒。 - 画图比读文件慢,超时 15 分钟(问 AI 仍是 5 分钟)。 -- 过程:说的话(第一轮是那句技能命令,开始后扩展端报上来)、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在做什么(「读取 a.py」)。块下面同问答区的样子(`AskThread` 换成重写的称呼):灰字历史、当前这一轮、输入框(提示「说说这段注释要怎么改…」,旁边 profile 下拉,能贴图),正在写时发送换成停止。同一块同时只重写一轮,全插件同时最多 3 段。 +- 过程:说的话(第一轮是那句技能命令,排上队或开始后扩展端报上来)、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在做什么(「读取 a.py」)。块下面同问答区的样子(`AskThread` 换成重写的称呼):灰字历史、当前这一轮、输入框(提示「说说这段注释要怎么改…」,旁边 profile 下拉,能贴图),正在写时发送换成停止。 +- 排队(`asks.ts` 的 `REWRITE_POLICY`):整个扩展(所有预览、所有文件,第一轮和接着说都算)同时只跑一段重写,因为 OpenPencil 同一时间只能开一个画板,两段同时画,一边 `op stop` 另一边就跟着断。后来的排在后面,块下面这一轮写「排队中,前面的写完就开始」(沙漏图标、不转圈),输入框旁边的按钮是「取消排队」,点了这一轮按被叫停结束(「已停止,这次没存。」)、不再开始;前一段结束(写成、出错、被叫停都算)后按先后自动开始下一段,开始了照常显示进展。同一块同时只有一轮(在写、排着都算),这时块右上角的按钮不能点,再从输入框说也回「这段注释已经在重写了」。问 AI 不排队,照旧最多同时 3 个。 - 写回(扩展端,`WorkspaceEdit`,能撤销):换掉时套回原来那段的定界符和缩进(Python 三引号、`#` / `//` / `///` / `//!` 行注释、`/* */` 块注释,`refillComment`;几段隔着空行的照第一段的写法);新写时用这块说明的壳(docstring、`/** */`、`///`,或行注释 `#` / `//`)一行一行写,缩进同插入的那一行,空行写成只有记号的一行(docstring 的空行仍是空行)。写之前核对这块代码的那几行和开始时一样,变了就不写(「这块代码在重写期间变了」);没交回正文、定界符认不出、编辑器没接受也不写。 - 记录:每个工作区文件夹一份 `.spx/comment-rewrites.json`,和 `code-qa.json` 同一套写法(同一份 `qaMap.ts`,改名、删文件跟着走),条目 `{ "id", "question", "body"?, "error"?, "createdAt", "sessionId", "profile"?, "images"? }`:说的话(第一轮是技能命令)、交回的正文、没写进文件的原因(写进去了没有)。模型答完就记,写回失败也记下原因,好接着让它改;Claude Code 自己报错、被叫停的不记,文件不动。带的图片和问答的放在同一个 `.spx/code-qa/` 里,删一条跟着删。没展开时块下面一行「重写过 N 次」带灰字历史(没写进文件的那条下面写着原因),能收起、能删(「删除这条记录」)。 -- 预览重新加载(标签页切走再切回来)后照样接上:进行中的、出错被叫停留着的那一轮记在扩展端(同问 AI,`QaAsks`),随 `qa` 消息的 `rewriteCurrent` 回来,展开它那块、进展接着长、停止能用;收起这块时留着的那一轮去掉。切走期间扩展端照样写完、写回、记下。 +- 预览重新加载(标签页切走再切回来)后照样接上:排着队的、进行中的、出错被叫停留着的那一轮记在扩展端(同问 AI,`QaAsks`),随 `qa` 消息的 `rewriteCurrent` 回来,展开它那块、排队中照样显示、进展接着长、停止和取消排队能用;收起这块时留着的那一轮去掉。切走期间扩展端照样写完、写回、记下。 ## 通用契约 @@ -2222,7 +2223,7 @@ vscode/src/codeReader/ qa/claudeAsk.ts 命令行参数(只读、选的 profile、要结构化结果时的 --json-schema)、问一次(问题连同图片从 stdin 发,追问找不到会话时重新问)、重写注释问一次(askCommentBody)、失败给人看的话 qa/qaImages.ts 问题带的图片:认哪些格式、存到 .spx/code-qa/ 叫什么名字、写文件(不碰 VS Code) qa/askProfiles.ts AskProfiles:问 AI 用哪个 profile,下拉的选项、记在 globalState、通知所有预览(不碰 VS Code) - qa/asks.ts QaAsks:正在答的问题,一块一个、最多 3 个、能叫停(不碰 VS Code) + qa/asks.ts QaAsks:块下面正在进行的对话,一块一个、能叫停;问 AI 最多同时 3 个,重写注释排成一队、一次一段(RunPolicy,不碰 VS Code) qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话,用选的 profile,带贴的图)、答完存(带 profile 名、图片)、删,发给预览的问答、图片目录 (cc/spawnClaude.ts 的 runClaudeLines:跑 claude、给了 input 就写进 stdin、stdout 逐行交出、超时、叫停,问 AI 用它;buildStreamJsonLine 拼 stream-json 的一条用户消息) editor/previewEditor.ts CustomTextEditorProvider diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 36ca8e1..4f6adbf 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -88,7 +88,7 @@ export interface QaThreadView { /** * 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」; - * current、rewriteCurrent 是当前这一轮:正在答的问题、正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。 + * current、rewriteCurrent 是当前这一轮:正在答的问题、排着队和正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。 */ export interface QaMessage { type: 'qa' @@ -128,7 +128,7 @@ export interface StickyScrollSettings { * askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 - * rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) + * rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) * rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error); * failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 @@ -158,7 +158,7 @@ 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: 'rewriteProgress', askId: string, question: string, text: string, activity?: string } + | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true } | { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } | { type: 'diff', diff: DocumentDiff | null } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 6216a2a..4360d5b 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -303,8 +303,8 @@ class Preview { if (event.owner !== this.subject().uri.toString() || !this.ready) return if (event.kind === 'update') { - const { askId, question, text, activity } = event.ask - this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) }) + const { askId, question, text, activity, queued } = event.ask + this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }), ...(queued && { queued }) }) return } const { end, entryId } = event @@ -1020,8 +1020,6 @@ class Preview { fail(refused) return } - // 页面点按钮时还不知道那句技能命令,先报上去 - this.post({ type: 'rewriteProgress', askId, question, text: '' }) this.postQa() } diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index 8297dba..97dcf80 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -2,7 +2,7 @@ import type { AskEnd, AskStart, RunningAsk } from './asks' import type { Answer, AskRun } from './claudeAsk' import { describe, expect, it } from 'vitest' import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude' -import { ASK_WORDING, QaAsks, REWRITE_WORDING } from './asks' +import { ASK_POLICY, QaAsks, REWRITE_POLICY } from './asks' /** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */ function fakeRunner(): { runner: (run: AskRun) => Promise, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } { @@ -38,7 +38,7 @@ function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ende } describe('正在答的问题', () => { - it('回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => { + it('开始了、回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) const events = listener() @@ -48,6 +48,7 @@ describe('正在答的问题', () => { runs[0].onEvent({ kind: 'activity', label: '读取 m.py' }) runs[0].onEvent({ kind: 'text', text: '答' }) expect(events.updates).toEqual([ + { askId: '1', anchor: '1', question: '问 1', text: '' }, { askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' }, { askId: '1', anchor: '1', question: '问 1', text: '答' }, ]) @@ -89,31 +90,33 @@ describe('正在答的问题', () => { expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。') }) - it('不同块可以同时答,最多几个;答完一个又能问', async () => { + it('不同块可以同时答,最多三个,再多不开始、说清楚;答完一个又能问', async () => { const { runner, runs } = fakeRunner() - const asks = new QaAsks(runner, ASK_WORDING, 2) + const asks = new QaAsks(runner) const first = listener() asks.start(start('1'), first) asks.start(start('2'), listener()) - expect(asks.start(start('3'), listener())).toBe('同时最多问 2 个问题,等前面的答完再问。') + asks.start(start('3'), listener()) + expect(asks.start(start('4'), listener())).toBe('同时最多问 3 个问题,等前面的答完再问。') + expect(runs).toHaveLength(3) runs[0].answer({ text: '答', sessionId: 's' }) await first.ended - expect(asks.start(start('3'), listener())).toBeUndefined() + expect(asks.start(start('4'), listener())).toBeUndefined() + expect(runs).toHaveLength(4) }) - it('重写注释用自己的说法:这段已经在重写、同时重写的太多、等太久停掉', async () => { + it('重写注释用自己的说法:这段已经在重写(排着队也算)、等太久停掉', async () => { const { runner, runs } = fakeRunner() - const rewrites = new QaAsks(runner, REWRITE_WORDING, 2) + const rewrites = new QaAsks(runner, REWRITE_POLICY) const first = listener() rewrites.start(start('1', 'function f'), first) expect(rewrites.start(start('2', 'function f'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。') rewrites.start(start('3', 'function g'), listener()) - expect(rewrites.start(start('4', 'function h'), listener())).toBe('同时最多重写 2 段注释,等前面的写完再来。') + expect(rewrites.start(start('4', 'function g'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。') runs[0].fail(new ClaudeTimeoutError('Claude 调用超时(900s)', '')) await first.ended expect(first.ends[0][1]).toEqual({ status: 'failed', error: '等了 15 分钟还没写完,已经停掉。' }) }) - it('出错、叫停的那一轮没记下,留在这里给重新加载的页面再画出来;这块再开一轮、收起这块时去掉,别的文件不算', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) @@ -137,7 +140,7 @@ describe('正在答的问题', () => { it('留着的那一轮不占名额,收起时还在答的不去掉', async () => { const { runner, runs } = fakeRunner() - const asks = new QaAsks(runner, ASK_WORDING, 1) + const asks = new QaAsks(runner, { ...ASK_POLICY, limit: 1 }) const failing = listener() asks.start(start('1', 'function f'), failing) runs[0].fail(new ClaudeError('网络断了', '')) @@ -158,3 +161,80 @@ describe('正在答的问题', () => { expect(runs.every(run => run.signal.aborted)).toBe(true) }) }) + +describe('重写注释排成一队', () => { + const after = (): Promise => new Promise(resolve => setTimeout(resolve, 0)) + + it('同时只跑一段,不管哪份文件;后面的排着、报排队中,前一段写成、出错、被叫停后按先后自动开始下一段', async () => { + const { runner, runs } = fakeRunner() + const rewrites = new QaAsks(runner, REWRITE_POLICY) + const [a, b, c, d] = [listener(), listener(), listener(), listener()] + expect(rewrites.start(start('1', 'function f'), a)).toBeUndefined() + expect(rewrites.start(start('2', 'function g', 'file:///b.py'), b)).toBeUndefined() + expect(rewrites.start(start('3', 'function h'), c)).toBeUndefined() + expect(rewrites.start(start('4', 'function k', 'file:///b.py'), d)).toBeUndefined() + expect(runs.map(run => run.prompt)).toEqual(['完整的 1']) + expect(b.updates).toEqual([{ askId: '2', anchor: 'function g', question: '问 2', text: '', queued: true }]) + expect(rewrites.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'running'], ['3', 'queued']]) + expect(rewrites.currentFor('file:///b.py')).toEqual([ + { askId: '2', anchor: 'function g', question: '问 2', text: '', status: 'queued' }, + { askId: '4', anchor: 'function k', question: '问 4', text: '', status: 'queued' }, + ]) + + runs[0].answer({ text: '写好了', sessionId: 's1' }) + await a.ended + await after() + expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2']) + expect(b.updates.at(-1)).toEqual({ askId: '2', anchor: 'function g', question: '问 2', text: '' }) + expect(rewrites.currentFor('file:///b.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'running'], ['4', 'queued']]) + + runs[1].fail(new ClaudeError('网络断了', '')) + await b.ended + await after() + expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2', '完整的 3']) + + rewrites.stop('3') + await c.ended + await after() + expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2', '完整的 3', '完整的 4']) + }) + + it('排着队的能取消:报 stopped,不再开始;排在它后面的照样轮到', async () => { + const { runner, runs } = fakeRunner() + const rewrites = new QaAsks(runner, REWRITE_POLICY) + const [a, b, c] = [listener(), listener(), listener()] + rewrites.start(start('1', 'function f'), a) + rewrites.start(start('2', 'function g'), b) + rewrites.start(start('3', 'function h'), c) + rewrites.stop('2') + await b.ended + expect(b.ends).toEqual([[{ askId: '2', anchor: 'function g', question: '问 2', text: '' }, { status: 'stopped' }]]) + expect(rewrites.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'running'], ['2', 'stopped'], ['3', 'queued']]) + runs[0].answer({ text: '写好了', sessionId: 's1' }) + await a.ended + await after() + expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 3']) + }) + + it('全部叫停时排着队的也结束,不再开始', async () => { + const { runner, runs } = fakeRunner() + const rewrites = new QaAsks(runner, REWRITE_POLICY) + const [a, b] = [listener(), listener()] + rewrites.start(start('1', 'function f'), a) + rewrites.start(start('2', 'function g'), b) + rewrites.stopAll() + await Promise.all([a.ended, b.ended]) + await after() + expect(b.ends[0][1]).toEqual({ status: 'stopped' }) + expect(runs).toHaveLength(1) + }) + + it('问 AI 不排队:同时问几个照旧都开始', () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + asks.start(start('1'), listener()) + asks.start(start('2'), listener()) + expect(runs).toHaveLength(2) + expect(asks.currentFor('file:///a.py').map(ask => ask.status)).toEqual(['running', 'running']) + }) +}) diff --git a/vscode/src/codeReader/qa/asks.ts b/vscode/src/codeReader/qa/asks.ts index 7fc64c8..f2d340f 100644 --- a/vscode/src/codeReader/qa/asks.ts +++ b/vscode/src/codeReader/qa/asks.ts @@ -1,5 +1,5 @@ /** - * 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,不同块可以同时进行(最多 MAX_RUNNING 个),能叫停。 + * 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,能叫停;不同块同时进行几轮、满了怎么办按 RunPolicy。 * 不碰 VS Code:结束后怎么存、怎么告诉界面交给 AskListener。 */ @@ -7,9 +7,6 @@ import type { Answer, AskRun } from './claudeAsk' import { ClaudeCancelledError } from '../../cc/spawnClaude' import { askFailure, rewriteFailure } from './claudeAsk' -/** 同时进行的最多几个:每个都是一个 claude 进程 */ -export const MAX_RUNNING = 3 - /** 正在进行的一轮,界面照它显示。 */ export interface RunningAsk { askId: string @@ -19,6 +16,8 @@ export interface RunningAsk { text: string /** 正在做的事:「读取 routes.py」 */ activity?: string + /** 还在排队,前面的结束了才开始 */ + queued?: true } /** 进行中报上来的:会话 id、回答长出来的字、正在做的事。 */ @@ -52,28 +51,34 @@ export interface AskListener { end: (ask: RunningAsk, end: AskEnd) => void | Promise } -/** 给人看的话:这块已经在进行、同时进行的太多、没成的原因。 */ -export interface RunWording { +/** 一种对话怎么跑:同时最多几轮(每轮一个 claude 进程),满了是不开始(refuse,回给人看的话)还是排在后面(queue),以及给人看的话。 */ +export interface RunPolicy { + limit: number + whenFull: { kind: 'refuse', message: (limit: number) => string } | { kind: 'queue' } + /** 这块已经在进行 */ busy: string - full: (limit: number) => string + /** 没成的原因 */ failure: (err: unknown) => string } -export const ASK_WORDING: RunWording = { +export const ASK_POLICY: RunPolicy = { + limit: 3, + whenFull: { kind: 'refuse', message: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。` }, busy: '这里已经在问了,等它答完或者先停下再问。', - full: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。`, failure: askFailure, } -export const REWRITE_WORDING: RunWording = { +/** 重写注释一次只跑一段:OpenPencil 同一时间只能开一个画板,两段同时画,一边 `op stop` 另一边就跟着断。 */ +export const REWRITE_POLICY: RunPolicy = { + limit: 1, + whenFull: { kind: 'queue' }, busy: '这段注释已经在重写了,等它写完或者先停下再说。', - full: limit => `同时最多重写 ${limit} 段注释,等前面的写完再来。`, failure: rewriteFailure, } -/** 这一轮眼下怎样:还在进行,或出错、被叫停(没记下,留着给人看)。 */ -export interface AskRound extends RunningAsk { - status: 'running' | 'failed' | 'stopped' +/** 这一轮眼下怎样:排着队、还在进行,或出错、被叫停(没记下,留着给人看)。 */ +export interface AskRound extends Omit { + status: 'queued' | 'running' | 'failed' | 'stopped' /** failed 时给人看的原因 */ error?: string } @@ -82,12 +87,15 @@ interface Round { owner: string ask: RunningAsk controller: AbortController - /** 出错、被叫停了;还在进行时没有 */ + /** 排着队时才有:轮到了怎么开始,取消了怎么结束 */ + waiting?: { launch: () => void, cancel: () => void } + /** 出错、被叫停了;还没结束时没有 */ end?: Exclude } /** * Run 是跑一轮要的全部,叫停的开关和进行中的消息由这里给。 + * 满了按 RunPolicy 不开始或排在后面;排着的按先后,前面结束一轮(答成、出错、被叫停都算)就开始下一轮。 * 答成了的那一轮交给 listener 记下,这里就不管了;出错、被叫停的没记下,留在这里(页面重新加载后靠 currentFor 再画出来), * 这块再开一轮、或页面上收起这块时去掉(forget)。 */ @@ -96,72 +104,91 @@ export class QaAsks Promise, - private readonly wording: RunWording = ASK_WORDING, - private readonly limit = MAX_RUNNING, + private readonly policy: RunPolicy = ASK_POLICY, ) {} - /** 开始一轮;这块已经在进行、同时进行的太多时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */ + /** 开始一轮,满了按 RunPolicy 排在后面;这块已经在进行或排着、满了又不排队时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */ start(start: AskStart>, listener: AskListener): string | undefined { const { owner, askId, anchor, question } = start if (this.rounds.has(askId)) return '这个问题已经在问了。' - const running = [...this.rounds.values()].filter(round => !round.end) - if (running.some(round => round.owner === owner && round.ask.anchor === anchor)) - return this.wording.busy - if (running.length >= this.limit) - return this.wording.full(this.limit) + if ([...this.rounds.values()].some(round => !round.end && round.owner === owner && round.ask.anchor === anchor)) + return this.policy.busy + const full = this.running() >= this.policy.limit + if (full && this.policy.whenFull.kind === 'refuse') + return this.policy.whenFull.message(this.policy.limit) this.forget(owner, anchor) - const controller = new AbortController() const ask: RunningAsk = { askId, anchor, question, text: '' } - const round: Round = { owner, ask, controller } + const round: Round = { owner, ask, controller: new AbortController() } this.rounds.set(askId, round) - const control: RunControl = { - signal: controller.signal, - onEvent: (event) => { - if (event.kind === 'text') { - ask.text = event.text - delete ask.activity - } - else if (event.kind === 'activity') { - ask.activity = event.label - } - else { - return - } - listener.update({ ...ask }) - }, - } - // Run 里除了这两项就是 start.run,拼回去还是 Run - void this.runner({ ...start.run, ...control } as unknown as Run).then( - (answer): AskEnd => ({ status: 'done', answer }), - (err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.wording.failure(err) }, - ).then(async (end) => { + const finish = async (end: AskEnd): Promise => { if (end.status === 'done') this.rounds.delete(askId) else round.end = end - await listener.end({ ...ask }, end) - }) + try { + await listener.end({ ...ask }, end) + } + finally { + this.next() + } + } + const launch = (): void => { + listener.update({ ...ask }) + const control: RunControl = { + signal: round.controller.signal, + onEvent: (event) => { + if (event.kind === 'text') { + ask.text = event.text + delete ask.activity + } + else if (event.kind === 'activity') { + ask.activity = event.label + } + else { + return + } + listener.update({ ...ask }) + }, + } + // Run 里除了这两项就是 start.run,拼回去还是 Run + void this.runner({ ...start.run, ...control } as unknown as Run).then( + (answer): AskEnd => ({ status: 'done', answer }), + (err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.policy.failure(err) }, + ).then(finish) + } + if (!full) { + launch() + return undefined + } + ask.queued = true + round.waiting = { launch, cancel: () => void finish({ status: 'stopped' }) } + listener.update({ ...ask }) return undefined } - /** 叫停:结束 claude 进程,这次不存。 */ + /** 叫停:结束 claude 进程,这次不存;排着队的取消,不再开始。 */ stop(askId: string): void { const round = this.rounds.get(askId) - if (round && !round.end) + if (!round || round.end) + return + const waiting = this.dequeue(round) + if (waiting) + waiting.cancel() + else round.controller.abort() } - /** owner 这份文件当前的各轮:还在进行的,和出错、被叫停留着的,按开始的先后。 */ + /** owner 这份文件当前的各轮:排着队的、还在进行的,和出错、被叫停留着的,按开始的先后。 */ currentFor(owner: string): AskRound[] { - return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask, end }) => ({ + return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask: { queued, ...ask }, end }) => ({ ...ask, - status: end?.status ?? 'running', + status: end?.status ?? (queued ? 'queued' : 'running'), ...(end?.status === 'failed' && { error: end.error }), })) } - /** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行的不动。 */ + /** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行、排着的不动。 */ forget(owner: string, anchor: string): void { for (const [askId, round] of this.rounds) { if (round.end && round.owner === owner && round.ask.anchor === anchor) @@ -169,11 +196,32 @@ export class QaAsks !round.end && !round.waiting).length + } + + /** 让这一轮不再排队,返回它排队时的办法;没在排队时没有。 */ + private dequeue(round: Round): Round['waiting'] { + const { waiting } = round + delete round.waiting + delete round.ask.queued + return waiting + } + + /** 有空位就按先后开始排着的。 */ + private next(): void { for (const round of this.rounds.values()) { + if (this.running() >= this.policy.limit) + return if (!round.end) - round.controller.abort() + this.dequeue(round)?.launch() } } } diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts index d7fc981..ded96c3 100644 --- a/vscode/src/codeReader/qa/codeQa.test.ts +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -182,7 +182,10 @@ describe('重写注释的记录', () => { expect(rewriter.mock.calls[1][0].followUp).toEqual({ sessionId: 'r1', prompt: 'a.ts:1-5\n\n短一点' }) expect((await second).end).toEqual({ status: 'done', body: '短的。', error: '编辑器没接受这次修改,没有写回。' }) expect(writeBack).toHaveBeenCalledWith('短的。') - expect(rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])).toEqual([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']]) + // 两条可能记在同一毫秒,不比先后 + const entries = rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error]) + expect(entries).toHaveLength(2) + expect(entries).toEqual(expect.arrayContaining([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']])) await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] }) expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp') @@ -199,8 +202,10 @@ describe('重写注释的记录', () => { const second = ended(qa, '2') await qa.rewrite(uri, request('2', { writeBack: async body => body === undefined ? '模型没有按要求交回注释正文,没有改文件。' : undefined })) expect(rewriter.mock.calls[1][0]).not.toHaveProperty('followUp') - expect((await second).end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' }) - expect(rewritesOf(qa)[0].entries.at(-1)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' }) + const { end, entryId } = await second + expect(end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' }) + // 两条可能记在同一毫秒,按 id 找这一条 + expect(rewritesOf(qa)[0].entries.find(entry => entry.id === entryId)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' }) }) it('出错、被叫停的不记,也不写文件;进行中的、没记下留着的那一轮在 rewriteCurrent 里,页面重新加载能再画出来,收起这块时去掉', async () => { @@ -227,4 +232,33 @@ describe('重写注释的记录', () => { qa.forgetRewrite(uri, 'function f') expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([]) }) + + it('整个扩展一次只重写一段:别的文件的排在后面、报排队中,记在 rewriteCurrent 里;前一段写完自动开始;问 AI 照旧同时问', async () => { + const ctx = context() + const { qa, runner, rewriter } = service(ctx) + let finishFirst: (answer: CommentAnswer) => void = () => {} + rewriter.mockImplementationOnce(() => new Promise((resolve) => { + finishFirst = resolve + })) + rewriter.mockResolvedValueOnce({ body: '第二段。', sessionId: 'r2' }) + const other = { fsPath: '/workspace/b.ts', toString: () => 'file:///workspace/b.ts' } as Uri + const events: RewriteEvent[] = [] + qa.onDidRewrite(event => events.push(event)) + const writeBack = vi.fn(async () => undefined) + expect(await qa.rewrite(uri, request('1'))).toBeUndefined() + expect(await qa.rewrite(other, request('2', { anchor: 'function g', writeBack }))).toBeUndefined() + expect(rewriter).toHaveBeenCalledTimes(1) + expect(events).toContainEqual({ owner: 'file:///workspace/b.ts', kind: 'update', ask: { askId: '2', anchor: 'function g', question: SKILL, text: '', queued: true } }) + expect(qa.view(other, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function g', question: SKILL, text: '', status: 'queued' }]) + + await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] }) + await qa.ask(other, { askId: '4', anchor: 'function g', question: '为什么', prompt: '完整的', followUp: '短的', images: [] }) + expect(runner).toHaveBeenCalledTimes(2) + + const second = ended(qa, '2') + finishFirst({ body: '第一段。', sessionId: 'r1' }) + expect((await second).end).toEqual({ status: 'done', body: '第二段。' }) + expect(rewriter).toHaveBeenCalledTimes(2) + expect(writeBack).toHaveBeenCalledWith('第二段。') + }) }) diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index f99761c..3c45c0e 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -13,7 +13,7 @@ import { getSettings, onDidChangeSettings } from '../../settings/store' import { pathQaLocation } from '../editor/pathPreview' import { FolderStore, locate } from '../folderStore' import { AskProfiles } from './askProfiles' -import { QaAsks, REWRITE_WORDING } from './asks' +import { QaAsks, REWRITE_POLICY } from './asks' import { askClaude, askCommentBody } from './claudeAsk' import { describePath } from './pathAskDescribe' import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt' @@ -135,7 +135,7 @@ export class CodeQa implements Disposable { }) }) this.asks = new QaAsks(runner) - this.rewrites = new QaAsks(rewriter, REWRITE_WORDING) + this.rewrites = new QaAsks(rewriter, REWRITE_POLICY) } /** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */ diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx index 07e4dd4..329186a 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -149,4 +149,13 @@ describe('块下面的重写记录(样子和问答一样)', () => { expect(written).not.toContain('role="alert"') expect(text(render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'stopped' })] }))).toContain('已停止,这次没存。') }) + + it('排着队:这一轮写着「排队中」、不转圈;输入框旁边的按钮是「取消排队」,点了按叫停发出去', () => { + const html = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ question: '/ecc:writing-code-comments web/a.tsx:3-40', status: 'queued' })] }) + expect(text(html)).toContain('排队中,前面的写完就开始') + expect(html).not.toContain('cr-qa-spin') + expect(html).toContain('data-tooltip="取消排队" aria-label="取消排队"') + expect(html).not.toContain('aria-label="停止"') + expect(html).not.toContain('aria-label="发送"') + }) }) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 3aa2f1e..8d01d5e 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -3,12 +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, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' +import { ChevronDown, ChevronRight, CircleStop, Hourglass, LoaderCircle, MessageSquareText, 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 { renderMarkdown, renderMarkdownOnce } from './markdown' -import { answerPreview, relativeTime } from './qa' +import { answerPreview, relativeTime, unfinished } from './qa' import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' /** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */ @@ -140,7 +140,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) @@ -165,7 +165,13 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW {label} )} - {ask.error !== undefined && ask.status !== 'running' && ( + {ask.status === 'queued' && ( +
+ + 排队中,前面的写完就开始 +
+ )} + {ask.error !== undefined && !unfinished(ask) && (
{ask.error} @@ -176,9 +182,10 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW ) } -/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ -function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { - running: Ask | undefined +/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止,排着队时换成取消排队。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ +function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { + /** 正在答或排着队的那一轮 */ + active: Ask | undefined /** 输入框里的提示(后面接着怎么发送、收起) */ placeholder: string /** 输入框读屏用的名字 */ @@ -197,6 +204,7 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs focusToken: number }): ReactElement { const [value, setValue] = useState('') + const stopLabel = active?.status === 'queued' ? '取消排队' : '停止' const [images, setImages] = useState([]) /** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */ const [notice, setNotice] = useState() @@ -221,7 +229,7 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs } const send = (): void => { const question = value.trim() - if (question === '' || running) + if (question === '' || active) return onAsk(question, images) setValue('') @@ -282,9 +290,9 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它" className="cr-qa-profile" /> - {running + {active ? ( - ) @@ -357,7 +365,7 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码' {history.length > 0 && asks.length > 0 &&
} {asks.map(ask => )} ask.status === 'running')} + active={asks.find(unfinished)} placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)} label={wording.inputLabel} profiles={profiles} diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 914c135..a116cf8 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -1,5 +1,5 @@ import type { BlockTreeState, RewriteTree } from './BlockTree' -import type { ChatState } from './qa' +import type { Ask, ChatState } from './qa' import type { BlockView, KindBadge } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' @@ -215,6 +215,18 @@ describe('「注释」一行和「重写注释」按钮', () => { expect(opened).toContain('aria-label="对这段注释的修改意见"') }) + it('展开着又在写或排着队时按钮不能点;出错、停下的不挡', () => { + const block = view('6', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } }) + const button = (status: Ask['status']): string => { + const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] } + const markup = renderToStaticMarkup() + return /]*aria-label="按注释规范重写"[^>]*>/.exec(markup)?.[0] ?? '' + } + expect(button('running')).toContain('disabled') + expect(button('queued')).toContain('disabled') + expect(button('stopped')).not.toContain('disabled') + }) + it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => { inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释') expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范') diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 8163ca8..70c2f3c 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -14,7 +14,7 @@ import { DiffText } from './DiffText' import { removedTitle } from './diffView' import { KindIcon } from './kindIcons' import { PropertyGrid, PropertyTable } from './PropertyGrid' -import { asksOf, historyOf } from './qa' +import { asksOf, historyOf, unfinished } from './qa' import { TextPreview } from './TextPreview' import { tableId } from './viewState' @@ -272,7 +272,7 @@ function callTreeTool(block: BlockView, tree: BlockTreeState) { /** * 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。 - * 展开着又正在写时不能再点。 + * 展开着又正在写、排着队时不能再点。 */ function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined { const { edit, rewrite } = tree @@ -284,7 +284,7 @@ function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | unde const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0) return { fresh: block.commentTarget.kind === 'insert', - rewriting: open && asks.some(ask => ask.status === 'running'), + rewriting: open && asks.some(unfinished), reopen, onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor), } diff --git a/vscode/webview-ui/src/codeReader/qa.test.ts b/vscode/webview-ui/src/codeReader/qa.test.ts index acdd2bb..7f4ccd4 100644 --- a/vscode/webview-ui/src/codeReader/qa.test.ts +++ b/vscode/webview-ui/src/codeReader/qa.test.ts @@ -130,6 +130,25 @@ describe('重写注释的状态:和问 AI 同一套', () => { state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' }) expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' }) }) + + it('扩展端说排着队就显示排队中,轮到了变成在写;收起这块时排着的和在写的一样留着;取消了照停下记', () => { + let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] }) + state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '', queued: true }) + expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, images: [], text: '', status: 'queued' }]) + state = chatReducer(state, { type: 'close', anchor: IF }) + expect(asksOf(state, IF).map(ask => ask.status)).toEqual(['queued']) + state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' }) + expect(asksOf(state, IF)[0].status).toBe('running') + state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'stopped' } }) + expect(asksOf(state, IF)[0].status).toBe('stopped') + expect(chatReducer(state, { type: 'answer', askId: 'r1', text: '晚到的', queued: true })).toEqual(state) + }) + + it('页面重新加载后:扩展端排着队的那一轮照样显示排队中、展开它那块', () => { + const state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }] }) + expect(state.open).toEqual([IF]) + expect(asksOf(state, IF)).toEqual([{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }]) + }) }) describe('问的时间', () => { diff --git a/vscode/webview-ui/src/codeReader/qa.ts b/vscode/webview-ui/src/codeReader/qa.ts index 783b496..b553ee2 100644 --- a/vscode/webview-ui/src/codeReader/qa.ts +++ b/vscode/webview-ui/src/codeReader/qa.ts @@ -17,7 +17,8 @@ export interface Ask { text: string /** 正在做的事:「读取 routes.py」 */ activity?: string - status: 'running' | 'done' | 'failed' | 'stopped' + /** queued 是重写注释排着队,前面的写完才开始 */ + status: 'queued' | 'running' | 'done' | 'failed' | 'stopped' /** failed 时给人看的原因;重写注释 done 了但没写进文件时也有 */ error?: string /** done 时存下的那条:历史里不再重复显示它 */ @@ -62,11 +63,11 @@ export const EMPTY_QA: QaState = { } /** - * sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道) + * sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了、排上队了(queued)或轮到了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道) */ export type ChatAction = | { type: 'sync', available: boolean, threads: QaThreadView[], current: AskRound[] } - | { type: 'answer', askId: string, text: string, activity?: string, question?: string } + | { type: 'answer', askId: string, text: string, activity?: string, question?: string, queued?: boolean } | { type: 'answerEnd', end: RoundEnd } | { type: 'ask', askId: string, anchor: string, question: string, images: string[] } | { type: 'open', anchor: string } @@ -98,8 +99,8 @@ export function chatReducer(state: S, action: ChatAction): case 'answer': return { ...state, - asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running' - ? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }) } + asks: state.asks.map(ask => ask.askId === action.askId && unfinished(ask) + ? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }), status: action.queued ? 'queued' : 'running' } : ask), } case 'answerEnd': { @@ -120,10 +121,10 @@ export function chatReducer(state: S, action: ChatAction): case 'open': return { ...state, open: withOpen(state.open, action.anchor) } case 'close': - // 收起就结束这一轮:答完的都回到灰字历史里;还在进行的留着,答完照样存 + // 收起就结束这一轮:答完的都回到灰字历史里;还在进行、排着队的留着,答完照样存 return { ...state, - asks: state.asks.filter(ask => ask.anchor !== action.anchor || ask.status === 'running'), + asks: state.asks.filter(ask => ask.anchor !== action.anchor || unfinished(ask)), open: state.open.filter(anchor => anchor !== action.anchor), } } @@ -142,6 +143,11 @@ export function qaReducer(state: QaState, action: QaAction): QaState { } } +/** 这一轮还没结束:在进行,或排着队 */ +export function unfinished(ask: Pick): boolean { + return ask.status === 'running' || ask.status === 'queued' +} + /** 当前这一轮在 anchor 那块说的 */ export function asksOf(state: ChatState, anchor: string): Ask[] { return state.asks.filter(ask => ask.anchor === anchor) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index d9a647a..f3daad9 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -285,7 +285,7 @@ export interface QaThreadView { } /** - * 扩展端记着的当前一轮:还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」), + * 扩展端记着的当前一轮:排着队的(重写注释一次只跑一段)、还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」), * error 是 failed 时给人看的原因 */ export interface AskRound { @@ -294,7 +294,7 @@ export interface AskRound { question: string text: string activity?: string - status: 'running' | 'failed' | 'stopped' + status: 'queued' | 'running' | 'failed' | 'stopped' error?: string } @@ -460,7 +460,7 @@ export interface StickyScrollSettings { * askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了,见 AnswerEnd - * rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) + * rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) * rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 @@ -527,7 +527,7 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | AnswerEnd - | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string } + | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true } | RewriteEnd | { type: 'diff', diff: DocumentDiff | null } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } diff --git a/vscode/webview-ui/src/codeReader/useRewrite.ts b/vscode/webview-ui/src/codeReader/useRewrite.ts index dba56f8..5672498 100644 --- a/vscode/webview-ui/src/codeReader/useRewrite.ts +++ b/vscode/webview-ui/src/codeReader/useRewrite.ts @@ -2,21 +2,21 @@ import type { RewriteTree } from './BlockTree' import type { ChatState } from './qa' import type { ProfileChoice } from './types' import { useEffect, useMemo, useReducer, useRef } from 'react' -import { chatReducer, EMPTY_CHAT, newAskId } from './qa' +import { chatReducer, EMPTY_CHAT, newAskId, unfinished } from './qa' import { toAskImage } from './qaImages' import { ANSWER_FRAME_MS } from './useQa' import { onMessage, postMessage } from './vscode' /** * 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套: - * 进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。 + * 排着队的、进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。 */ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string) => void): RewriteTree { const [state, dispatch] = useReducer(chatReducer, EMPTY_CHAT) useEffect(() => { /** 攒着没画的进展:askId → 最新的一份 */ - const pending = new Map() + const pending = new Map() let timer: ReturnType | undefined const flush = (): void => { timer = undefined @@ -30,7 +30,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string dispatch({ type: 'sync', available: msg.available, threads: msg.rewrites, current: msg.rewriteCurrent }) return case 'rewriteProgress': - pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) }) + pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }), ...(msg.queued && { queued: true }) }) timer ??= setTimeout(flush, ANSWER_FRAME_MS) return case 'rewriteEnd': @@ -45,7 +45,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string } }, []) - /** 动作里读眼下的状态(这块是不是已经在写) */ + /** 动作里读眼下的状态(这块是不是已经在写或排着队) */ const current = useRef(state) current.current = state @@ -54,7 +54,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string profiles, onSelectProfile, onRewrite: (anchor) => { - if (current.current.asks.some(ask => ask.anchor === anchor && ask.status === 'running')) + if (current.current.asks.some(ask => ask.anchor === anchor && unfinished(ask))) return const askId = newAskId() // 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来