✨ feat(vscode): 问 AI 输入框旁边能选 Claude profile,记住上次选的,问答灰字写用哪个 profile 答的

This commit is contained in:
cruldra committed 2026-09-26 22:28:29 +08:00
1 parent 1eb42d8389
commit 9efae1f496
20 files changed
+397 -63

No files matched your search

@@ -1,6 +1,6 @@
import type { AskThreadProps } from './AskThread'
import type { Ask } from './qa'
import type { QaEntryView } from './types'
import type { ProfileChoice, QaEntryView } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AskThread } from './AskThread'
@@ -12,8 +12,10 @@ const HISTORY: QaEntryView[] = [
{ id: 'e2', question: 'UserRole 还有哪些角色?', answer: '只有 ADMIN 和 USER。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() },
]
const PROFILES: ProfileChoice = { options: [{ id: 'offical', label: 'offical' }, { id: 'sonnet', label: 'sonnet' }], selected: 'sonnet' }
function render(props: Partial<AskThreadProps>): string {
return renderToStaticMarkup(<AskThread history={[]} asks={[]} open={false} now={NOW} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />)
return renderToStaticMarkup(<AskThread history={[]} asks={[]} open={false} profiles={PROFILES} now={NOW} onSelectProfile={() => {}} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />)
}
const text = (html: string): string => html.replace(/<[^>]*>/g, '')
@@ -67,4 +69,27 @@ describe('块下面的问答区', () => {
expect(text(render({ open: true, asks: [ask({ status: 'stopped', text: '一半' })] }))).toContain('已停止,这次没存。')
expect(text(render({ open: true, asks: [ask({})] }))).toContain('正在想…')
})
it('输入框右边、发送按钮前面是 profile 下拉:显示选中的名字,键盘能打开(按钮、弹出列表);没收到选项时写「默认」', () => {
const html = render({ open: true })
const row = html.slice(html.indexOf('<div class="cr-qa-input">'))
const [textarea, select, send] = ['<textarea', 'cr-qa-profile', 'aria-label="发送"'].map(mark => row.indexOf(mark))
expect(textarea).toBeGreaterThanOrEqual(0)
expect(select).toBeGreaterThan(textarea)
expect(send).toBeGreaterThan(select)
const trigger = row.slice(row.lastIndexOf('<button', select), row.indexOf('</button>', select))
expect(trigger).toContain('aria-haspopup="listbox"')
expect(trigger).toContain('aria-expanded="false"')
expect(trigger).toContain('title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"')
expect(text(trigger)).toBe('sonnet')
const none = render({ open: true, profiles: { options: [], selected: '' } })
expect(text(none.slice(none.indexOf('cr-qa-profile')))).toContain('默认')
})
it('历史每条的灰字写时间和用哪个 profile 答的;以前存的没有 profile 只写时间', () => {
const html = render({ open: true, history: [{ ...HISTORY[0]!, profile: 'sonnet' }, HISTORY[1]!] })
expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前 · sonnet')
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
expect(text(html)).not.toContain('昨天 ·')
})
})
+26 -6
View File
@@ -1,8 +1,9 @@
import type { KeyboardEvent, ReactElement } from 'react'
import type { Ask } from './qa'
import type { QaEntryView } from './types'
import type { ProfileChoice, QaEntryView } from './types'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react'
import { useState } from 'react'
import { SelectMenu } from '@/components/ui/select-menu'
import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime } from './qa'
@@ -17,9 +18,12 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number
<button type="button" className="cr-qa-question" aria-expanded={expanded} title={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
{entry.question}
</button>
<time className="cr-qa-when" dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
{relativeTime(entry.createdAt, now)}
</time>
<span className="cr-qa-when">
<time dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
{relativeTime(entry.createdAt, now)}
</time>
{entry.profile && <span title="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
</span>
<button type="button" className="cr-tool cr-qa-delete" title="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
@@ -69,10 +73,12 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */
function AskInput({ running, first, onAsk, onStop, onClose }: {
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
first: boolean
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
onStop: (askId: string) => void
onClose: () => void
@@ -107,6 +113,15 @@ function AskInput({ running, first, onAsk, onStop, onClose }: {
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
@@ -127,7 +142,10 @@ export interface AskThreadProps {
asks: Ask[]
/** 点了「问 AI」:展开全部历史、当前这一轮和输入框 */
open: boolean
/** 输入框旁边的 profile 下拉 */
profiles: ProfileChoice
now?: number
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
@@ -138,7 +156,7 @@ export interface AskThreadProps {
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块只露一行「问过 N 次」,点开看灰字历史,读代码时一眼知道这里问过什么。
*/
export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(false)
if (!open) {
if (history.length === 0)
@@ -163,6 +181,8 @@ export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
profiles={profiles}
onSelectProfile={onSelectProfile}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
@@ -40,6 +40,8 @@ export interface QaTree {
onAsk: (anchor: string, question: string) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
/** 换「问 AI」用的 profile(所有块共用),id 同 ProfileOption.id */
onSelectProfile: (id: string) => void
}
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
@@ -97,6 +99,8 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
history={historyOf(qa.state, anchor)}
asks={asksOf(qa.state, anchor)}
open={qa.state.open.includes(anchor)}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={question => qa.onAsk(anchor, question)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(anchor, entryId)}
@@ -661,6 +661,28 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
.cr-qa-input .cr-tool:disabled {
opacity: 0.4;
}
/* 发送按钮前面的 profile 下拉:只占名字那么宽,长名字截断,输入框还是主角;字号同弹出的选项(弹层按它量宽) */
.cr-qa-input .cr-qa-profile {
height: 20px;
max-width: 140px;
flex-shrink: 0;
padding: 0 4px 0 6px;
border-radius: 4px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.cr-qa-input .cr-qa-profile:hover {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-foreground);
}
.cr-qa-input .cr-qa-profile[aria-expanded="true"] {
background: var(--vscode-toolbar-activeBackground);
color: var(--vscode-foreground);
}
.cr-qa-input .cr-qa-profile:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.cr-qa-summary {
display: inline-flex;
width: fit-content;
@@ -34,6 +34,14 @@ describe('问 AI 的状态', () => {
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
})
it('profile 下拉:扩展端发来什么就显示什么,问答刷新不丢', () => {
const profiles = { options: [{ id: '', label: '默认' }, { id: 'sonnet', label: 'sonnet' }], selected: 'sonnet' }
expect(EMPTY_QA.profiles).toEqual({ options: [], selected: '' })
const state = run({ type: 'profiles', profiles }, qa([thread(IF, 'e1')]))
expect(state.profiles).toEqual(profiles)
expect(state.threads).toHaveLength(1)
})
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
+8 -2
View File
@@ -3,7 +3,7 @@
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答不显示。
*/
import type { AnswerEnd, QaEntryView, QaMessage, QaThreadView } from './types'
import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types'
/** 这次打开以来问的一个问题 */
export interface Ask {
@@ -29,12 +29,15 @@ export interface QaState {
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
/** 输入框旁边的 profile 下拉,所有块共用 */
profiles: ProfileChoice
}
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [] }
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [], 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 }
| { type: 'ask', askId: string, anchor: string, question: string }
@@ -52,12 +55,15 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
const known = new Set(state.asks.map(ask => ask.askId))
const adopted = action.message.running.filter(ask => !known.has(ask.askId))
return {
...state,
available: action.message.available,
threads: action.message.threads,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
}
}
case 'profiles':
return { ...state, profiles: action.profiles }
case 'answer':
return {
...state,
+20 -2
View File
@@ -156,12 +156,14 @@ export interface DocumentMessage {
reveal?: Reveal
}
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间) */
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的 */
export interface QaEntryView {
id: string
question: string
answer: string
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */
@@ -190,6 +192,18 @@ export interface QaMessage {
running: RunningAsk[]
}
/** 「问 AI」用的 Claude Code profile 的下拉一项:id 是 profile 名;「默认」(不带 `--settings`)的 id 是 '' */
export interface ProfileOption {
id: string
label: string
}
/** 「问 AI」输入框旁边的 profile 下拉:选项和选中的那项,所有预览共用、扩展端记着 */
export interface ProfileChoice {
options: ProfileOption[]
selected: string
}
/**
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,
* failed 带给人看的原因,stopped 是被叫停、没存
@@ -209,6 +223,7 @@ export interface AnswerEnd {
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
*/
@@ -219,6 +234,7 @@ export type ExtensionToWebview
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'filter', excluded: Excluded }
| QaMessage
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
| AnswerEnd
@@ -230,9 +246,10 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -246,3 +263,4 @@ export type WebviewToExtension
| { type: 'ask', askId: string, anchor: string, question: string }
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
@@ -29,6 +29,9 @@ export function useQa(): QaTree {
case 'qa':
dispatch({ type: 'qa', message: msg })
return
case 'askProfiles':
dispatch({ type: 'profiles', profiles: { options: msg.options, selected: msg.selected } })
return
case 'answer':
pending.set(msg.askId, { text: msg.text, activity: msg.activity })
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
@@ -56,5 +59,6 @@ export function useQa(): QaTree {
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }),
}), [state])
}