✨ feat(vscode): 重写注释时在卡片下展示模型过程

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 08fb04e3fd
commit 5e8e7b65ee
15 files changed
+369 -83

No files matched your search

+7 -17
View File
@@ -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<DocumentMessage | null>(null)
/** 正在按注释规范重写的块(记号)。点下去立刻记上,扩展端写完或失败再发 commentRewritten */
const rewritingRef = useRef(new Set<string>())
const [rewriting, setRewriting] = useState<ReadonlySet<string>>(NONE)
const [view, setView] = useState<BlockModes>(savedBlockModes)
const [focusId, setFocusId] = useState<string | null>(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
+31 -2
View File
@@ -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 (
<div className="cr-qa-entry" data-current="">
<div className="cr-qa-head">
@@ -116,7 +118,12 @@ function CurrentItem({ ask, onOpenImage }: { ask: Ask, onOpenImage: (url: string
{running && (
<div className="cr-qa-status" role="status">
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
{ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…')}
{onStop ? <span className="min-w-0 flex-1">{label}</span> : label}
{onStop && (
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={onStop}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
</div>
)}
{ask.status === 'failed' && (
@@ -297,3 +304,25 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
</section>
)
}
/**
* 注释块下面的重写过程,样子和展开的「问 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 (
<section className="cr-qa" aria-label="按注释规范重写">
<CurrentItem ask={ask} onOpenImage={() => {}} onStop={run.status === 'running' ? onStop : undefined} />
</section>
)
}
@@ -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')
})
})
@@ -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
? <LoaderCircle size={14} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
: <RefreshCw size={14} strokeWidth={1.8} aria-hidden />}
<MessageSquareText size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
{onToggleAsk && (
@@ -67,8 +67,9 @@ function render(folds: Record<string, boolean>): string {
onToggleEdit: () => {},
onToggleFold: () => {},
onSetFolds: () => {},
rewriting: new Set(),
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
onOpenRun: () => {},
}
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={tree} />)
+15 -4
View File
@@ -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<string, boolean>) => void
/** 正在按注释规范重写的注释块(记号) */
rewriting: ReadonlySet<string>
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
rewrites: ReadonlyMap<string, RewriteRun>
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
<BlockCard block={block} tree={tree} />
</ChangeRow>
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
<BlockRewrite anchor={block.anchor} tree={tree} />
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
@@ -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 <RewriteThread run={run} onStop={() => 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}
/>
</div>
@@ -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(<RewriteThread run={run} onStop={() => {}} />)
}
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('<textarea')
expect(html).not.toContain('问过')
expect(html).not.toContain('继续问')
expect(html).not.toContain('cr-qa-profile')
})
it('没写成时原因留在这块上,不收成「问过 N 次」', () => {
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('<textarea')
expect(html).not.toContain('aria-label="停止"')
})
})
@@ -129,7 +129,8 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
transition: background-color 100ms ease-out;
}
.cr-block:hover + .cr-level > * > .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;
}
@@ -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)
})
})
@@ -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<string, RewriteRun>
/** 点了重写:这块标成正在进行。已经在进行就不动。 */
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
}
+7 -1
View File
@@ -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 }
+1 -1
View File
@@ -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 {
@@ -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<RewriteRuns>(() => new Map())
/** 动作里读眼下的过程(这块是不是已经在重写) */
const current = useRef(runs)
current.current = runs
useEffect(() => {
/** 攒着没画的进展:anchor → 最新的一份 */
const pending = new Map<string, { question: string, text: string, activity?: string }>()
let timer: ReturnType<typeof setTimeout> | 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])
}