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>,
}))
vi.mock('../controls/ModelSelector', () => ({
ModelSelector: () => <button type="button">Model</button>,
}))
vi.mock('../controls/ModelSelector', async () => {
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 { 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 () => {
useChatStore.setState({
sessions: {

View File

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

View File

@ -180,6 +180,10 @@ describe('composerUtils', () => {
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', () => {
// 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).

View File

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

View File

@ -110,13 +110,23 @@ vi.mock('../components/controls/PermissionModeSelector', () => ({
),
}))
vi.mock('../components/controls/ModelSelector', () => ({
ModelSelector: ({ compact }: { compact?: boolean }) => (
<button type="button" data-testid="model-selector" data-compact={compact ? 'true' : 'false'}>
Model
</button>
),
}))
vi.mock('../components/controls/ModelSelector', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
ModelSelector: React.forwardRef<{ open: () => void }, { compact?: boolean }>(({ compact }, ref) => {
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 { ApiError } from '../api/client'
@ -353,6 +363,29 @@ describe('EmptySession', () => {
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 () => {
useSettingsStore.setState({
chatSendBehavior: 'enter',

View File

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