From d671a5e2576794145d8e5ae1e991c60a9d17f5ad Mon Sep 17 00:00:00 2001 From: sakebomb <1392534+sakebomb@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:56:10 -0500 Subject: [PATCH] feat: add quick generation settings panel in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a compact panel from the chat input bar for adjusting temperature, top-p, and max tokens without opening the full settings sheet. - Temperature pill in the chat input showing the current session value - QuickGenSettingsSheet with sliders for temperature and top-p - Max tokens: an "Unlimited" toggle plus a finite slider, mirroring the full Generation Settings sheet's n_predict semantics. n_predict = -1 (unlimited) is the default since #687, so the slider (64–8192) alone could not represent it — opening the panel on a default session would show a false "64" cap and any slider touch would silently cap an otherwise-unlimited session. The toggle represents -1 directly; turning it off starts the slider at the same 1024 fallback the full sheet uses. - Changes apply to the session's completion settings immediately (settingsSource flips to 'custom'); Reset restores the unlimited default - Tests cover the finite/unlimited/toggle-off/reset/no-session paths Closes #605 --- __mocks__/stores/chatSessionStore.ts | 3 +- src/components/ChatInput/ChatInput.tsx | 30 +++ .../QuickGenSettingsSheet.tsx | 206 +++++++++++++++ .../__tests__/QuickGenSettingsSheet.test.tsx | 238 ++++++++++++++++++ src/components/QuickGenSettingsSheet/index.ts | 1 + src/components/index.ts | 1 + src/locales/__tests__/locales.test.ts | 1 + src/locales/en.json | 12 + src/screens/ChatScreen/ChatScreen.tsx | 11 + 9 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 src/components/QuickGenSettingsSheet/QuickGenSettingsSheet.tsx create mode 100644 src/components/QuickGenSettingsSheet/__tests__/QuickGenSettingsSheet.test.tsx create mode 100644 src/components/QuickGenSettingsSheet/index.ts diff --git a/__mocks__/stores/chatSessionStore.ts b/__mocks__/stores/chatSessionStore.ts index 005881667..77f24dbe3 100644 --- a/__mocks__/stores/chatSessionStore.ts +++ b/__mocks__/stores/chatSessionStore.ts @@ -6,10 +6,11 @@ import type { // Mock defaultCompletionSettings to avoid circular imports // This should match the actual defaultCompletionSettings from ChatSessionStore +// (n_predict default is -1 = unlimited since #687). export const mockDefaultCompletionSettings = { version: 3, include_thinking_in_context: true, - n_predict: 1024, + n_predict: -1, temperature: 0.7, top_k: 40, top_p: 0.95, diff --git a/src/components/ChatInput/ChatInput.tsx b/src/components/ChatInput/ChatInput.tsx index 9296ad0ec..9b03c63be 100644 --- a/src/components/ChatInput/ChatInput.tsx +++ b/src/components/ChatInput/ChatInput.tsx @@ -80,6 +80,8 @@ export interface ChatInputTopLevelProps { reasoningEffort?: string; /** Callback to cycle the graded effort state (off -> values -> off) */ onEffortCycle?: () => void; + /** When provided, shows a temperature pill that opens the generation settings panel */ + onGenSettingsPress?: () => void; } export interface ChatInputAdditionalProps { @@ -105,6 +107,8 @@ export interface ChatInputAdditionalProps { reasoningEffort?: string; /** Callback to cycle the graded effort state (off -> values -> off) */ onEffortCycle?: () => void; + /** When provided, shows a temperature pill that opens the generation settings panel */ + onGenSettingsPress?: () => void; } export type ChatInputProps = ChatInputTopLevelProps & ChatInputAdditionalProps; @@ -143,6 +147,7 @@ export const ChatInput = observer( effortValues = [], reasoningEffort, onEffortCycle, + onGenSettingsPress, }: ChatInputProps) => { const l10n = React.useContext(L10nContext); const theme = useTheme(); @@ -571,6 +576,31 @@ export const ChatInput = observer( )} + {/* Temperature Pill */} + {onGenSettingsPress && !isCameraActive && hasActiveModel && ( + + + {`T: ${( + chatSessionStore.sessions.find( + s => s.id === chatSessionStore.activeSessionId, + )?.completionSettings?.temperature ?? 0.7 + ).toFixed(1)}`} + + + )} + {/* Thinking Toggle Button. Graded models (axis-2) cycle off -> low -> medium -> high; effortless models toggle on/off. The label shows the current effort when graded. */} diff --git a/src/components/QuickGenSettingsSheet/QuickGenSettingsSheet.tsx b/src/components/QuickGenSettingsSheet/QuickGenSettingsSheet.tsx new file mode 100644 index 000000000..5e0da2553 --- /dev/null +++ b/src/components/QuickGenSettingsSheet/QuickGenSettingsSheet.tsx @@ -0,0 +1,206 @@ +import React, {useEffect, useState} from 'react'; +import {View, StyleSheet} from 'react-native'; +import {Button, Switch, Text} from 'react-native-paper'; +import {observer} from 'mobx-react'; + +import {Sheet} from '../Sheet/Sheet'; +import {InputSlider} from '../InputSlider'; +import {chatSessionStore, defaultCompletionSettings} from '../../store'; +import {CompletionParams} from '../../utils/completionTypes'; +import {useTheme} from '../../hooks'; +import {L10nContext} from '../../utils'; + +interface QuickGenSettingsSheetProps { + isVisible: boolean; + onClose: () => void; +} + +// n_predict === -1 means "unlimited" (generate until EOS). The slider can only +// express a finite cap, so unlimited is a separate toggle; this is the value +// the slider starts at when the user turns unlimited off — matching the finite +// fallback the full Generation Settings sheet uses. +const FINITE_MAX_TOKENS_FALLBACK = 1024; + +const resolveMaxTokens = (nPredict: number | undefined) => { + const n = nPredict ?? defaultCompletionSettings.n_predict ?? -1; + return { + isUnlimited: n === -1, + maxTokens: n > 0 ? n : FINITE_MAX_TOKENS_FALLBACK, + }; +}; + +export const QuickGenSettingsSheet: React.FC = + observer(({isVisible, onClose}) => { + const theme = useTheme(); + const l10n = React.useContext(L10nContext); + + const activeSession = chatSessionStore.activeSessionId + ? chatSessionStore.sessions.find( + s => s.id === chatSessionStore.activeSessionId, + ) + : null; + + const sourceSettings: CompletionParams = + activeSession?.completionSettings ?? defaultCompletionSettings; + + const [temperature, setTemperature] = useState( + sourceSettings.temperature ?? 0.7, + ); + const [topP, setTopP] = useState(sourceSettings.top_p ?? 0.95); + const initialMaxTokens = resolveMaxTokens(sourceSettings.n_predict); + const [isUnlimited, setIsUnlimited] = useState( + initialMaxTokens.isUnlimited, + ); + const [maxTokens, setMaxTokens] = useState(initialMaxTokens.maxTokens); + + // Sync sliders when sheet opens or session changes + useEffect(() => { + if (isVisible) { + const s = chatSessionStore.activeSessionId + ? chatSessionStore.sessions.find( + ss => ss.id === chatSessionStore.activeSessionId, + )?.completionSettings + : null; + setTemperature( + s?.temperature ?? defaultCompletionSettings.temperature ?? 0.7, + ); + setTopP(s?.top_p ?? defaultCompletionSettings.top_p ?? 0.95); + const resolved = resolveMaxTokens(s?.n_predict); + setIsUnlimited(resolved.isUnlimited); + setMaxTokens(resolved.maxTokens); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, chatSessionStore.activeSessionId]); + + const handleSave = async () => { + const updated: CompletionParams = { + ...sourceSettings, + temperature, + top_p: topP, + n_predict: isUnlimited ? -1 : maxTokens, + }; + await chatSessionStore.updateSessionCompletionSettings(updated); + onClose(); + }; + + const handleReset = () => { + setTemperature(defaultCompletionSettings.temperature ?? 0.7); + setTopP(defaultCompletionSettings.top_p ?? 0.95); + const resolved = resolveMaxTokens(defaultCompletionSettings.n_predict); + setIsUnlimited(resolved.isUnlimited); + setMaxTokens(resolved.maxTokens); + }; + + return ( + + + + + + + + + + + + + + + {l10n.quickGenSettings.maxTokens} + + + {l10n.quickGenSettings.unlimited} + + + + + + {!isUnlimited && ( + setMaxTokens(Math.round(v))} + min={64} + max={8192} + step={64} + precision={0} + testID="quick-max-tokens-slider" + /> + )} + + + + + + + + + ); + }); + +const styles = StyleSheet.create({ + content: { + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 16, + }, + divider: { + height: 16, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + toggleLabelColumn: { + flex: 1, + paddingRight: 12, + }, + actions: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 24, + }, +}); diff --git a/src/components/QuickGenSettingsSheet/__tests__/QuickGenSettingsSheet.test.tsx b/src/components/QuickGenSettingsSheet/__tests__/QuickGenSettingsSheet.test.tsx new file mode 100644 index 000000000..ca1b95b90 --- /dev/null +++ b/src/components/QuickGenSettingsSheet/__tests__/QuickGenSettingsSheet.test.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import {fireEvent, render, act} from '../../../../jest/test-utils'; +import {QuickGenSettingsSheet} from '../QuickGenSettingsSheet'; +import {chatSessionStore} from '../../../store'; + +// Mock Sheet component +jest.mock('../../Sheet/Sheet', () => { + const {View, TouchableOpacity, Text} = require('react-native'); + const MockSheet = ({ + children, + isVisible, + onClose, + title, + }: { + children: React.ReactNode; + isVisible: boolean; + onClose: () => void; + title: string; + }) => { + if (!isVisible) { + return null; + } + return ( + + {title} + + Close + + {children} + + ); + }; + MockSheet.ScrollView = ({children}: {children: React.ReactNode}) => ( + {children} + ); + return {Sheet: MockSheet}; +}); + +// Mock InputSlider +jest.mock('../../InputSlider', () => { + const {View, Text} = require('react-native'); + return { + InputSlider: ({ + label, + value, + testID, + onValueChange, + }: { + label: string; + value: number; + testID: string; + onValueChange: (v: number) => void; + }) => ( + + {label} + {value} + onValueChange(1.5)} /> + + ), + }; +}); + +describe('QuickGenSettingsSheet', () => { + const mockOnClose = jest.fn(); + + // A session with a finite (custom) max-tokens cap. + const finiteSession = () => { + (chatSessionStore as any).activeSessionId = 'test-session'; + (chatSessionStore as any).sessions = [ + { + id: 'test-session', + completionSettings: {temperature: 0.8, top_p: 0.9, n_predict: 2048}, + }, + ]; + }; + + // A session left on the default unlimited cap (n_predict === -1). + const unlimitedSession = () => { + (chatSessionStore as any).activeSessionId = 'test-session'; + (chatSessionStore as any).sessions = [ + { + id: 'test-session', + completionSettings: {temperature: 0.8, top_p: 0.9, n_predict: -1}, + }, + ]; + }; + + beforeEach(() => { + jest.clearAllMocks(); + finiteSession(); + }); + + it('renders nothing when not visible', () => { + const {queryByTestId} = render( + , + ); + expect(queryByTestId('sheet')).toBeNull(); + }); + + it('renders sheet with the sliders and unlimited toggle when visible', () => { + const {getByTestId} = render( + , + ); + expect(getByTestId('sheet')).toBeTruthy(); + expect(getByTestId('quick-temperature-slider')).toBeTruthy(); + expect(getByTestId('quick-top-p-slider')).toBeTruthy(); + expect(getByTestId('quick-unlimited-toggle')).toBeTruthy(); + // Finite session -> the max-tokens slider is shown. + expect(getByTestId('quick-max-tokens-slider')).toBeTruthy(); + }); + + it('loads a finite session cap into the max-tokens slider', () => { + const {getByTestId} = render( + , + ); + expect(getByTestId('quick-temperature-slider-value').props.children).toBe( + 0.8, + ); + expect(getByTestId('quick-top-p-slider-value').props.children).toBe(0.9); + expect(getByTestId('quick-max-tokens-slider-value').props.children).toBe( + 2048, + ); + expect(getByTestId('quick-unlimited-toggle').props.value).toBe(false); + }); + + it('shows the unlimited toggle on and hides the slider for an unlimited session', () => { + unlimitedSession(); + const {getByTestId, queryByTestId} = render( + , + ); + expect(getByTestId('quick-unlimited-toggle').props.value).toBe(true); + // No finite cap to show while unlimited. + expect(queryByTestId('quick-max-tokens-slider')).toBeNull(); + }); + + it('reveals the max-tokens slider when unlimited is toggled off', () => { + unlimitedSession(); + const {getByTestId, queryByTestId} = render( + , + ); + expect(queryByTestId('quick-max-tokens-slider')).toBeNull(); + + act(() => { + fireEvent(getByTestId('quick-unlimited-toggle'), 'valueChange', false); + }); + + // Slider appears at the finite fallback (1024). + expect(getByTestId('quick-max-tokens-slider')).toBeTruthy(); + expect(getByTestId('quick-max-tokens-slider-value').props.children).toBe( + 1024, + ); + }); + + it('saves a finite cap on apply', async () => { + const {getByTestId} = render( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('quick-gen-apply')); + }); + + expect( + chatSessionStore.updateSessionCompletionSettings, + ).toHaveBeenCalledWith( + expect.objectContaining({temperature: 0.8, top_p: 0.9, n_predict: 2048}), + ); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('preserves unlimited (n_predict: -1) on apply when the toggle stays on', async () => { + unlimitedSession(); + const {getByTestId} = render( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('quick-gen-apply')); + }); + + expect( + chatSessionStore.updateSessionCompletionSettings, + ).toHaveBeenCalledWith(expect.objectContaining({n_predict: -1})); + }); + + it('caps a previously-unlimited session when the toggle is turned off', async () => { + unlimitedSession(); + const {getByTestId} = render( + , + ); + + act(() => { + fireEvent(getByTestId('quick-unlimited-toggle'), 'valueChange', false); + }); + await act(async () => { + fireEvent.press(getByTestId('quick-gen-apply')); + }); + + expect( + chatSessionStore.updateSessionCompletionSettings, + ).toHaveBeenCalledWith(expect.objectContaining({n_predict: 1024})); + }); + + it('resets to the unlimited default on reset', () => { + // Default n_predict is -1 (unlimited) since #687. + const {getByTestId, queryByTestId} = render( + , + ); + + act(() => { + fireEvent.press(getByTestId('quick-gen-reset')); + }); + + expect(getByTestId('quick-temperature-slider-value').props.children).toBe( + 0.7, + ); + expect(getByTestId('quick-unlimited-toggle').props.value).toBe(true); + expect(queryByTestId('quick-max-tokens-slider')).toBeNull(); + }); + + it('falls back to the unlimited default when no active session', () => { + (chatSessionStore as any).activeSessionId = null; + + const {getByTestId, queryByTestId} = render( + , + ); + + expect(getByTestId('quick-temperature-slider-value').props.children).toBe( + 0.7, + ); + expect(getByTestId('quick-top-p-slider-value').props.children).toBe(0.95); + expect(getByTestId('quick-unlimited-toggle').props.value).toBe(true); + expect(queryByTestId('quick-max-tokens-slider')).toBeNull(); + }); +}); diff --git a/src/components/QuickGenSettingsSheet/index.ts b/src/components/QuickGenSettingsSheet/index.ts new file mode 100644 index 000000000..ef7d758ed --- /dev/null +++ b/src/components/QuickGenSettingsSheet/index.ts @@ -0,0 +1 @@ +export {QuickGenSettingsSheet} from './QuickGenSettingsSheet'; diff --git a/src/components/index.ts b/src/components/index.ts index 02d2f5892..f0c4a182d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -76,3 +76,4 @@ export * from './InputSlider'; export * from './MemoryRequirement'; export * from './RemoteModelSheet'; export * from './ServerDetailsSheet'; +export * from './QuickGenSettingsSheet'; diff --git a/src/locales/__tests__/locales.test.ts b/src/locales/__tests__/locales.test.ts index c6bed0922..1b4fdc6eb 100644 --- a/src/locales/__tests__/locales.test.ts +++ b/src/locales/__tests__/locales.test.ts @@ -36,6 +36,7 @@ const EXPECTED_SECTIONS = [ 'htmlPreview', 'onboarding', 'downloadBanner', + 'quickGenSettings', ]; const ALL_LANGUAGES: AvailableLanguage[] = [ diff --git a/src/locales/en.json b/src/locales/en.json index e897e53ad..d781cd20d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1568,5 +1568,17 @@ "titleByPal": "{{name}} is downloading", "titleByModel": "{{name}} is downloading", "extraInProgress": "+{{count}} more in progress" + }, + "quickGenSettings": { + "title": "Generation Settings", + "temperature": "Temperature", + "temperatureDesc": "Controls randomness. Lower = more focused, higher = more creative.", + "topP": "Top-p", + "topPDesc": "Nucleus sampling threshold. Lower values keep only the most likely tokens.", + "maxTokens": "Max tokens", + "maxTokensDesc": "Maximum number of tokens to generate per response.", + "unlimited": "Unlimited", + "resetDefaults": "Reset to defaults", + "apply": "Apply" } } diff --git a/src/screens/ChatScreen/ChatScreen.tsx b/src/screens/ChatScreen/ChatScreen.tsx index ceb67056b..2afe234ca 100644 --- a/src/screens/ChatScreen/ChatScreen.tsx +++ b/src/screens/ChatScreen/ChatScreen.tsx @@ -10,6 +10,7 @@ import { ModelErrorReportSheet, } from '../../components'; import {PalSheet} from '../../components/PalsSheets'; +import {QuickGenSettingsSheet} from '../../components/QuickGenSettingsSheet'; import {useChatSession} from '../../hooks'; import {usePendingMessage} from '../../hooks/useDeepLinking'; @@ -68,6 +69,9 @@ export const ChatScreen: React.FC = observer(() => { // State for pal sheet const [isPalSheetVisible, setIsPalSheetVisible] = useState(false); + // State for quick generation settings panel + const [isGenSettingsVisible, setIsGenSettingsVisible] = useState(false); + // State for model error report sheet const [isErrorReportVisible, setIsErrorReportVisible] = useState(false); const [errorToReport, setErrorToReport] = useState(null); @@ -279,6 +283,9 @@ export const ChatScreen: React.FC = observer(() => { effortValues: reasoningCapability.effortValues, reasoningEffort, onEffortCycle: handleEffortCycle, + onGenSettingsPress: chatSessionStore.activeSessionId + ? () => setIsGenSettingsVisible(true) + : undefined, }} textInputProps={{ placeholder: !modelStore.engine @@ -313,6 +320,10 @@ export const ChatScreen: React.FC = observer(() => { pal={activePal} /> )} + setIsGenSettingsVisible(false)} + /> ); });