diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 3c7caaa1..471c6d1c 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -69,9 +69,21 @@ vi.mock('../controls/PermissionModeSelector', () => ({ PermissionModeSelector: () => , })) -vi.mock('../controls/ModelSelector', () => ({ - ModelSelector: () => , -})) +vi.mock('../controls/ModelSelector', async () => { + const React = await vi.importActual('react') + return { + ModelSelector: React.forwardRef<{ open: () => void }, Record>((_props, ref) => { + const [open, setOpen] = React.useState(false) + React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []) + return ( + <> + + {open &&
Model selector opened
} + + ) + }), + } +}) 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() + + 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: { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 0aff84d1..36d36c78 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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(null) const panelRef = useRef(null) const fileInputRef = useRef(null) + const modelSelectorRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) const fileSearchRef = useRef(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 && ( - + )} - ), -})) +vi.mock('../components/controls/ModelSelector', async () => { + const React = await vi.importActual('react') + return { + ModelSelector: React.forwardRef<{ open: () => void }, { compact?: boolean }>(({ compact }, ref) => { + const [open, setOpen] = React.useState(false) + React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []) + return ( + <> + + {open &&
Model selector opened
} + + ) + }), + } +}) 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() + + 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', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 92482e1d..c87c2133 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -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(null) const panelRef = useRef(null) const fileInputRef = useRef(null) + const modelSelectorRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) const fileSearchRef = useRef(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} /> - +