[eric] multi-model polish: updated model registry, Gemini thought-signature fix, collapsible model picker, UI fixes

This commit is contained in:
ciregenz
2026-04-12 20:49:33 -07:00
parent 4778cea80e
commit bc2ca1f33a
7 changed files with 183 additions and 108 deletions
+76 -48
View File
@@ -227,6 +227,12 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (modesArr.length === 0) dispatch(fetchModes());
}, [dispatch, modesArr.length]);
// Collapsible provider groups in the model picker. The group containing
// the currently selected model is always expanded; others start collapsed
// when there are 3+ groups to keep the dropdown manageable.
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const toggleGroup = (prov: string) => setCollapsedGroups(prev => ({ ...prev, [prov]: !prev[prov] }));
const [images, setImages] = useState<AttachedImage[]>([]);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
@@ -1060,56 +1066,78 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
>
{Object.entries(allModelOptions.grouped).map(([prov, models]) => [
<MenuItem key={`header-${prov}`} disabled sx={{ opacity: '0.7 !important', py: 0.5, px: 1.5, minHeight: 'auto' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
{prov}
</Typography>
</MenuItem>,
...models.map((opt) => (
{Object.entries(allModelOptions.grouped).map(([prov, models]) => {
// Default: group with selected model starts expanded, others
// collapsed when 3+ groups. But user can manually toggle any
// group including the active one.
const groupCount = Object.keys(allModelOptions.grouped).length;
const hasSelectedModel = models.some(m => m.value === model);
const defaultCollapsed = hasSelectedModel ? false : (groupCount >= 3);
const isCollapsed = collapsedGroups[prov] ?? defaultCollapsed;
const modelCount = models.length;
return [
// Clickable group header with expand/collapse arrow
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
// Derive API-level provider key from the display group name
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'gemini',
// OpenRouter-backed providers
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
onProviderChange(providerMap[provLower] || provLower);
}
// Warn (once) when switching to a non-Claude model with
// many MCP tools enabled. Non-Claude models don't have
// access to the deferred-tool pool and will receive every
// tool schema upfront, potentially exhausting context.
if (prov.toLowerCase() !== 'anthropic' && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
try {
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
setMcpWarningOpen(true);
}
} catch { /* ignore localStorage errors */ }
}
setModelAnchor(null);
}}
key={`header-${prov}`}
onClick={(e) => { e.stopPropagation(); toggleGroup(prov); }}
sx={{ py: 0.5, px: 1.5, minHeight: 'auto', cursor: 'pointer', '&:hover': { bgcolor: `${c.bg.secondary}80` } }}
>
<ListItemText
primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)),
]).flat()}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: '100%', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
{prov}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isCollapsed && <Typography sx={{ fontSize: '0.58rem', color: c.text.ghost }}>{modelCount}</Typography>}
<KeyboardArrowDownIcon sx={{
fontSize: 12,
color: c.text.ghost,
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.15s ease',
}} />
</Box>
</Box>
</MenuItem>,
// Models (hidden when collapsed)
...(!isCollapsed ? models.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'gemini',
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
onProviderChange(providerMap[provLower] || provLower);
}
if (prov.toLowerCase() !== 'anthropic' && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
try {
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
setMcpWarningOpen(true);
}
} catch {}
}
setModelAnchor(null);
}}
>
<ListItemText
primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)) : []),
];
}).flat()}
</Menu>
<Box sx={{ flex: 1 }} />
+41 -36
View File
@@ -59,7 +59,7 @@ const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude, GPT, Gemini, and more', color: '#8B949E', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude, GPT, Gemini, and more', color: '#8B949E', preview: false },
];
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
@@ -257,48 +257,53 @@ const SubscriptionCards: React.FC = () => {
setPollTimer(devicePollTimer);
// Detect when the popup is closed (user may close it after seeing
// GitHub's "Congratulations" page). Give 9Router 3 seconds to
// process the token exchange, then do a final status check. If
// the connection still isn't found, reset the card so it doesn't
// stay stuck on "Waiting for authorization" forever — the root
// cause is a 9Router-side issue where the GitHub device-code poll
// sometimes fails to detect the token exchange completion.
const popupCloseCheck = setInterval(() => {
if (stopped) { clearInterval(popupCloseCheck); return; }
if (devicePopup && devicePopup.closed) {
clearInterval(popupCloseCheck);
setTimeout(async () => {
if (stopped) return;
// One last status check before giving up
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
onDeviceSuccess();
return;
}
} catch {}
// Connection not found — reset card instead of staying stuck
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatus();
}, 3000);
}
}, 1000);
// Detect when the user returns to the main window after
// interacting with the popup. In Electron, `popup.closed` is
// unreliable (the WindowProxy may not update when the child
// BrowserWindow is destroyed). Listening for `focus` on the
// main window is more robust — it fires when the user closes
// the popup, switches tabs, or clicks back on the app.
let focusCheckDone = false;
const onFocus = async () => {
if (stopped || focusCheckDone) return;
focusCheckDone = true;
window.removeEventListener('focus', onFocus);
// Give 9Router 3 seconds to process the token exchange
await new Promise(r => setTimeout(r, 3000));
if (stopped) return;
// Final status check
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
onDeviceSuccess();
return;
}
} catch {}
// Connection not found — reset card
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatus();
};
// Delay registering the focus listener so the initial popup
// open doesn't immediately trigger it (opening a popup blurs
// then refocuses the parent in some cases).
setTimeout(() => {
if (!stopped) window.addEventListener('focus', onFocus);
}, 2000);
// 5-minute hard timeout — clean up everything.
setTimeout(() => {
if (stopped) return;
stopped = true;
window.removeEventListener('focus', onFocus);
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
clearInterval(popupCloseCheck);
setPollTimer(null);
setConnecting(null);
setUserCode('');