✨ feat(vscode): 问 AI 输入框旁边能选 Claude profile,记住上次选的,问答灰字写用哪个 profile 答的
This commit is contained in:
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('昨天 ·')
|
||||
})
|
||||
})
|
||||
@@ -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')]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user