✨ feat(vscode): 重写注释排成一队,整个扩展一次只跑一段
OpenPencil 同一时间只能开一个画板,两段同时画会互相掐断。所有预览、所有文件的重写(第一轮和接着说)排成一队,前一段写成、出错、被叫停后按先后自动开始下一段;问 AI 照旧最多同时 3 个。 排着的那一轮记在扩展端(rewriteCurrent 的 queued),块下面写「排队中」、能取消排队,切标签页、页面重新加载后照样显示。
This commit is contained in:
1 parent
b7c29c60e1
commit
aecc4d1ab5
15 files changed
+329
-114
No files matched your search
@@ -149,4 +149,13 @@ describe('块下面的重写记录(样子和问答一样)', () => {
|
||||
expect(written).not.toContain('role="alert"')
|
||||
expect(text(render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'stopped' })] }))).toContain('已停止,这次没存。')
|
||||
})
|
||||
|
||||
it('排着队:这一轮写着「排队中」、不转圈;输入框旁边的按钮是「取消排队」,点了按叫停发出去', () => {
|
||||
const html = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ question: '/ecc:writing-code-comments web/a.tsx:3-40', status: 'queued' })] })
|
||||
expect(text(html)).toContain('排队中,前面的写完就开始')
|
||||
expect(html).not.toContain('cr-qa-spin')
|
||||
expect(html).toContain('data-tooltip="取消排队" aria-label="取消排队"')
|
||||
expect(html).not.toContain('aria-label="停止"')
|
||||
expect(html).not.toContain('aria-label="发送"')
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,12 @@ import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'rea
|
||||
import type { Ask } from './qa'
|
||||
import type { PendingImage } from './qaImages'
|
||||
import type { ProfileChoice, QaEntryView } from './types'
|
||||
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, CircleStop, Hourglass, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { SelectMenu } from '@/components/ui/select-menu'
|
||||
import { renderMarkdown, renderMarkdownOnce } from './markdown'
|
||||
import { answerPreview, relativeTime } from './qa'
|
||||
import { answerPreview, relativeTime, unfinished } from './qa'
|
||||
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
|
||||
|
||||
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */
|
||||
@@ -140,7 +140,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
|
||||
)
|
||||
}
|
||||
|
||||
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
|
||||
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
|
||||
function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement {
|
||||
const running = ask.status === 'running'
|
||||
const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering)
|
||||
@@ -165,7 +165,13 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{ask.error !== undefined && ask.status !== 'running' && (
|
||||
{ask.status === 'queued' && (
|
||||
<div className="cr-qa-status" role="status">
|
||||
<Hourglass size={12} strokeWidth={1.8} aria-hidden />
|
||||
排队中,前面的写完就开始
|
||||
</div>
|
||||
)}
|
||||
{ask.error !== undefined && !unfinished(ask) && (
|
||||
<div className="cr-qa-error" role="alert">
|
||||
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
|
||||
<span>{ask.error}</span>
|
||||
@@ -176,9 +182,10 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW
|
||||
)
|
||||
}
|
||||
|
||||
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
|
||||
function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
|
||||
running: Ask | undefined
|
||||
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止,排着队时换成取消排队。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
|
||||
function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
|
||||
/** 正在答或排着队的那一轮 */
|
||||
active: Ask | undefined
|
||||
/** 输入框里的提示(后面接着怎么发送、收起) */
|
||||
placeholder: string
|
||||
/** 输入框读屏用的名字 */
|
||||
@@ -197,6 +204,7 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs
|
||||
focusToken: number
|
||||
}): ReactElement {
|
||||
const [value, setValue] = useState('')
|
||||
const stopLabel = active?.status === 'queued' ? '取消排队' : '停止'
|
||||
const [images, setImages] = useState<PendingImage[]>([])
|
||||
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
|
||||
const [notice, setNotice] = useState<string>()
|
||||
@@ -221,7 +229,7 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs
|
||||
}
|
||||
const send = (): void => {
|
||||
const question = value.trim()
|
||||
if (question === '' || running)
|
||||
if (question === '' || active)
|
||||
return
|
||||
onAsk(question, images)
|
||||
setValue('')
|
||||
@@ -282,9 +290,9 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs
|
||||
tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
|
||||
className="cr-qa-profile"
|
||||
/>
|
||||
{running
|
||||
{active
|
||||
? (
|
||||
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
|
||||
<button type="button" className="cr-tool" data-tooltip={stopLabel} aria-label={stopLabel} onClick={() => onStop(active.askId)}>
|
||||
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)
|
||||
@@ -357,7 +365,7 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
|
||||
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
|
||||
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
|
||||
<AskInput
|
||||
running={asks.find(ask => ask.status === 'running')}
|
||||
active={asks.find(unfinished)}
|
||||
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
|
||||
label={wording.inputLabel}
|
||||
profiles={profiles}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BlockTreeState, RewriteTree } from './BlockTree'
|
||||
import type { ChatState } from './qa'
|
||||
import type { Ask, ChatState } from './qa'
|
||||
import type { BlockView, KindBadge } from './types'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -215,6 +215,18 @@ describe('「注释」一行和「重写注释」按钮', () => {
|
||||
expect(opened).toContain('aria-label="对这段注释的修改意见"')
|
||||
})
|
||||
|
||||
it('展开着又在写或排着队时按钮不能点;出错、停下的不挡', () => {
|
||||
const block = view('6', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
|
||||
const button = (status: Ask['status']): string => {
|
||||
const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] }
|
||||
const markup = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), rewrite)} />)
|
||||
return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup)?.[0] ?? ''
|
||||
}
|
||||
expect(button('running')).toContain('disabled')
|
||||
expect(button('queued')).toContain('disabled')
|
||||
expect(button('stopped')).not.toContain('disabled')
|
||||
})
|
||||
|
||||
it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => {
|
||||
inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释')
|
||||
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DiffText } from './DiffText'
|
||||
import { removedTitle } from './diffView'
|
||||
import { KindIcon } from './kindIcons'
|
||||
import { PropertyGrid, PropertyTable } from './PropertyGrid'
|
||||
import { asksOf, historyOf } from './qa'
|
||||
import { asksOf, historyOf, unfinished } from './qa'
|
||||
import { TextPreview } from './TextPreview'
|
||||
import { tableId } from './viewState'
|
||||
|
||||
@@ -272,7 +272,7 @@ function callTreeTool(block: BlockView, tree: BlockTreeState) {
|
||||
|
||||
/**
|
||||
* 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。
|
||||
* 展开着又正在写时不能再点。
|
||||
* 展开着又正在写、排着队时不能再点。
|
||||
*/
|
||||
function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined {
|
||||
const { edit, rewrite } = tree
|
||||
@@ -284,7 +284,7 @@ function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | unde
|
||||
const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0)
|
||||
return {
|
||||
fresh: block.commentTarget.kind === 'insert',
|
||||
rewriting: open && asks.some(ask => ask.status === 'running'),
|
||||
rewriting: open && asks.some(unfinished),
|
||||
reopen,
|
||||
onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor),
|
||||
}
|
||||
|
||||
@@ -130,6 +130,25 @@ describe('重写注释的状态:和问 AI 同一套', () => {
|
||||
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
|
||||
expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' })
|
||||
})
|
||||
|
||||
it('扩展端说排着队就显示排队中,轮到了变成在写;收起这块时排着的和在写的一样留着;取消了照停下记', () => {
|
||||
let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] })
|
||||
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '', queued: true })
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, images: [], text: '', status: 'queued' }])
|
||||
state = chatReducer(state, { type: 'close', anchor: IF })
|
||||
expect(asksOf(state, IF).map(ask => ask.status)).toEqual(['queued'])
|
||||
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
|
||||
expect(asksOf(state, IF)[0].status).toBe('running')
|
||||
state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'stopped' } })
|
||||
expect(asksOf(state, IF)[0].status).toBe('stopped')
|
||||
expect(chatReducer(state, { type: 'answer', askId: 'r1', text: '晚到的', queued: true })).toEqual(state)
|
||||
})
|
||||
|
||||
it('页面重新加载后:扩展端排着队的那一轮照样显示排队中、展开它那块', () => {
|
||||
const state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }] })
|
||||
expect(state.open).toEqual([IF])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('问的时间', () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface Ask {
|
||||
text: string
|
||||
/** 正在做的事:「读取 routes.py」 */
|
||||
activity?: string
|
||||
status: 'running' | 'done' | 'failed' | 'stopped'
|
||||
/** queued 是重写注释排着队,前面的写完才开始 */
|
||||
status: 'queued' | 'running' | 'done' | 'failed' | 'stopped'
|
||||
/** failed 时给人看的原因;重写注释 done 了但没写进文件时也有 */
|
||||
error?: string
|
||||
/** done 时存下的那条:历史里不再重复显示它 */
|
||||
@@ -62,11 +63,11 @@ export const EMPTY_QA: QaState = {
|
||||
}
|
||||
|
||||
/**
|
||||
* sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道)
|
||||
* sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了、排上队了(queued)或轮到了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道)
|
||||
*/
|
||||
export type ChatAction
|
||||
= | { type: 'sync', available: boolean, threads: QaThreadView[], current: AskRound[] }
|
||||
| { type: 'answer', askId: string, text: string, activity?: string, question?: string }
|
||||
| { type: 'answer', askId: string, text: string, activity?: string, question?: string, queued?: boolean }
|
||||
| { type: 'answerEnd', end: RoundEnd }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
|
||||
| { type: 'open', anchor: string }
|
||||
@@ -98,8 +99,8 @@ export function chatReducer<S extends ChatState>(state: S, action: ChatAction):
|
||||
case 'answer':
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running'
|
||||
? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }) }
|
||||
asks: state.asks.map(ask => ask.askId === action.askId && unfinished(ask)
|
||||
? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }), status: action.queued ? 'queued' : 'running' }
|
||||
: ask),
|
||||
}
|
||||
case 'answerEnd': {
|
||||
@@ -120,10 +121,10 @@ export function chatReducer<S extends ChatState>(state: S, action: ChatAction):
|
||||
case 'open':
|
||||
return { ...state, open: withOpen(state.open, action.anchor) }
|
||||
case 'close':
|
||||
// 收起就结束这一轮:答完的都回到灰字历史里;还在进行的留着,答完照样存
|
||||
// 收起就结束这一轮:答完的都回到灰字历史里;还在进行、排着队的留着,答完照样存
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.filter(ask => ask.anchor !== action.anchor || ask.status === 'running'),
|
||||
asks: state.asks.filter(ask => ask.anchor !== action.anchor || unfinished(ask)),
|
||||
open: state.open.filter(anchor => anchor !== action.anchor),
|
||||
}
|
||||
}
|
||||
@@ -142,6 +143,11 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
|
||||
}
|
||||
}
|
||||
|
||||
/** 这一轮还没结束:在进行,或排着队 */
|
||||
export function unfinished(ask: Pick<Ask, 'status'>): boolean {
|
||||
return ask.status === 'running' || ask.status === 'queued'
|
||||
}
|
||||
|
||||
/** 当前这一轮在 anchor 那块说的 */
|
||||
export function asksOf(state: ChatState, anchor: string): Ask[] {
|
||||
return state.asks.filter(ask => ask.anchor === anchor)
|
||||
|
||||
@@ -285,7 +285,7 @@ export interface QaThreadView {
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展端记着的当前一轮:还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」),
|
||||
* 扩展端记着的当前一轮:排着队的(重写注释一次只跑一段)、还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」),
|
||||
* error 是 failed 时给人看的原因
|
||||
*/
|
||||
export interface AskRound {
|
||||
@@ -294,7 +294,7 @@ export interface AskRound {
|
||||
question: string
|
||||
text: string
|
||||
activity?: string
|
||||
status: 'running' | 'failed' | 'stopped'
|
||||
status: 'queued' | 'running' | 'failed' | 'stopped'
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ export interface StickyScrollSettings {
|
||||
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了,见 AnswerEnd
|
||||
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
* rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
* rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
|
||||
@@ -527,7 +527,7 @@ export type ExtensionToWebview
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| AnswerEnd
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
|
||||
| RewriteEnd
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
|
||||
|
||||
@@ -2,21 +2,21 @@ import type { RewriteTree } from './BlockTree'
|
||||
import type { ChatState } from './qa'
|
||||
import type { ProfileChoice } from './types'
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import { chatReducer, EMPTY_CHAT, newAskId } from './qa'
|
||||
import { chatReducer, EMPTY_CHAT, newAskId, unfinished } from './qa'
|
||||
import { toAskImage } from './qaImages'
|
||||
import { ANSWER_FRAME_MS } from './useQa'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/**
|
||||
* 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套:
|
||||
* 进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。
|
||||
* 排着队的、进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。
|
||||
*/
|
||||
export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string) => void): RewriteTree {
|
||||
const [state, dispatch] = useReducer(chatReducer<ChatState>, EMPTY_CHAT)
|
||||
|
||||
useEffect(() => {
|
||||
/** 攒着没画的进展:askId → 最新的一份 */
|
||||
const pending = new Map<string, { question: string, text: string, activity?: string }>()
|
||||
const pending = new Map<string, { question: string, text: string, activity?: string, queued?: boolean }>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const flush = (): void => {
|
||||
timer = undefined
|
||||
@@ -30,7 +30,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string
|
||||
dispatch({ type: 'sync', available: msg.available, threads: msg.rewrites, current: msg.rewriteCurrent })
|
||||
return
|
||||
case 'rewriteProgress':
|
||||
pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) })
|
||||
pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }), ...(msg.queued && { queued: true }) })
|
||||
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
|
||||
return
|
||||
case 'rewriteEnd':
|
||||
@@ -45,7 +45,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 动作里读眼下的状态(这块是不是已经在写) */
|
||||
/** 动作里读眼下的状态(这块是不是已经在写或排着队) */
|
||||
const current = useRef(state)
|
||||
current.current = state
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string
|
||||
profiles,
|
||||
onSelectProfile,
|
||||
onRewrite: (anchor) => {
|
||||
if (current.current.asks.some(ask => ask.anchor === anchor && ask.status === 'running'))
|
||||
if (current.current.asks.some(ask => ask.anchor === anchor && unfinished(ask)))
|
||||
return
|
||||
const askId = newAskId()
|
||||
// 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来
|
||||
|
||||
Reference in new issue
Block a user