mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 20:44:50 +02:00
[eric] multi-model polish: updated model registry, Gemini thought-signature fix, collapsible model picker, UI fixes
This commit is contained in:
@@ -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 }} />
|
||||
|
||||
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user