mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
7b2477f492
commit
a169cba613
@ -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: {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user