Merge branch 'main' into camoufox-novnc-browser-use

This commit is contained in:
Haile
2026-07-03 23:34:36 +03:00
committed by GitHub
24 changed files with 853 additions and 204 deletions

View File

@@ -0,0 +1,13 @@
import type { LLMProvider, ProviderModelOption } from '../../../types/app';
export const DEFAULT_EFFORT_VALUE = 'default';
export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial<Record<LLMProvider, readonly string[]>> = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
codex: ['low', 'medium', 'high', 'xhigh'],
opencode: ['none', 'low', 'medium', 'high', 'xhigh', 'max'],
};
export const toProviderEffortOptions = (
values: readonly string[],
): NonNullable<ProviderModelOption['effort']>['values'] => values.map((value) => ({ value }));

View File

@@ -34,9 +34,11 @@ interface UseChatComposerStateArgs {
provider: LLMProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode;
cursorModel: string;
claudeModel: string;
codexModel: string;
currentProviderEffort: string;
geminiModel: string;
opencodeModel: string;
isLoading: boolean;
@@ -168,9 +170,11 @@ export function useChatComposerState({
provider,
permissionMode,
cyclePermissionMode,
resolvePermissionModeForProvider,
cursorModel,
claudeModel,
codexModel,
currentProviderEffort,
geminiModel,
opencodeModel,
isLoading,
@@ -728,8 +732,9 @@ export function useChatComposerState({
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
? opencodeModel
: claudeModel;
const effort = currentProviderEffort;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
@@ -740,9 +745,8 @@ export function useChatComposerState({
content: messageContent,
options: {
model,
// Codex has no plan mode; downgrade rather than sending an
// unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
effort,
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
@@ -769,6 +773,7 @@ export function useChatComposerState({
attachedImages,
claudeModel,
codexModel,
currentProviderEffort,
currentSessionId,
cursorModel,
executeCommand,
@@ -779,6 +784,7 @@ export function useChatComposerState({
onSessionEstablished,
permissionMode,
provider,
resolvePermissionModeForProvider,
resetCommandMenuState,
scrollToBottom,
selectedProject,

View File

@@ -1,22 +1,31 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type {
ProjectSession,
LLMProvider,
Project,
ProviderModelOption,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
} from '../../../types/app';
import {
DEFAULT_EFFORT_VALUE,
FALLBACK_PROVIDER_EFFORT_VALUES,
toProviderEffortOptions,
} from '../constants/providerEffort';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'opus',
claude: 'default',
cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
};
const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
@@ -39,6 +48,7 @@ type ProviderCapabilities = {
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
supportsEffort?: boolean;
};
type ProviderCapabilitiesApiResponse = {
@@ -72,7 +82,7 @@ type ChangeActiveModelApiResponse = {
};
};
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => {
@@ -87,6 +97,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
});
const [providerEfforts, setProviderEfforts] = useState<Partial<Record<LLMProvider, string>>>(() => {
return PROVIDERS.reduce<Partial<Record<LLMProvider, string>>>((acc, targetProvider) => {
acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE;
return acc;
}, {});
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
@@ -145,8 +161,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('opencode-model', model);
}, []);
const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
setProviderEfforts((previous) => (
previous[targetProvider] === effort
? previous
: { ...previous, [targetProvider]: effort }
));
localStorage.setItem(`${targetProvider}-effort`, effort);
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -159,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
try {
const results = await Promise.all(
providers.map(async (p) => {
PROVIDERS.map(async (p) => {
const params = new URLSearchParams();
if (options.bypassCache) {
params.set('bypassCache', 'true');
@@ -183,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
providers.forEach((p, i) => {
PROVIDERS.forEach((p, i) => {
const entry = results[i];
if (!entry) {
return;
@@ -244,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => {
const modes = getPermissionModesForProvider(targetProvider);
const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined;
if (capabilityDefault && modes.includes(capabilityDefault)) {
return capabilityDefault;
}
return modes[0] ?? 'default';
}, [getPermissionModesForProvider, providerCapabilities]);
const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => {
const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort;
if (typeof capabilitySupport === 'boolean') {
return capabilitySupport;
}
return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length);
}, [providerCapabilities]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
@@ -259,6 +300,70 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return def.DEFAULT;
};
const getModelOption = useCallback((
targetProvider: LLMProvider,
model: string,
): ProviderModelOption | null => {
const definition = providerModelCatalog[targetProvider];
if (!definition) {
return null;
}
return definition.OPTIONS.find((option) => option.value === model) ?? null;
}, [providerModelCatalog]);
const getEffortOptionsForModel = useCallback((
targetProvider: LLMProvider,
model: string,
): NonNullable<ProviderModelOption['effort']>['values'] => {
if (!getSupportsEffortForProvider(targetProvider)) {
return [];
}
const option = getModelOption(targetProvider, model);
if (option) {
return option.effort?.values ?? [];
}
return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? []);
}, [getModelOption, getSupportsEffortForProvider]);
const getAllowedEffortValues = useCallback((
targetProvider: LLMProvider,
model: string,
): string[] => (
getEffortOptionsForModel(targetProvider, model).map((value) => value.value)
), [getEffortOptionsForModel]);
const reconcileStoredEffort = useCallback((
targetProvider: LLMProvider,
model: string,
currentEffort: string,
): string => {
const allowedValues = getAllowedEffortValues(targetProvider, model);
if (allowedValues.length === 0) {
return DEFAULT_EFFORT_VALUE;
}
if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) {
return DEFAULT_EFFORT_VALUE;
}
if (allowedValues.includes(currentEffort)) {
return currentEffort;
}
return DEFAULT_EFFORT_VALUE;
}, [getAllowedEffortValues]);
const providerModels = useMemo<Record<LLMProvider, string>>(() => ({
claude: claudeModel,
cursor: cursorModel,
codex: codexModel,
gemini: geminiModel,
opencode: opencodeModel,
}), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]);
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
@@ -324,6 +429,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const nextEfforts: Partial<Record<LLMProvider, string>> = {};
let hasUpdates = false;
for (const targetProvider of PROVIDERS) {
const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE;
const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort);
if (nextEffort === currentEffort) {
continue;
}
nextEfforts[targetProvider] = nextEffort;
localStorage.setItem(`${targetProvider}-effort`, nextEffort);
hasUpdates = true;
}
if (hasUpdates) {
setProviderEfforts((previous) => ({ ...previous, ...nextEfforts }));
}
}, [providerEfforts, providerModels, reconcileStoredEffort]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -331,8 +457,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
setPermissionMode(
savedMode && validModes.includes(savedMode)
? savedMode
: getDefaultPermissionModeForProvider(provider),
);
}, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -386,6 +516,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
const resolvePermissionModeForProvider = useCallback((
targetProvider: LLMProvider,
requestedMode: PermissionMode | string,
): PermissionMode => {
const validModes = getPermissionModesForProvider(targetProvider);
return validModes.includes(requestedMode as PermissionMode)
? requestedMode as PermissionMode
: getDefaultPermissionModeForProvider(targetProvider);
}, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,
model: string,
@@ -421,6 +561,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
};
}, [setStoredProviderModel]);
const currentProviderEffortOptions = useMemo(() => {
return getEffortOptionsForModel(provider, providerModels[provider]);
}, [getEffortOptionsForModel, provider, providerModels]);
const currentProviderEffort = useMemo(() => {
return reconcileStoredEffort(
provider,
providerModels[provider],
providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE,
);
}, [provider, providerEfforts, providerModels, reconcileStoredEffort]);
return {
provider,
setProvider,
@@ -430,6 +581,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setClaudeModel,
codexModel,
setCodexModel,
currentProviderEffort,
currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -445,5 +598,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
providerModelsRefreshing,
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel,
setStoredProviderEffort,
resolvePermissionModeForProvider,
};
}

View File

@@ -17,7 +17,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
function ChatInterface({
selectedProject,
selectedSession,
@@ -70,6 +69,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
currentProviderEffort,
currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -84,6 +85,8 @@ function ChatInterface({
providerModelsRefreshing,
hardRefreshProviderModels,
selectProviderModel,
setStoredProviderEffort,
resolvePermissionModeForProvider,
} = useChatProviderState({
selectedSession,
selectedProject,
@@ -197,6 +200,7 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
currentProviderEffort,
geminiModel,
opencodeModel,
isLoading: isProcessing,
@@ -213,6 +217,7 @@ function ChatInterface({
addMessage,
setIsUserScrolledUp,
setPendingPermissionRequests,
resolvePermissionModeForProvider,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
@@ -383,6 +388,9 @@ function ChatInterface({
onAbortSession={handleAbortSession}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
effort={currentProviderEffort}
availableEffortOptions={currentProviderEffortOptions}
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type {
ChangeEvent,
ClipboardEvent,
@@ -11,12 +11,13 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app';
import {
PromptInput,
PromptInputHeader,
@@ -62,6 +63,9 @@ interface ChatComposerProps {
onAbortSession: () => void;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
effort: string;
availableEffortOptions: NonNullable<ProviderModelOption['effort']>['values'];
onSelectEffort: (effort: string) => void;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
@@ -114,6 +118,9 @@ export default function ChatComposer({
onAbortSession,
permissionMode,
onModeSwitch,
effort,
availableEffortOptions,
onSelectEffort,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
@@ -167,7 +174,7 @@ export default function ChatComposer({
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
}, [input, isCommandMenuOpen, textareaRef]);
}, [isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -189,6 +196,67 @@ export default function ChatComposer({
);
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
const effortDropdownRef = useRef<HTMLDivElement | null>(null);
const effortDropdownMenuRef = useRef<HTMLDivElement | null>(null);
const effortDropdownButtonRef = useRef<HTMLButtonElement | null>(null);
const [effortDropdownPosition, setEffortDropdownPosition] = useState<{
left: number;
top: number;
maxHeight: number;
} | null>(null);
const effortOptions = useMemo(
() => [{ value: 'default' }, ...availableEffortOptions],
[availableEffortOptions],
);
const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
const updateEffortDropdownPosition = useCallback(() => {
const rect = effortDropdownButtonRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
setEffortDropdownPosition({
left: rect.left,
top: rect.top - 8,
maxHeight: Math.max(96, rect.top - 16),
});
}, []);
useEffect(() => {
if (!isEffortDropdownOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (
!effortDropdownRef.current?.contains(target)
&& !effortDropdownMenuRef.current?.contains(target)
) {
setIsEffortDropdownOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setIsEffortDropdownOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('resize', updateEffortDropdownPosition);
window.addEventListener('scroll', updateEffortDropdownPosition, true);
window.addEventListener('keydown', handleKeyDown, { capture: true });
updateEffortDropdownPosition();
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('resize', updateEffortDropdownPosition);
window.removeEventListener('scroll', updateEffortDropdownPosition, true);
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [isEffortDropdownOpen, updateEffortDropdownPosition]);
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
@@ -376,6 +444,70 @@ export default function ChatComposer({
</div>
</button>
{availableEffortOptions.length > 0 && (
<div ref={effortDropdownRef} className="relative">
<button
ref={effortDropdownButtonRef}
type="button"
onClick={() => {
updateEffortDropdownPosition();
setIsEffortDropdownOpen((current) => !current);
}}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-muted/40 px-2 text-xs font-medium text-foreground transition-all duration-200 hover:bg-muted"
aria-haspopup="menu"
aria-expanded={isEffortDropdownOpen}
aria-label="Select reasoning effort"
title="Select reasoning effort"
>
<span className="hidden text-[11px] text-muted-foreground sm:inline">Effort</span>
<span className="max-w-16 truncate capitalize sm:max-w-20">{selectedEffortLabel}</span>
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isEffortDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isEffortDropdownOpen && effortDropdownPosition && createPortal(
<div
ref={effortDropdownMenuRef}
className="fixed z-[100] min-w-36 overflow-y-auto rounded-lg border border-border bg-card p-1 shadow-lg"
style={{
left: effortDropdownPosition.left,
top: effortDropdownPosition.top,
maxHeight: effortDropdownPosition.maxHeight,
transform: 'translateY(-100%)',
}}
role="menu"
>
{effortOptions.map((option) => {
const isSelected = option.value === effort;
const label = option.value === 'default' ? 'Default' : option.value;
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={isSelected}
onClick={() => {
onSelectEffort(option.value);
setIsEffortDropdownOpen(false);
}}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs capitalize transition-colors ${
isSelected
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/70 hover:text-foreground'
}`}
>
<span className="flex h-3 w-3 items-center justify-center">
{isSelected && <Check className="h-3 w-3 text-primary" />}
</span>
<span>{label}</span>
</button>
);
})}
</div>,
document.body,
)}
</div>
)}
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<PromptInputButton

View File

@@ -263,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]);
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) {

View File

@@ -4,6 +4,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
};
export type ProviderModelsDefinition = {