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)}
+ />
>
);
});