✨ feat(vscode): 重写注释排成一队,整个扩展一次只跑一段

OpenPencil 同一时间只能开一个画板,两段同时画会互相掐断。所有预览、所有文件的重写(第一轮和接着说)排成一队,前一段写成、出错、被叫停后按先后自动开始下一段;问 AI 照旧最多同时 3 个。
排着的那一轮记在扩展端(rewriteCurrent 的 queued),块下面写「排队中」、能取消排队,切标签页、页面重新加载后照样显示。
This commit is contained in:
cruldra committed 2026-10-12 15:11:22 +11:00
1 parent b7c29c60e1
commit aecc4d1ab5
15 files changed
+329 -114

No files matched your search

+4 -3
View File
@@ -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
+3 -3
View File
@@ -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[] }
@@ -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()
}
+91 -11
View File
@@ -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<Answer>, 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<void> => 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'])
})
})
+104 -56
View File
@@ -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<Result = Answer> {
end: (ask: RunningAsk, end: AskEnd<Result>) => void | Promise<void>
}
/** 给人看的话:这块已经在进行、同时进行的太多、没成的原因。 */
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<RunningAsk, 'queued'> {
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<AskEnd, { status: 'done' }>
}
/**
* Run 是跑一轮要的全部,叫停的开关和进行中的消息由这里给。
* 满了按 RunPolicy 不开始或排在后面;排着的按先后,前面结束一轮(答成、出错、被叫停都算)就开始下一轮。
* 答成了的那一轮交给 listener 记下,这里就不管了;出错、被叫停的没记下,留在这里(页面重新加载后靠 currentFor 再画出来),
* 这块再开一轮、或页面上收起这块时去掉(forget)。
*/
@@ -96,72 +104,91 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
constructor(
private readonly runner: (run: Run) => Promise<Result>,
private readonly wording: RunWording = ASK_WORDING,
private readonly limit = MAX_RUNNING,
private readonly policy: RunPolicy = ASK_POLICY,
) {}
/** 开始一轮;这块已经在进行、同时进行的太多时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
/** 开始一轮,满了按 RunPolicy 排在后面;这块已经在进行或排着、满了又不排队时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
start(start: AskStart<Omit<Run, keyof RunControl>>, listener: AskListener<Result>): 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<Result> => ({ status: 'done', answer }),
(err: unknown): AskEnd<Result> => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.wording.failure(err) },
).then(async (end) => {
const finish = async (end: AskEnd<Result>): Promise<void> => {
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<Result> => ({ status: 'done', answer }),
(err: unknown): AskEnd<Result> => 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<Run extends { signal: AbortSignal } = AskRun, Result = Answe
}
}
/** 全部叫停(插件停用时)。 */
/** 全部叫停(插件停用时)。排着的同步取消,前面的停下后不会再开始它们。 */
stopAll(): void {
for (const askId of [...this.rounds.keys()])
this.stop(askId)
}
/** 正在跑的有几轮(排着的、结束了的不算) */
private running(): number {
return [...this.rounds.values()].filter(round => !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()
}
}
}
+37 -3
View File
@@ -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('第二段。')
})
})
+2 -2
View File
@@ -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 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */
@@ -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="发送"')
})
})
+19 -11
View File
@@ -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}
</div>
)}
{ask.error !== undefined && ask.status !== 'running' && (
{ask.status === 'queued' && (
<div className="cr-qa-status" role="status">
<Hourglass size={12} strokeWidth={1.8} aria-hidden />
排队中,前面的写完就开始
</div>
)}
{ask.error !== undefined && !unfinished(ask) && (
<div className="cr-qa-error" role="alert">
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
<span>{ask.error}</span>
@@ -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<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
@@ -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
? (
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
<button type="button" className="cr-tool" data-tooltip={stopLabel} aria-label={stopLabel} onClick={() => onStop(active.askId)}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)
@@ -357,7 +365,7 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
active={asks.find(unfinished)}
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
label={wording.inputLabel}
profiles={profiles}
@@ -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(<BlockLevel blocks={[block]} tree={state(new Set(), rewrite)} />)
return /<button[^>]*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('按注释规范')
@@ -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),
}
@@ -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('问的时间', () => {
+13 -7
View File
@@ -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<S extends ChatState>(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<S extends ChatState>(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<Ask, 'status'>): 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)
+4 -4
View File
@@ -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[] }
@@ -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<ChatState>, EMPTY_CHAT)
useEffect(() => {
/** 攒着没画的进展:askId → 最新的一份 */
const pending = new Map<string, { question: string, text: string, activity?: string }>()
const pending = new Map<string, { question: string, text: string, activity?: string, queued?: boolean }>()
let timer: ReturnType<typeof setTimeout> | 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 报上来