Skip to content

Commit 4644973

Browse files
committed
fix: model-aware thinking budget, focus timing and pending edits
Three more consequences of the earlier fixes in this branch. The thinking budget rendered with the shared model-agnostic -1..32000 range, which the new clamp then enforced: Gemini 2.5 Pro was capped below its documented 32,768 ceiling while Flash values above 24,576 were still accepted and sent on. The ceiling is now resolved per model alongside the other model-aware defaults. Only the maximum moves, because -1 is the "decide automatically" sentinel and raising the floor would clamp it away. Restoring focus from afterSlide was too early. That callback runs on the same frame as the deferred state flip, so the opener's ancestor was often still inert and the browser refused the focus, leaving it on a scrim about to be hidden. It now happens once the closed state has committed. Migrating Google to the shared controls also made its fields debounced, so Save or Export clicked straight after typing read the previous value. The debounce hook exposes a flush, and the text controls call it on blur, which is the event a Save click already fires first.
1 parent c2e0c75 commit 4644973

6 files changed

Lines changed: 82 additions & 17 deletions

File tree

‎client/src/components/SidePanel/Parameters/DynamicInput.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ function DynamicInput({
2828
const localize = useLocalize();
2929
const { preset } = useChatContext();
3030

31-
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
31+
const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput<
32+
string | number
33+
>({
3234
optionKey: settingKey,
3335
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
3436
setter: () => ({}),
@@ -66,6 +68,9 @@ function DynamicInput({
6668
* say) was persisted and only rejected later by the provider. Clamping on
6769
* blur rather than on change leaves partially typed numbers alone. */
6870
const handleInputBlur = () => {
71+
/** Clicking Save blurs the field first, so committing the pending edit here
72+
* is what stops submitPreset reading the value from before it. */
73+
flushInputValue();
6974
if (type !== SettingTypes.Number || range == null) {
7075
return;
7176
}

‎client/src/components/SidePanel/Parameters/DynamicTextarea.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ function DynamicTextarea({
2626
const localize = useLocalize();
2727
const { preset } = useChatContext();
2828

29-
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
29+
const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput<
30+
string | null
31+
>({
3032
optionKey: settingKey,
3133
initialValue:
3234
optionType !== OptionTypes.Custom
@@ -75,6 +77,9 @@ function DynamicTextarea({
7577
disabled={readonly}
7678
value={inputValue ?? ''}
7779
onChange={setInputValue}
80+
/** Clicking Save blurs this first, so the pending edit is committed
81+
* before submitPreset reads the preset. */
82+
onBlur={flushInputValue}
7883
aria-label={localize(label as TranslationKeys)}
7984
placeholder={
8085
placeholderCode

‎client/src/hooks/Conversations/useDebouncedInput.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import debounce from 'lodash/debounce';
21
import React, { useState, useCallback, useMemo } from 'react';
2+
import debounce from 'lodash/debounce';
33
import type { SetterOrUpdater } from 'recoil';
44
import type { TSetOption } from '~/common';
55
import { defaultDebouncedDelay } from '~/common';
@@ -23,7 +23,7 @@ function useDebouncedInput<T = unknown>({
2323
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T, numeric?: boolean) => void,
2424
T,
2525
SetterOrUpdater<T>,
26-
// (newValue: string) => void,
26+
() => void,
2727
] {
2828
const [value, setValue] = useState<T>(initialValue);
2929

@@ -52,7 +52,13 @@ function useDebouncedInput<T = unknown>({
5252
},
5353
[setDebouncedOption],
5454
);
55-
return [onChange, value, setValue];
55+
/** Lets a caller commit a pending edit immediately, e.g. on blur, so a Save
56+
* clicked in the same gesture does not read the pre-edit value. */
57+
const flush = useCallback(() => {
58+
setDebouncedOption.flush();
59+
}, [setDebouncedOption]);
60+
61+
return [onChange, value, setValue, flush];
5662
}
5763

5864
export default useDebouncedInput;

‎client/src/routes/Root.tsx‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default function Root() {
6464
/** The drawer and pane snap under reduced motion (see kickDrawerAnimation),
6565
* so the scrim must not keep fading on its own. */
6666
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
67+
const restoreFocusRef = useRef(false);
6768
const paneRef = useRef<HTMLDivElement>(null);
6869
/** Focus handoff lives in the drawer header's own expanded-effect — the
6970
* commit drives it, so every opener (button, swipe) is covered without a
@@ -107,6 +108,16 @@ export default function Root() {
107108
}
108109
}, [termsData]);
109110

111+
/** Focus can only move once the pane has actually shed `inert`, which is a
112+
* commit later than the close is requested. */
113+
useEffect(() => {
114+
if (sidebarExpanded || !restoreFocusRef.current) {
115+
return;
116+
}
117+
restoreFocusRef.current = false;
118+
document.getElementById(OPEN_SIDEBAR_ID)?.focus();
119+
}, [sidebarExpanded]);
120+
110121
const handleAcceptTerms = () => {
111122
setShowTerms(false);
112123
};
@@ -150,14 +161,15 @@ export default function Root() {
150161
<button
151162
type="button"
152163
aria-label={localize('com_nav_close_sidebar')}
153-
onClick={() =>
164+
onClick={() => {
154165
/** The scrim goes aria-hidden and untabbable on close,
155-
* so focus cannot stay here; afterSlide hands it to the
156-
* control that reopens the drawer. */
157-
setSidebarOpen(false, () =>
158-
document.getElementById(OPEN_SIDEBAR_ID)?.focus(),
159-
)
160-
}
166+
* so focus cannot stay here. The handoff waits for the
167+
* committed state below rather than riding afterSlide,
168+
* which fires on the same frame as the deferred flip,
169+
* while the opener's ancestor is still inert. */
170+
restoreFocusRef.current = true;
171+
setSidebarOpen(false);
172+
}}
161173
tabIndex={sidebarExpanded ? 0 : -1}
162174
aria-hidden={!sidebarExpanded || undefined}
163175
className={cn(

‎packages/data-provider/src/parameterSettings.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
EModelEndpoint,
77
openAISettings,
88
googleSettings,
9+
getGoogleThinkingBudgetMax,
910
Providers,
1011
ReasoningEffort,
1112
AnthropicEffort,
@@ -1267,9 +1268,19 @@ export function applyModelAwareDefaults(
12671268
if (endpoint !== EModelEndpoint.google || !model) {
12681269
return settings;
12691270
}
1270-
return settings.map((setting) =>
1271-
setting.key === 'maxOutputTokens'
1272-
? { ...setting, default: googleSettings.maxOutputTokens.reset(model) }
1273-
: setting,
1274-
);
1271+
return settings.map((setting) => {
1272+
if (setting.key === 'maxOutputTokens') {
1273+
return { ...setting, default: googleSettings.maxOutputTokens.reset(model) };
1274+
}
1275+
/** The shared thinking budget range is model-agnostic, so it caps Pro below
1276+
* its real ceiling and accepts Flash values the provider rejects. Only the
1277+
* maximum moves: -1 stays reachable as the "decide automatically" value. */
1278+
if (setting.key === 'thinkingBudget' && setting.range != null) {
1279+
const max = getGoogleThinkingBudgetMax(model);
1280+
if (max != null) {
1281+
return { ...setting, range: { ...setting.range, max } };
1282+
}
1283+
}
1284+
return setting;
1285+
});
12751286
}

‎packages/data-provider/src/schemas.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,32 @@ const getGoogleMaxOutputTokens = (modelName: string): number => {
463463
return GOOGLE_LEGACY_MAX_OUTPUT;
464464
};
465465

466+
/**
467+
* Per-model thinking budget ceilings, documented in
468+
* `com_endpoint_google_thinking_budget`: Gemini 2.5 Pro accepts up to 32,768
469+
* tokens while Flash and Flash Lite stop at 24,576. The generic 32,000 in the
470+
* shared definition both under-limits Pro and lets invalid Flash values through.
471+
*
472+
* Only the ceiling varies here. The documented floors (128 / 0 / 512) are the
473+
* supported thinking ranges, but -1 is the "decide automatically" sentinel, so
474+
* raising the minimum would clamp auto away.
475+
*/
476+
const GOOGLE_THINKING_BUDGET_PRO_MAX = 32768 as const;
477+
const GOOGLE_THINKING_BUDGET_FLASH_MAX = 24576 as const;
478+
479+
export const getGoogleThinkingBudgetMax = (modelName: string): number | undefined => {
480+
if (!/gemini-2\.5/i.test(modelName)) {
481+
return undefined;
482+
}
483+
if (/flash/i.test(modelName)) {
484+
return GOOGLE_THINKING_BUDGET_FLASH_MAX;
485+
}
486+
if (/pro/i.test(modelName)) {
487+
return GOOGLE_THINKING_BUDGET_PRO_MAX;
488+
}
489+
return undefined;
490+
};
491+
466492
export const googleSettings = {
467493
model: {
468494
default: 'gemini-1.5-flash-latest' as const,

0 commit comments

Comments
 (0)