Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
getSessionContextUsageDisplay()
);
const [isModelSwitching, setIsModelSwitching] = useState(false);
/** Bumped to ask the model selector to open itself, e.g. from `/model`. */
const [modelMenuOpenRequest, setModelMenuOpenRequest] = useState(0);
const [modelAvailability, setModelAvailability] = useState<ModelSelectorAvailability>({
status: 'loading',
canSend: false,
Expand Down Expand Up @@ -3410,6 +3412,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
label: t('chatInput.goalAction'),
}]
: []),
{
kind: 'action',
id: 'model' as const,
command: '/model',
label: t('chatInput.modelAction'),
},
{
kind: 'action',
id: 'usage' as const,
Expand Down Expand Up @@ -4914,6 +4922,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({
);
const isInterruptedTurnRecoveryInFlight =
interruptedTurnRecoveryGate.isSessionInFlight(effectiveTargetSessionId);
/**
* One availability contract for the model selector, shared by the control
* itself and by commands that open it (`/model`), so a request cannot look
* accepted while the selector is refusing input.
*/
const modelSelectorDisabled =
isInterruptedTurnRecoveryInFlight
|| btwDraftSettingsInherited
|| isWorkspaceSubmitting
|| (!!effectiveTargetSession?.draft && effectiveTargetSession.draft.phase !== 'editing');

const externalFileAvailability = resolveExternalFileIntakeAvailability({
desktopRuntime: isTauriRuntime(),
Expand Down Expand Up @@ -5339,6 +5357,43 @@ export const ChatInput: React.FC<ChatInputProps> = ({
t,
]);

/**
* `/model` is a pointer to the selector beside the composer, not a turn. It
* only removes its own text and asks the selector to open, so the choice
* still comes from the single model surface.
*/
const submitModelFromInput = useCallback(() => {
const message = inputState.value.trim();
if (!/^\/model\s*$/i.test(message)) {
notificationService.warning(t('chatInput.modelUsage'));
return;
}

if (modelSelectorDisabled || caps.submissionOptionsLocked) {
// Keep the command in the composer so the user can retry once the
// selector accepts changes again.
notificationService.warning(t('chatInput.modelPickerUnavailable'));
return;
}

dispatchInput({ type: 'CLEAR_VALUE' });
setQueuedInput(null);
setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 });
setModelMenuOpenRequest(request => request + 1);
}, [
caps.submissionOptionsLocked,
dispatchInput,
inputState.value,
modelSelectorDisabled,
setQueuedInput,
t,
]);

/** The model menu was only a detour; the next keystroke belongs in the composer. */
const returnFocusToComposerAfterModelMenu = useCallback(() => {
window.setTimeout(() => richTextInputRef.current?.focus(), 0);
}, []);

const handleSendOrCancel = useCallback(async (messageOverride?: string) => {
if (workspaceSubmittingRef.current) return;
if (!derivedState) return;
Expand Down Expand Up @@ -5411,6 +5466,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
if (!submissionScope.isCurrent()) return;

if (promptSlashCommandsEnabled && caps.ops.has('model') && isSlashCommand(message, '/model')) {
submitModelFromInput();
return;
}

if (promptSlashCommandsEnabled && caps.ops.has('btw') && isSlashCommand(message, '/btw')) {
// When idle, /btw can be sent via the normal send button.
await submitBtwFromInput();
Expand Down Expand Up @@ -5635,6 +5695,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
submitReviewFromInput,
submitMcpPromptFromInput,
submitReloadFromInput,
submitModelFromInput,
reloadContextSupported,
confirmPromptCacheGuardIfNeeded,
t,
Expand Down Expand Up @@ -7239,9 +7300,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
externalSelection={dispatchModelSelection}
modeDefaultModelId={targetModeInfo?.model}
persistSharedModeDefault={!isBtwDraftTarget && Boolean(targetModeInfo && targetModeInfo.source !== 'external')}
disabled={isInterruptedTurnRecoveryInFlight || btwDraftSettingsInherited || isWorkspaceSubmitting
|| !!effectiveTargetSession?.draft && effectiveTargetSession.draft.phase !== 'editing'}
disabled={modelSelectorDisabled}
disabledReason={btwDraftSettingsInherited ? t('selection.inheritedSettings') : undefined}
openRequest={modelMenuOpenRequest}
onRequestMenuClosed={returnFocusToComposerAfterModelMenu}
reasoningTriggerPresentation="label"
/>
</div>
Expand Down
69 changes: 69 additions & 0 deletions src/web-ui/src/flow_chat/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ interface ModelSelectorProps {
disabledReason?: string;
/** Compact trigger treatment supplied by the owning composer. */
reasoningTriggerPresentation?: 'meter' | 'label';
/**
* Increments each time the owning composer asks the selector to open, e.g.
* from the `/model` slash command. The selector owns its menu state, so this
* is a request signal rather than a controlled `open` prop.
*/
openRequest?: number;
/**
* Called after a menu opened by `openRequest` closes without navigating away,
* so the composer can take focus back for the next message.
*/
onRequestMenuClosed?: () => void;
}

interface ModelInfo {
Expand Down Expand Up @@ -376,6 +387,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
persistSharedModeDefault = true,
disabled = false,
disabledReason,
openRequest = 0,
onRequestMenuClosed,
}) => {
const { t, formatNumber } = useI18n('flow-chat');
const deviceSurfaceScope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope);
Expand Down Expand Up @@ -454,6 +467,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const focusPanelOnOpenRef = useRef(false);
const focusSummaryOnReturnRef = useRef<ModelSelectorPanel | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
/** Last composer `openRequest` value this selector already answered. */
const handledOpenRequestRef = useRef(0);
/** Whether the open menu belongs to a composer request rather than the trigger. */
const requestOwnsMenuRef = useRef(false);
/** Last rendered menu state, so the handoff only fires on an open-to-closed transition. */
const menuWasOpenRef = useRef(false);
const onRequestMenuClosedRef = useRef(onRequestMenuClosed);
onRequestMenuClosedRef.current = onRequestMenuClosed;
const menuId = useId();

useEffect(() => {
Expand Down Expand Up @@ -1131,6 +1152,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}, [availability, onAvailabilityChange]);

const handleOpenModelSettings = useCallback(() => {
// Navigation replaces the composer's focus intent; do not pull focus back.
requestOwnsMenuRef.current = false;
setDropdownOpen(false);
setKeyboardNavigationOpen(false);
quickActions.openSettings({ pageId: 'ai.models' });
Expand Down Expand Up @@ -2290,6 +2313,52 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
if (disabled || loading || externalSelection?.disabled) return;
openPanel('models', true);
};
/**
* Open the menu the way the trigger does. Trigger activation and composer
* requests (`/model`) share this so both land on the same view and focus.
*/
const openMenu = useCallback((): boolean => {
if (disabled || loading || reasoningLoading || externalSelection?.disabled || !showModelTrigger) {
return false;
}
if (dropdownOpen) return true;
resetDropdownNavigation();
// A command request is a keyboard interaction: focus the current choice
// instead of leaving focus in the composer.
setKeyboardNavigationOpen(true);
if (isAcpSelection && !modelInformationLoading) void loadAcpOptions();
if (!hasSettingsPanel) {
openPanel('models', true);
}
setDropdownOpen(true);
return true;
}, [
disabled,
dropdownOpen,
externalSelection?.disabled,
hasSettingsPanel,
isAcpSelection,
loadAcpOptions,
loading,
modelInformationLoading,
openPanel,
reasoningLoading,
resetDropdownNavigation,
showModelTrigger,
]);
useEffect(() => {
if (openRequest <= 0 || openRequest === handledOpenRequestRef.current) return;
handledOpenRequestRef.current = openRequest;
// Only a request that actually opened the menu owns the close handoff.
requestOwnsMenuRef.current = openMenu();
}, [openMenu, openRequest]);
useEffect(() => {
const wasOpen = menuWasOpenRef.current;
menuWasOpenRef.current = dropdownOpen;
if (!wasOpen || dropdownOpen || !requestOwnsMenuRef.current) return;
requestOwnsMenuRef.current = false;
onRequestMenuClosedRef.current?.();
}, [dropdownOpen]);
const isSelectionPanel = activePanel === 'models' || activePanel === 'providers' || activePanel === 'pool' || activePanel === 'pools';
const isModelPicker = activePanel === 'models' && !activeProviderGroup;
const showEmptyModeCard = !showModelLoadingState && !activeProviderGroup && (
Expand Down
Loading
Loading