✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。 重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。 进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
1 parent
ae4e2160ef
commit
c9f60f5b30
32 files changed
+1654
-860
No files matched your search
@@ -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', 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('已停止,这次没存。')
|
||||
})
|
||||
})
|
||||
@@ -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('按注释规范')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user