fix(desktop): open model selector for slash command (#909)

Fixes #909.

Tested:
- cd desktop && bun run test -- src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx -t "/model"
- cd desktop && bun run test -- src/components/chat/composerUtils.test.ts src/components/controls/ModelSelector.test.tsx src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx
- cd desktop && bun run build
- git diff --check

Not-tested:
- Full bun run check:desktop remains blocked by an unrelated MessageList timestamp assertion and existing TabBar AbortSignal rejections.

Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:00:26 +08:00
parent 7b2477f492
commit a169cba613
7 changed files with 132 additions and 18 deletions

View File

@ -69,9 +69,21 @@ vi.mock('../controls/PermissionModeSelector', () => ({
PermissionModeSelector: () => <button type="button">Permissions</button>, PermissionModeSelector: () => <button type="button">Permissions</button>,
})) }))
vi.mock('../controls/ModelSelector', () => ({ vi.mock('../controls/ModelSelector', async () => {
ModelSelector: () => <button type="button">Model</button>, const React = await vi.importActual<typeof import('react')>('react')
})) return {
ModelSelector: React.forwardRef<{ open: () => void }, Record<string, never>>((_props, ref) => {
const [open, setOpen] = React.useState(false)
React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), [])
return (
<>
<button type="button">Model</button>
{open && <div data-testid="model-selector-dropdown">Model selector opened</div>}
</>
)
}),
}
})
import { ChatInput } from './ChatInput' import { ChatInput } from './ChatInput'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
@ -1335,6 +1347,32 @@ describe('ChatInput file mentions', () => {
}) })
}) })
it('opens the model selector for /model without sending a user message', async () => {
useSettingsStore.setState({
chatSendBehavior: 'enter',
})
render(<ChatInput />)
await waitFor(() => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/model',
selectionStart: 6,
},
})
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByTestId('model-selector-dropdown')).toHaveTextContent('Model selector opened')
expect(input).toHaveValue('')
})
it('prioritizes active-session slash commands by command name when filtering', async () => { it('prioritizes active-session slash commands by command name when filtering', async () => {
useChatStore.setState({ useChatStore.setState({
sessions: { sessions: {

View File

@ -15,7 +15,7 @@ import {
import { sessionsApi, type SessionGitInfo } from '../../api/sessions' import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
import { agentsApi } from '../../api/agents' import { agentsApi } from '../../api/agents'
import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector' import { ModelSelector, type ModelSelectorHandle } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat' import type { AttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery' import { AttachmentGallery } from './AttachmentGallery'
import { ComposerDropOverlay } from './ComposerDropOverlay' import { ComposerDropOverlay } from './ComposerDropOverlay'
@ -109,6 +109,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
const plusMenuRef = useRef<HTMLDivElement>(null) const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null) const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null) const fileSearchRef = useRef<FileSearchMenuHandle>(null)
@ -628,6 +629,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
return return
} }
if (pendingSlashUiAction?.type === 'model') {
modelSelectorRef.current?.open()
setComposerInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
if (showLaunchControls && (!launchReady || launchTransitioning)) return if (showLaunchControls && (!launchReady || launchTransitioning)) return
const workspaceReferencePrompt = !isMemberSession const workspaceReferencePrompt = !isMemberSession
@ -1296,7 +1306,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
/> />
)} )}
{!isMemberSession && activeTabId && ( {!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={useCompactControls} /> <ModelSelector ref={modelSelectorRef} runtimeKey={activeTabId} disabled={isActive} compact={useCompactControls} />
)} )}
<button <button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit} onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}

View File

@ -180,6 +180,10 @@ describe('composerUtils', () => {
expect(resolveSlashUiAction('status')).toEqual({ type: 'panel', command: 'status' }) expect(resolveSlashUiAction('status')).toEqual({ type: 'panel', command: 'status' })
}) })
it('routes /model to the local model selector action', () => {
expect(resolveSlashUiAction('model')).toEqual({ type: 'model' })
})
it('falls back to the static English description when a translation key is missing', () => { it('falls back to the static English description when a translation key is missing', () => {
// Simulate an i18n t() function that returns the raw key for missing entries // Simulate an i18n t() function that returns the raw key for missing entries
// (this is what the real translate() does via zh[key] ?? en[key] ?? key). // (this is what the real translate() does via zh[key] ?? en[key] ?? key).

View File

@ -173,6 +173,9 @@ export type SlashUiAction =
type: 'settings' type: 'settings'
tab: SettingsTab tab: SettingsTab
} }
| {
type: 'model'
}
export function resolveSlashUiAction(value: string): SlashUiAction | null { export function resolveSlashUiAction(value: string): SlashUiAction | null {
const normalizedValue = SLASH_COMMAND_ALIASES.find((alias) => alias.name === value)?.target ?? value const normalizedValue = SLASH_COMMAND_ALIASES.find((alias) => alias.name === value)?.target ?? value
@ -186,6 +189,10 @@ export function resolveSlashUiAction(value: string): SlashUiAction | null {
return { type: 'settings', tab: settingsCommand.tab } return { type: 'settings', tab: settingsCommand.tab }
} }
if (normalizedValue === 'model') {
return { type: 'model' }
}
return null return null
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog'
import { import {
@ -37,6 +37,10 @@ type Props = {
compact?: boolean compact?: boolean
} }
export type ModelSelectorHandle = {
open: () => void
}
type DropdownPosition = { type DropdownPosition = {
top: number | undefined top: number | undefined
bottom: number | undefined bottom: number | undefined
@ -164,7 +168,7 @@ function resolveDefaultRuntimeSelection(
} }
} }
export function ModelSelector({ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function ModelSelector({
value, value,
onChange, onChange,
runtimeSelection: controlledRuntimeSelection, runtimeSelection: controlledRuntimeSelection,
@ -172,7 +176,7 @@ export function ModelSelector({
runtimeKey, runtimeKey,
disabled = false, disabled = false,
compact = false, compact = false,
}: Props = {}) { }: Props = {}, selectorRef) {
const t = useTranslation() const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime() const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const { const {
@ -228,6 +232,14 @@ export function ModelSelector({
void fetchOpenAIOAuthStatus() void fetchOpenAIOAuthStatus()
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open]) }, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
const openSelector = useCallback(() => {
if (!disabled) setOpen(true)
}, [disabled])
useImperativeHandle(selectorRef, () => ({
open: openSelector,
}), [openSelector])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
@ -587,4 +599,4 @@ export function ModelSelector({
{dropdown} {dropdown}
</div> </div>
) )
} })

View File

@ -110,13 +110,23 @@ vi.mock('../components/controls/PermissionModeSelector', () => ({
), ),
})) }))
vi.mock('../components/controls/ModelSelector', () => ({ vi.mock('../components/controls/ModelSelector', async () => {
ModelSelector: ({ compact }: { compact?: boolean }) => ( const React = await vi.importActual<typeof import('react')>('react')
<button type="button" data-testid="model-selector" data-compact={compact ? 'true' : 'false'}> return {
Model ModelSelector: React.forwardRef<{ open: () => void }, { compact?: boolean }>(({ compact }, ref) => {
</button> const [open, setOpen] = React.useState(false)
), React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), [])
})) return (
<>
<button type="button" data-testid="model-selector" data-compact={compact ? 'true' : 'false'}>
Model
</button>
{open && <div data-testid="model-selector-dropdown">Model selector opened</div>}
</>
)
}),
}
})
import { EmptySession } from './EmptySession' import { EmptySession } from './EmptySession'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
@ -353,6 +363,29 @@ describe('EmptySession', () => {
expect(input).toHaveValue('/agent debugger ') expect(input).toHaveValue('/agent debugger ')
}) })
it('opens the draft model selector for /model without creating or sending a session', async () => {
useSettingsStore.setState({
chatSendBehavior: 'enter',
})
render(<EmptySession />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/model',
selectionStart: 6,
},
})
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByTestId('model-selector-dropdown')).toHaveTextContent('Model selector opened')
expect(input).toHaveValue('')
})
it('selects a highlighted agent entry from /agent without creating a session', async () => { it('selects a highlighted agent entry from /agent without creating a session', async () => {
useSettingsStore.setState({ useSettingsStore.setState({
chatSendBehavior: 'enter', chatSendBehavior: 'enter',

View File

@ -12,7 +12,7 @@ import { useUIStore } from '../stores/uiStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls' import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector' import { ModelSelector, type ModelSelectorHandle } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery' import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import { ComposerDropOverlay } from '../components/chat/ComposerDropOverlay' import { ComposerDropOverlay } from '../components/chat/ComposerDropOverlay'
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
@ -105,6 +105,7 @@ export function EmptySession() {
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
const plusMenuRef = useRef<HTMLDivElement>(null) const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null) const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null) const fileSearchRef = useRef<FileSearchMenuHandle>(null)
@ -303,6 +304,15 @@ export function EmptySession() {
return return
} }
if (slashUiAction?.type === 'model') {
modelSelectorRef.current?.open()
setInput('')
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
return
}
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY]
@ -784,7 +794,7 @@ export function EmptySession() {
draft draft
compact={isMobileComposer} compact={isMobileComposer}
/> />
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} /> <ModelSelector ref={modelSelectorRef} runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}