✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图

每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
cruldra committed 2026-10-12 13:45:37 +11:00
1 parent ae4e2160ef
commit c9f60f5b30
32 files changed
+1654 -860

No files matched your search

+4 -6
View File
@@ -59,8 +59,8 @@ export function App(): ReactElement | null {
const qa = useQa()
/** 侧栏「问 AI」要聚焦顶部输入框时加一 */
const [pathFocus, setPathFocus] = useState(0)
/** 按注释规范重写的过程,只在这一页上 */
const rewrites = useRewrite()
/** 按注释规范重写:和问 AI 共用 profile 下拉 */
const rewrite = useRewrite(qa.state.profiles, qa.onSelectProfile)
const [doc, setDoc] = useState<DocumentMessage | null>(null)
const [view, setView] = useState<BlockModes>(savedBlockModes)
const [focusId, setFocusId] = useState<string | null>(null)
@@ -213,9 +213,6 @@ export function App(): ReactElement | null {
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
}))
},
rewrites: rewrites.runs,
onRewriteComment: rewrites.onRewrite,
onStopRewrite: rewrites.onStop,
},
collapsed,
hidden: filter.hidden,
@@ -225,6 +222,7 @@ export function App(): ReactElement | null {
onToggleFold: id => setFolded([id], !collapsed.has(id)),
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
...(qa.state.available && { qa }),
...(rewrite.state.available && { rewrite }),
...(changes && { diff: changes }),
...(folded && { folded }),
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
@@ -233,7 +231,7 @@ export function App(): ReactElement | null {
...(openCall?.version === doc.version && { openAnchor: openCall.anchor }),
onOpen: onOpenCall,
},
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded, callCounts, openCall, onOpenCall])
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrite, qa, changes, folded, callCounts, openCall, onOpenCall])
if (!doc || !tree)
return null
@@ -3,7 +3,7 @@ import type { Ask } from './qa'
import type { ProfileChoice, QaEntryView } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AskThread } from './AskThread'
import { AskThread, REWRITE_THREAD } from './AskThread'
const NOW = new Date(2026, 8, 26, 15, 0).getTime()
@@ -112,3 +112,41 @@ describe('块下面的问答区', () => {
expect(html).not.toContain('data-pending')
})
})
describe('块下面的重写记录(样子和问答一样)', () => {
const REWRITES: QaEntryView[] = [
{ id: 'r1', question: '/ecc:writing-code-comments web/a.tsx:3-40', answer: '我的智能体页:用卡片网格列出自己的智能体。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() },
{ id: 'r2', question: '加上原型图', answer: '我的智能体页。\n\n![原型](web/prototypes/A.png)', createdAt: new Date(2026, 8, 26, 14, 0).toISOString(), error: '编辑器没接受这次修改,没有写回。' },
]
it('没展开:一行「重写过 N 次」,下面灰字历史,没写进文件的那条带着原因、能删;没有输入框', () => {
const html = render({ wording: REWRITE_THREAD, history: REWRITES })
expect(text(html)).toMatch(/^重写过 2 次/)
expect(text(html)).toContain('/ecc:writing-code-comments web/a.tsx:3-40')
expect(text(html)).toContain('没写进文件:编辑器没接受这次修改,没有写回。')
expect(html.match(/data-tooltip="删除这条记录" aria-label="删除这条记录"/g)).toHaveLength(2)
expect(html).not.toContain('<textarea')
expect(html).not.toContain('问过')
})
it('展开:当前这一轮流出来的正文、正在做什么、能停;输入框说修改意见,旁边是 profile 下拉', () => {
const html = render({ wording: REWRITE_THREAD, open: true, history: REWRITES, asks: [ask({ question: '/ecc:writing-code-comments web/a.tsx:3-40', text: '新的**注释**', activity: '运行 op export' })] })
expect(html).toContain('aria-label="按注释规范重写"')
expect(html).toContain('data-streaming=""')
expect(text(html)).toContain('运行 op export')
expect(html).toContain('placeholder="说说这段注释要怎么改… Enter 发送,Esc 收起"')
expect(html).toContain('aria-label="对这段注释的修改意见"')
expect(html).toContain('cr-qa-profile')
expect(html).toContain('data-tooltip="停止" aria-label="停止"')
})
it('答完了但没写进文件:照常显示交回的正文,下面写着原因;出错、停止同问答', () => {
const done = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'done', text: '正文', error: '这块代码在重写期间变了,没有写回。', entryId: 'r3' })] })
expect(done).toContain('role="alert"')
expect(text(done)).toContain('正文')
expect(text(done)).toContain('这块代码在重写期间变了,没有写回。')
const written = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'done', text: '正文', entryId: 'r3' })] })
expect(written).not.toContain('role="alert"')
expect(text(render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'stopped' })] }))).toContain('已停止,这次没存。')
})
})
+79 -58
View File
@@ -1,9 +1,9 @@
import type { LucideIcon } from 'lucide-react'
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 { ChevronDown, ChevronRight, CircleStop, 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'
@@ -11,6 +11,39 @@ import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime } from './qa'
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */
export interface ThreadWording {
label: string
icon: LucideIcon
summary: (count: number) => string
deleteLabel: string
/** first:还没说过;hint:「问」后面的对象 */
placeholder: (first: boolean, hint: string) => string
inputLabel: string
answering: string
}
export const ASK_THREAD: ThreadWording = {
label: '问 AI',
icon: Sparkles,
summary: count => `问过 ${count} 次`,
deleteLabel: '已理解,删除',
placeholder: (first, hint) => first ? `问${hint}…` : '继续问…',
inputLabel: '问 AI 的问题',
answering: '正在回答…',
}
/** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */
export const REWRITE_THREAD: ThreadWording = {
label: '按注释规范重写',
icon: MessageSquareText,
summary: count => `重写过 ${count} 次`,
deleteLabel: '删除这条记录',
placeholder: () => '说说这段注释要怎么改…',
inputLabel: '对这段注释的修改意见',
answering: '正在写…',
}
/** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */
function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null {
if (urls.length === 0)
@@ -64,14 +97,18 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
)
}
/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
/**
* 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
* 重写记录没写进文件时,最下面写着原因
*/
function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: QaEntryView, wording: ThreadWording, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
const toggle = (): void => setExpanded(value => !value)
const Icon = wording.icon
return (
<div className="cr-qa-entry" data-history="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
{entry.question}
</button>
@@ -81,7 +118,7 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
</time>
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
</span>
<button type="button" className="cr-tool cr-qa-delete" data-tooltip="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
<button type="button" className="cr-tool cr-qa-delete" data-tooltip={wording.deleteLabel} aria-label={wording.deleteLabel} onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
@@ -93,18 +130,25 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
{answerPreview(entry.answer)}
</button>
)}
{entry.error && (
<div className="cr-qa-status" data-warning="">
<TriangleAlert size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
{`没写进文件:${entry.error}`}
</div>
)}
</div>
)
}
/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */
function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url: string) => void, onStop?: () => void }): ReactElement {
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 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 === '' ? '正在想…' : '正在回答…')
const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering)
const Icon = wording.icon
return (
<div className="cr-qa-entry" data-current="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
</div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
@@ -118,15 +162,10 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
{running && (
<div className="cr-qa-status" role="status">
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
{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>
)}
{label}
</div>
)}
{ask.status === 'failed' && (
{ask.error !== undefined && ask.status !== 'running' && (
<div className="cr-qa-error" role="alert">
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
<span>{ask.error}</span>
@@ -138,12 +177,12 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
running: Ask | undefined
/** 还没问过:提示「问…」,问过了是「继续问…」 */
first: boolean
/** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */
hint: string
/** 输入框里的提示(后面接着怎么发送、收起) */
placeholder: string
/** 输入框读屏用的名字 */
label: string
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string, images: PendingImage[]) => void
@@ -228,8 +267,8 @@ function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onSt
ref={textarea}
rows={1}
value={value}
placeholder={`${first ? `问${hint}…` : '继续问…'} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label="问 AI 的问题"
placeholder={`${placeholder} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label={label}
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
@@ -268,6 +307,8 @@ export interface AskThreadProps {
profiles: ProfileChoice
/** 「问」后面的对象,默认「这块代码」 */
hint?: string
/** 哪种对话,默认问 AI */
wording?: ThreadWording
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
autoFocus?: boolean
/** 变大时把光标放进输入框 */
@@ -276,7 +317,7 @@ export interface AskThreadProps {
closable?: boolean
now?: number
onSelectProfile: (id: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
/** images 是贴进输入框的图片,随这句话一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
@@ -284,14 +325,16 @@ export interface AskThreadProps {
}
/**
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。
* 块下面的对话区(问 AI、重写注释,左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但说过的块是一行「问过 N 次」(重写注释是「重写过 N 次」)、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里说过什么。
*/
export function AskThread({ history, asks, open, profiles, hint = '这块代码', autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
export function AskThread({ history, asks, open, profiles, hint = '这块代码', wording = ASK_THREAD, autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(true)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
const Icon = wording.icon
const historyItems = history.map(entry => <HistoryItem key={entry.id} entry={entry} wording={wording} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)
if (!open) {
if (history.length === 0)
return null
@@ -299,26 +342,26 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
return (
<div className="cr-qa" data-collapsed="">
<button type="button" className="cr-qa-summary" aria-expanded={showHistory} onClick={() => setShowHistory(value => !value)}>
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{`问过 ${history.length} 次`}
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{wording.summary(history.length)}
<Chevron size={12} strokeWidth={1.8} aria-hidden />
</button>
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{showHistory && historyItems}
{lightbox}
</div>
)
}
return (
<section className="cr-qa" aria-label="问 AI">
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
<section className="cr-qa" aria-label={wording.label}>
{historyItems}
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} onOpenImage={setPreview} />)}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
label={wording.inputLabel}
profiles={profiles}
onSelectProfile={onSelectProfile}
hint={hint}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
@@ -331,25 +374,3 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
</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>
)
}
@@ -2,13 +2,13 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { BlockTools } from './BlockTools'
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }, callTree?: { count: number }): string {
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean, reopen?: boolean }, callTree?: { count: number }): string {
return renderToStaticMarkup(
<BlockTools
editing={editing}
onToggleEdit={() => {}}
{...(ask && { asking: ask.asking, onToggleAsk: () => {} })}
{...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, onRewrite: () => {} } })}
{...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, reopen: rewrite.reopen ?? false, onRewrite: () => {} } })}
{...(callTree && { callTree: { anchor: 'fn', count: callTree.count, open: false, onOpen: () => {} } })}
/>,
)
@@ -66,6 +66,13 @@ describe('块右上角的图标按钮', () => {
expect(buttons[2]).toContain('data-tooltip="编辑"')
})
it('重写过、下面的重写记录收着时,同一个按钮先展开记录(接着说),叫「展开重写记录」', () => {
const reopen = render(false, { asking: false }, { rewriting: false, reopen: true })
const buttons = [...reopen.matchAll(/<button[^>]*>/g)].map(match => match[0])
expect(buttons[0]).toContain('data-tooltip="展开重写记录" aria-label="展开重写记录"')
expect(buttons[0]).not.toContain('disabled')
})
it('调用树排在编辑后面;棵数大于 0 时按钮上带这个数字,是 0 时只有图标', () => {
const html = render(false, { asking: false }, { rewriting: false }, { count: 3 })
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
@@ -2,16 +2,21 @@ import type { ReactElement } from 'react'
import { Eye, GitBranch, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
/** 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点。 */
/**
* 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点;
* reopen 是重写过、下面的重写记录收着,点了先展开它(好接着说),不重写。
*/
export interface RewriteTool {
fresh: boolean
rewriting: boolean
reopen: boolean
onRewrite: () => void
}
/**
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(data-tooltip)和读屏用的 aria-label 里。
* 「按注释规范重写」:用写注释的技能重写这块代码的注释并写回文件,块还没有注释时叫「按注释规范写注释」、新写一段;正在重写时不能再点。图标是注释。
* 「按注释规范重写」:用写注释的技能重写这块代码的注释并写回文件,块还没有注释时叫「按注释规范写注释」、新写一段;正在重写时不能再点。
* 重写过、记录收着时叫「展开重写记录」,点了只展开。图标是注释。
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
*/
@@ -29,7 +34,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
}): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
const rewriteLabel = rewrite?.fresh ? '按注释规范写注释' : '按注释规范重写'
const rewriteLabel = rewrite?.reopen ? '展开重写记录' : rewrite?.fresh ? '按注释规范写注释' : '按注释规范重写'
return (
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
{rewrite && (
@@ -1,4 +1,5 @@
import type { BlockTreeState } from './BlockTree'
import type { BlockTreeState, RewriteTree } from './BlockTree'
import type { ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
@@ -11,6 +12,7 @@ import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh
import { BlockLevel } from './BlockTree'
import { applyFilter } from './filter'
import { FilterBar } from './FilterBar'
import { EMPTY_CHAT } from './qa'
import { collapsedIds } from './viewState'
// 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
@@ -58,7 +60,12 @@ const CARD: BlockView = {
],
}
function state(collapsed: ReadonlySet<string>): BlockTreeState {
/** 重写注释的状态和动作:动作什么都不做 */
function rewriteTree(state: ChatState): RewriteTree {
return { state, profiles: { options: [], selected: '' }, onRewrite: () => {}, onAsk: () => {}, onStop: () => {}, onDelete: () => {}, onOpen: () => {}, onClose: () => {}, onSelectProfile: () => {} }
}
function state(collapsed: ReadonlySet<string>, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeState {
return {
edit: {
version: 1,
@@ -66,10 +73,8 @@ function state(collapsed: ReadonlySet<string>): BlockTreeState {
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
rewrite: rewriteTree(rewrite),
collapsed,
hidden: new Set(),
temporary: new Set(),
@@ -197,6 +202,19 @@ describe('「注释」一行和「重写注释」按钮', () => {
inToolbar(markup, '按注释规范重写')
})
it('重写过的块:下面一行「重写过 N 次」带灰字历史;记录收着时按钮先展开它,展开着时再点才重写', () => {
const block = view('5', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
const entry = { id: 'r1', question: '/ecc:writing-code-comments a.py:1-1', answer: '保存。', createdAt: '2026-09-25T10:00:00.000Z' }
const closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] }
const collapsedMarkup = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), closed)} />)
expect(collapsedMarkup.replace(/<[^>]*>/g, '')).toContain('重写过 1 次')
inToolbar(collapsedMarkup, '展开重写记录')
const opened = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), { ...closed, open: [block.anchor] })} />)
expect(opened).toMatch(/role="group" aria-label="块操作"><button[^>]*aria-label="按注释规范重写"/)
expect(opened).toContain('<section class="cr-qa" aria-label="按注释规范重写">')
expect(opened).toContain('aria-label="对这段注释的修改意见"')
})
it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => {
inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释')
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
+53 -21
View File
@@ -2,13 +2,12 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { RewriteTool } from './BlockTools'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { GridSlots } from './PropertyGrid'
import type { QaState } from './qa'
import type { ChatState, QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { RewriteRun } from './rewriteRun'
import type { BlockMarks, BlockView } from './types'
import type { BlockMarks, BlockView, ProfileChoice } from './types'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { Fragment, useState } from 'react'
import { AskThread, RewriteThread } from './AskThread'
import { AskThread, REWRITE_THREAD } from './AskThread'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
@@ -20,7 +19,7 @@ import { TextPreview } from './TextPreview'
import { tableId } from './viewState'
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」、按注释规范重写。页面上的块树才有;定义卡片浮窗里的块树没有,
* 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有,
* 卡片只读,不画右上角的按钮
*/
export interface EditTree {
@@ -30,11 +29,6 @@ export interface EditTree {
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
rewrites: ReadonlyMap<string, RewriteRun>
onRewriteComment: (anchor: string) => void
/** 叫停这块正在进行的重写,不写文件 */
onStopRewrite: (anchor: string) => void
}
/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */
@@ -64,6 +58,8 @@ export interface BlockTreeState {
onSetFolds: (folds: Record<string, boolean>) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree
/** 按注释规范重写;同问 AI,文件不在工作区文件夹里时没有 */
rewrite?: RewriteTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */
@@ -71,6 +67,21 @@ export interface BlockTreeState {
onOpenRun: (id: string) => void
}
/** 重写注释的状态和动作,块按自己的记号(anchor)取;profile 下拉和问 AI 共用 */
export interface RewriteTree {
state: ChatState
profiles: ProfileChoice
/** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */
onRewrite: (anchor: string) => void
/** 在输入框里说的修改意见,接着这块最近一次重写的会话说;images 是贴的图片 */
onAsk: (anchor: string, request: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
onOpen: (anchor: string) => void
onClose: (anchor: string) => void
onSelectProfile: (id: string) => void
}
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
export interface QaTree {
state: QaState
@@ -171,7 +182,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} />}
{tree.edit && <BlockRewrite anchor={block.anchor} edit={tree.edit} />}
{tree.rewrite && <BlockRewrite key={`rewrite ${block.anchor}`} anchor={block.anchor} rewrite={tree.rewrite} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
@@ -203,11 +214,22 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
)
}
function BlockRewrite({ anchor, edit }: { anchor: string, edit: EditTree }): ReactElement | null {
const run = edit.rewrites.get(anchor)
if (!run)
return null
return <RewriteThread run={run} onStop={() => edit.onStopRewrite(anchor)} />
/** 块下面的重写记录,紧跟在问答区后面;样子和问答区一样,输入框里说的是对这段注释的修改意见 */
function BlockRewrite({ anchor, rewrite }: { anchor: string, rewrite: RewriteTree }): ReactElement | null {
return (
<AskThread
wording={REWRITE_THREAD}
history={historyOf(rewrite.state, anchor)}
asks={asksOf(rewrite.state, anchor)}
open={rewrite.state.open.includes(anchor)}
profiles={rewrite.profiles}
onSelectProfile={rewrite.onSelectProfile}
onAsk={(request, images) => rewrite.onAsk(anchor, request, images)}
onStop={rewrite.onStop}
onDelete={entryId => rewrite.onDelete(anchor, entryId)}
onClose={() => rewrite.onClose(anchor)}
/>
)
}
/**
@@ -248,13 +270,23 @@ function callTreeTool(block: BlockView, tree: BlockTreeState) {
}
}
function rewriteTool(block: BlockView, edit: EditTree | undefined): RewriteTool | undefined {
if (!edit || !block.commentTarget)
/**
* 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。
* 展开着又正在写时不能再点。
*/
function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined {
const { edit, rewrite } = tree
if (!edit || !rewrite || !block.commentTarget)
return undefined
const { anchor } = block
const open = rewrite.state.open.includes(anchor)
const asks = asksOf(rewrite.state, anchor)
const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0)
return {
fresh: block.commentTarget.kind === 'insert',
rewriting: edit.rewrites.get(block.anchor)?.status === 'running',
onRewrite: () => edit.onRewriteComment(block.anchor),
rewriting: open && asks.some(ask => ask.status === 'running'),
reopen,
onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor),
}
}
@@ -339,7 +371,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
onToggleEdit={() => edit.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewrite={rewriteTool(block, edit)}
rewrite={rewriteTool(block, tree)}
callTree={callTreeTool(block, tree)}
/>
)}
@@ -62,9 +62,6 @@ function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeStat
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
}),
collapsed: new Set(),
@@ -1,41 +0,0 @@
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="停止"')
})
})
@@ -793,6 +793,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
@keyframes cr-qa-spin {
to { transform: rotate(360deg); }
}
/* 重写记录没写进文件:灰字历史里写一行原因,警告色 */
.cr-qa-status[data-warning] {
color: var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground));
}
.cr-qa-error {
display: flex;
gap: 6px;
+43 -14
View File
@@ -1,7 +1,7 @@
import type { QaAction, QaState } from './qa'
import type { QaMessage, QaThreadView } from './types'
import { describe, expect, it } from 'vitest'
import { answerPreview, asksOf, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
import { answerPreview, asksOf, chatReducer, EMPTY_CHAT, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
const IF = 'function get_user > if @3fa1c2d0'
@@ -9,12 +9,12 @@ function thread(anchor: string, ...ids: string[]): QaThreadView {
return { anchor, entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
}
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
return { type: 'qa', available: true, threads, path: [], subject: 'file', running }
function message(threads: QaThreadView[], current: QaMessage['current'] = []): QaMessage {
return { type: 'qa', available: true, threads, path: [], subject: 'file', current, rewrites: [], rewriteCurrent: [] }
}
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
return { type: 'qa', message: message(threads, running) }
function qa(threads: QaThreadView[], current: QaMessage['current'] = []): QaAction {
return { type: 'qa', message: message(threads, current) }
}
function run(...actions: QaAction[]): QaState {
@@ -28,7 +28,7 @@ describe('问 AI 的状态', () => {
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '', status: 'running' }])
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
state = qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
@@ -51,7 +51,9 @@ describe('问 AI 的状态', () => {
threads: [],
path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }],
subject: 'folder',
running: [],
current: [],
rewrites: [],
rewriteCurrent: [],
},
})
expect(state.path.map(entry => entry.id)).toEqual(['p'])
@@ -64,7 +66,7 @@ describe('问 AI 的状态', () => {
let state = run(
qa([thread(IF, 'e1')]),
{ type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] },
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } },
{ type: 'answerEnd', end: { askId: 'a1', status: 'done', entryId: 'e1' } },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] },
{ type: 'close', anchor: IF },
)
@@ -76,20 +78,20 @@ describe('问 AI 的状态', () => {
})
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }]
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半', status: 'running' as const }]
let state = run(qa([], running))
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }])
expect(asksOf(state, IF)).toEqual([running[0]])
state = qaReducer(state, qa([], running))
expect(state.asks).toHaveLength(1)
})
it('停止、失败照记;不认识的问题的消息不理', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { type: 'answer', askId: 'a1', text: '一半' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
state = qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status: 'stopped' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { askId: 'nope', status: 'done' } })).toEqual(state)
})
it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => {
@@ -97,12 +99,39 @@ describe('问 AI 的状态', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images })
expect(asksOf(state, IF)[0].images).toEqual(images)
for (const status of ['done', 'failed', 'stopped'] as const)
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status } }), IF)[0].images).toEqual(images)
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '' }]))
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status } }), IF)[0].images).toEqual(images)
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '', status: 'running' }]))
expect(asksOf(state, IF)[0].images).toBeUndefined()
})
})
describe('重写注释的状态:和问 AI 同一套', () => {
const SKILL = '/ecc:writing-code-comments a.py:3-8'
it('页面重新加载后:扩展端还在写的那一轮接上、展开它那块,后面的进展接得上;出错、被叫停留着的照原样画出来', () => {
let state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [
{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' },
{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' },
] })
expect(state.open).toEqual([IF, 'class A'])
expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' }])
expect(asksOf(state, 'class A')).toEqual([{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }])
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '写完了大半', activity: '运行 op export' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '写完了大半', activity: '运行 op export', status: 'running' })
state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' })
state = chatReducer(state, { type: 'sync', available: true, threads: [], current: [{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }] })
expect(state.asks).toHaveLength(2)
})
it('点按钮那一轮开始时还不知道那句技能命令,扩展端开始后报上来就换上', () => {
let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] })
expect(state.open).toEqual([IF])
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' })
})
})
describe('问的时间', () => {
const now = new Date(2026, 8, 26, 15, 0).getTime()
const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string =>
+64 -40
View File
@@ -1,24 +1,24 @@
/**
* 「问 AI」在页面上的状态和规则(不碰 DOM):块下面的问答按块的记号挂;
* 顶部的问答按路径记在 path 上,不挂到任何一块。
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。
* 块下面的对话(问 AI、重写注释)在页面上的状态和规则(不碰 DOM):两种各一份 ChatState,按块的记号挂;
* 问 AI 另有顶部的问答,按路径记在 path 上,不挂到任何一块。
* 这次打开以来说的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。
*/
import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types'
import type { AskRound, ProfileChoice, QaEntryView, QaMessage, QaThreadView, RoundEnd } from './types'
/** 这次打开以来问的一个问题 */
/** 这次打开以来的一轮 */
export interface Ask {
askId: string
anchor: string
question: string
/** 问题带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在答的没有 */
/** 带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在进行的没有 */
images?: string[]
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事:「读取 routes.py」 */
activity?: string
status: 'running' | 'done' | 'failed' | 'stopped'
/** failed 时给人看的原因 */
/** failed 时给人看的原因;重写注释 done 了但没写进文件时也有 */
error?: string
/** done 时存下的那条:历史里不再重复显示它 */
entryId?: string
@@ -27,68 +27,79 @@ export interface Ask {
/** 顶部问答的记号。跟扩展端 pathQa 里的是同一个,块的记号不会是这个。 */
export const PATH_ASK_ANCHOR = '\0path'
export interface QaState {
/** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */
/** 一种块下面的对话的状态 */
export interface ChatState {
/** 文件不在工作区文件夹里时记录没地方存,不给这种对话 */
available: boolean
threads: QaThreadView[]
/** 当前这一轮的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
}
export interface QaState extends ChatState {
/** 这一页顶部按路径记的问答 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
/** 当前这一轮问的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
/** 输入框旁边的 profile 下拉,所有块共用 */
profiles: ProfileChoice
}
export const EMPTY_QA: QaState = {
export const EMPTY_CHAT: ChatState = {
available: false,
threads: [],
path: [],
subject: 'file',
asks: [],
open: [],
}
export const EMPTY_QA: QaState = {
...EMPTY_CHAT,
path: [],
subject: 'file',
profiles: { options: [], selected: '' },
}
export type QaAction
= | { type: 'qa', message: QaMessage }
| { type: 'profiles', profiles: ProfileChoice }
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', end: AnswerEnd }
/**
* sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道)
*/
export type ChatAction
= | { type: 'sync', available: boolean, threads: QaThreadView[], current: AskRound[] }
| { type: 'answer', askId: string, text: string, activity?: string, question?: string }
| { type: 'answerEnd', end: RoundEnd }
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
| { type: 'open', anchor: string }
| { type: 'close', anchor: string }
export type QaAction
= | ChatAction
| { type: 'qa', message: QaMessage }
| { type: 'profiles', profiles: ProfileChoice }
function withOpen(open: string[], anchor: string): string[] {
return open.includes(anchor) ? open : [...open, anchor]
}
export function qaReducer(state: QaState, action: QaAction): QaState {
export function chatReducer<S extends ChatState>(state: S, action: ChatAction): S {
switch (action.type) {
case 'qa': {
// 页面重新加载、或同一文件在别处开着的预览问的:扩展端还在答的接上,展开它那块
case 'sync': {
// 页面重新加载、或同一文件在别处开着的预览开的:扩展端记着的当前一轮接上(还在进行的、出错被叫停留着的),展开它那块
const known = new Set(state.asks.map(ask => ask.askId))
const adopted = action.message.running.filter(ask => !known.has(ask.askId))
const adopted = action.current.filter(round => !known.has(round.askId))
return {
...state,
available: action.message.available,
threads: action.message.threads,
path: action.message.path,
subject: action.message.subject,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
available: action.available,
threads: action.threads,
asks: [...state.asks, ...adopted],
open: adopted.reduce((open, round) => withOpen(open, round.anchor), state.open),
}
}
case 'profiles':
return { ...state, profiles: action.profiles }
case 'answer':
return {
...state,
asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running'
? { ...ask, text: action.text, activity: action.activity }
? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }) }
: ask),
}
case 'answerEnd': {
@@ -109,7 +120,7 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
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'),
@@ -118,16 +129,29 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
}
}
/** 当前这一轮在 anchor 那块问的 */
export function asksOf(state: QaState, anchor: string): Ask[] {
export function qaReducer(state: QaState, action: QaAction): QaState {
switch (action.type) {
case 'qa': {
const { available, threads, current, path, subject } = action.message
return { ...chatReducer(state, { type: 'sync', available, threads, current }), path, subject }
}
case 'profiles':
return { ...state, profiles: action.profiles }
default:
return chatReducer(state, action)
}
}
/** 当前这一轮在 anchor 那块说的 */
export function asksOf(state: ChatState, anchor: string): Ask[] {
return state.asks.filter(ask => ask.anchor === anchor)
}
/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */
export function historyOf(state: QaState, anchor: string): QaEntryView[] {
/** anchor 那块的灰字历史:存下的记录,当前这一轮的那几条除外;顶部问答的在 path 上 */
export function historyOf(state: ChatState & { path?: QaEntryView[] }, anchor: string): QaEntryView[] {
const current = new Set(asksOf(state, anchor).map(ask => ask.entryId))
const entries = anchor === PATH_ASK_ANCHOR
? state.path
? state.path ?? []
: state.threads.find(thread => thread.anchor === anchor)?.entries ?? []
return entries.filter(entry => !current.has(entry.id))
}
@@ -1,38 +0,0 @@
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)
})
})
@@ -1,58 +0,0 @@
/** 一段注释正在按规范重写、或没写成时留在页面上的过程。只在这一页上,刷新或关掉预览就没了,不写入问答。 */
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
}
+40 -18
View File
@@ -258,7 +258,7 @@ export interface ShownName {
definition?: true
}
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的 */
/** 问过、重写过的一次:说的话、回答(Markdown;重写记录是交回的注释正文,没交回时是空的)、什么时候(ISO 时间)、用哪个 profile 答的 */
export interface QaEntryView {
id: string
question: string
@@ -266,8 +266,10 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,能直接放进 img 的地址;没带图片时没有 */
/** 带的图片,能直接放进 img 的地址;没带图片时没有 */
images?: string[]
/** 重写记录才有:没写进文件的原因 */
error?: string
}
/** 问题带的一张图片,发给扩展端的样子:格式(`image/png` 等)和不带 `data:` 前缀的 base64 */
@@ -282,18 +284,23 @@ export interface QaThreadView {
entries: QaEntryView[]
}
/** 扩展端正在答的一个问题:text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」) */
export interface RunningAsk {
/**
* 扩展端记着的当前一轮:还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」),
* error 是 failed 时给人看的原因
*/
export interface AskRound {
askId: string
anchor: string
question: string
text: string
activity?: string
status: 'running' | 'failed' | 'stopped'
error?: string
}
/**
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
* running 是扩展端正在答的问题(页面重新加载后靠它接上)
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
* current、rewriteCurrent 是扩展端记着的当前一轮(页面重新加载后靠它们接上)
*/
export interface QaMessage {
type: 'qa'
@@ -303,7 +310,10 @@ export interface QaMessage {
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
current: AskRound[]
/** 块下面的重写记录,按块的记号 */
rewrites: QaThreadView[]
rewriteCurrent: AskRound[]
}
/** 「问 AI」用的 Claude Code profile 的下拉一项:id 是 profile 名;「默认」(不带 `--settings`)的 id 是 '' */
@@ -319,11 +329,10 @@ export interface ProfileChoice {
}
/**
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,
* 一轮怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,重写注释没写进文件时还带原因(error);
* failed 带给人看的原因,stopped 是被叫停、没存
*/
export interface AnswerEnd {
type: 'answerEnd'
export interface RoundEnd {
askId: string
status: 'done' | 'failed' | 'stopped'
text?: string
@@ -331,6 +340,14 @@ export interface AnswerEnd {
entryId?: string
}
export interface AnswerEnd extends RoundEnd {
type: 'answerEnd'
}
export interface RewriteEnd extends RoundEnd {
type: 'rewriteEnd'
}
/** 一截是新加的还是删掉的;没变的不写 */
export type PartChange = 'add' | 'del'
@@ -443,8 +460,8 @@ export interface StickyScrollSettings {
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
* rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么
* rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
@@ -510,8 +527,8 @@ export type ExtensionToWebview
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
| AnswerEnd
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
| RewriteEnd
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path?: string, symbol?: string }
@@ -534,8 +551,11 @@ export type ExtensionToWebview
* setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 回来,不写入问答
* stopRewrite:叫停 anchor 这段正在进行的重写,不写文件
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由这边起,过程用 rewriteProgress / rewriteEnd 回来。
* 没有 request 是点了按钮:发技能命令、开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片
* stopRewrite:叫停 askId 那一轮重写,不写文件、不记
* deleteRewrite:删掉 anchor 那块下面的一条重写记录
* forgetRound:收起了 anchor 那块的问答(ask)或重写(rewrite),扩展端去掉这块留着的那一轮(出错、被叫停的),重新加载后不再画出来
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
@@ -552,8 +572,10 @@ export type WebviewToExtension
| { type: 'setFilter', excluded: Excluded }
| { type: 'setOnlyChanges', on: boolean }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'rewriteComment', anchor: string }
| { type: 'stopRewrite', anchor: string }
| { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: AskImage[] }
| { type: 'stopRewrite', askId: string }
| { type: 'deleteRewrite', anchor: string, entryId: string }
| { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
+20 -13
View File
@@ -49,17 +49,24 @@ export function useQa(): QaTree {
}
}, [])
return useMemo((): QaTree => ({
state,
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
onClose: anchor => dispatch({ type: 'close', anchor }),
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }),
}), [state])
return useMemo((): QaTree => {
/** 收起就结束这一轮,扩展端留着的那一轮(出错、被叫停的)也去掉 */
const close = (anchor: string): void => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'ask', anchor })
}
return {
state,
onToggle: anchor => current.current.open.includes(anchor) ? close(anchor) : dispatch({ type: 'open', anchor }),
onClose: close,
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }),
}
}, [state])
}
+55 -37
View File
@@ -1,43 +1,42 @@
import type { RewriteRuns } from './rewriteRun'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun'
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 { toAskImage } from './qaImages'
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
/**
* 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套:
* 进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。
*/
export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string) => void): RewriteTree {
const [state, dispatch] = useReducer(chatReducer<ChatState>, EMPTY_CHAT)
useEffect(() => {
/** 攒着没画的进展:anchor → 最新的一份 */
/** 攒着没画的进展:askId → 最新的一份 */
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
})
for (const [askId, progress] of pending)
dispatch({ type: 'answer', askId, ...progress })
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) {
switch (msg.type) {
case 'qa':
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 }) })
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
return
case 'rewriteEnd':
clearTimeout(timer)
timer = undefined
}
setRuns(prev => rewriteEnd(prev, msg.anchor, msg.status, msg.error))
flush()
dispatch({ type: 'answerEnd', end: msg })
}
})
return () => {
@@ -46,14 +45,33 @@ export function useRewrite(): { runs: RewriteRuns, onRewrite: (anchor: string) =
}
}, [])
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])
/** 动作里读眼下的状态(这块是不是已经在写) */
const current = useRef(state)
current.current = state
return useMemo((): RewriteTree => ({
state,
profiles,
onSelectProfile,
onRewrite: (anchor) => {
if (current.current.asks.some(ask => ask.anchor === anchor && ask.status === 'running'))
return
const askId = newAskId()
// 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来
dispatch({ type: 'ask', askId, anchor, question: '', images: [] })
postMessage({ type: 'rewriteComment', askId, anchor, images: [] })
},
onAsk: (anchor, request, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question: request, images: images.map(image => image.url) })
postMessage({ type: 'rewriteComment', askId, anchor, request, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopRewrite', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteRewrite', anchor, entryId }),
onOpen: anchor => dispatch({ type: 'open', anchor }),
onClose: (anchor) => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'rewrite', anchor })
},
}), [state, profiles, onSelectProfile])
}