[eric] subscription UI fixes: cards no longer flicker to the Starting-subscription-service spinner mid-task (is_running TTL cache + retry/poll on mount), show

Anthropic as its own picker group alongside OpenSwarm Pro when both are connected so users can pick per-request
This commit is contained in:
ciregenz
2026-04-16 11:12:27 -07:00
parent 1a68ccfc7b
commit 7eb85a7127
10 changed files with 477 additions and 54 deletions
+8 -2
View File
@@ -1043,8 +1043,14 @@ class AgentManager:
}
# Priority: openswarm-pro mode → Anthropic API key → 9Router.
# Non-Anthropic api_types always route through 9Router regardless.
# A resolved_model carrying a 9Router prefix (cc/cx/gc/gh/) also
# forces the 9Router branch — this is what makes pinned-route
# Anthropic values ("sonnet-cc" etc.) bypass the OpenSwarm Pro
# proxy and land on the user's own Claude subscription even while
# connection_mode is openswarm-pro.
from backend.apps.nine_router import is_running as _9r_running
if api_type == "anthropic" and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
resolved_is_9router = isinstance(resolved_model, str) and resolved_model.startswith(("cc/", "cx/", "gc/", "gh/"))
if api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(global_settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
bearer = getattr(global_settings, "openswarm_bearer_token", "") or ""
options_kwargs["env"] = {
@@ -1052,7 +1058,7 @@ class AgentManager:
"ANTHROPIC_BASE_URL": proxy_url,
}
logger.info(f"[MCP-DEBUG] Using OpenSwarm Pro proxy at {proxy_url}")
elif api_type == "anthropic" and global_settings.anthropic_api_key:
elif api_type == "anthropic" and not resolved_is_9router and global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
logger.info("[MCP-DEBUG] Using direct Anthropic API key")
elif _9r_running():
+42 -9
View File
@@ -323,25 +323,58 @@ async def list_models():
except Exception as e:
logger.debug(f"Failed to fetch 9Router providers: {e}")
def _serialize(models: list[dict]) -> list[dict]:
return [
{
"value": m["value"],
"label": m["label"],
"context_window": m.get("context_window", 128_000),
"reasoning": bool(m.get("reasoning", False)),
}
for m in models
]
has_api_key = bool(getattr(settings, "anthropic_api_key", None))
is_openswarm_pro = (
getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
and bool(getattr(settings, "openswarm_bearer_token", None))
)
has_claude_sub = "claude" in connected
result: dict[str, list[dict]] = {}
# Anthropic models: emit under "OpenSwarm Pro" (proxy-routed, adaptive
# values) and/or "Anthropic" (direct API key OR 9Router claude sub). When
# both the proxy and the personal claude sub are active we emit both
# groups, with the Anthropic group using the pinned "-cc" variants so a
# per-call selection actually routes through 9Router instead of the proxy.
anthropic_models = BUILTIN_MODELS.get("Anthropic", [])
adaptive = [m for m in anthropic_models if m.get("route") != "cc"]
cc_variants = [m for m in anthropic_models if m.get("route") == "cc"]
if is_openswarm_pro and has_claude_sub:
result["OpenSwarm Pro"] = _serialize(adaptive)
result["Anthropic"] = _serialize(cc_variants)
elif is_openswarm_pro:
result["OpenSwarm Pro"] = _serialize(adaptive)
elif has_api_key or has_claude_sub:
result["Anthropic"] = _serialize(adaptive)
# Non-Anthropic providers (OpenAI, Google, OpenSwarm/Copilot, etc.) —
# visibility is gated by 9Router's connected providers set.
for provider_name, models in BUILTIN_MODELS.items():
if provider_name == "Anthropic":
continue
visible = []
for m in models:
api = m.get("api", "")
# GitHub Copilot is not yet available to end users — hide its models.
if api == "github-copilot":
# Hidden from end users for now — `gh/` path lives in the
# registry but the Copilot subscription card is "Coming soon".
continue
if m.get("subscription_only"):
if not nine_router_up or api not in connected:
continue
elif api == "anthropic":
has_key = bool(getattr(settings, "anthropic_api_key", None))
is_openswarm_pro = (
getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
and bool(getattr(settings, "openswarm_bearer_token", None))
)
if not has_key and "claude" not in connected and not is_openswarm_pro:
continue
visible.append({
"value": m["value"],
"label": m["label"],
+21
View File
@@ -63,12 +63,27 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# (Feb 5 2026), Haiku 4.5 (Oct 2025). All three are the current
# production flagships in their respective size tiers.
"Anthropic": [
# Adaptive entries: route is chosen at call time based on
# settings.connection_mode (openswarm-pro → proxy; api_key → direct;
# else → 9Router cc/).
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
# Pinned-subscription entries: always route via 9Router's `cc/` prefix
# (the user's personal Claude Pro/Max subscription), regardless of
# connection_mode. Surfaced in list_models only when the user has
# BOTH openswarm-pro active AND the 9Router `claude` subscription
# connected — so the model picker can offer a per-call choice between
# the managed OpenSwarm proxy and their own Claude subscription.
{"value": "sonnet-cc", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "opus-cc", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
],
# OpenAI: ChatGPT Plus/Pro (Codex) subscription. gpt-5.4 is the
# current flagship — combines GPT-5.3 Codex coding capabilities with
@@ -267,6 +282,12 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
entry = _find_builtin_model(short_name)
if entry is None:
return short_name
# Pinned-route entries (e.g. "sonnet-cc") always use their router_model_id,
# bypassing connection_mode. This is what lets the picker offer a
# distinct "Anthropic" group pointing at the user's 9Router Claude
# subscription even while openswarm-pro is the default Claude route.
if entry.get("route") == "cc":
return entry.get("router_model_id", entry.get("model_id", short_name))
if entry.get("api") == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return entry.get("model_id", short_name)
+19 -1
View File
@@ -12,6 +12,7 @@ import logging
import os
import shutil
import subprocess
import time
import httpx
@@ -24,12 +25,29 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
_process: subprocess.Popen | None = None
# Short TTL cache for positive is_running() results. The probe is a sync
# httpx.get that blocks the event loop, and under load (9Router busy
# streaming inference) it can exceed its 2s timeout and return False even
# though 9Router is fine. Caching a recent True result avoids those false
# negatives without masking a real crash for more than _IS_RUNNING_TTL seconds.
# Negative results are NOT cached so startup detection in ensure_running()
# remains correct.
_IS_RUNNING_TTL = 10.0
_is_running_last_ok: float = 0.0
def is_running() -> bool:
"""Check if 9Router is running."""
global _is_running_last_ok
now = time.monotonic()
if now - _is_running_last_ok < _IS_RUNNING_TTL:
return True
try:
r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0)
return r.status_code == 200
if r.status_code == 200:
_is_running_last_ok = now
return True
return False
except Exception:
return False
+12
View File
@@ -54,6 +54,18 @@ app.add_middleware(
allow_headers=["*"],
)
# Chrome's Private Network Access check: a page on https://api.openswarm.com
# POSTing to http://127.0.0.1:8324 triggers a preflight that requires this
# header. Without it the request is blocked and the post-checkout activation
# flow silently fails. Harmless on every other request — it's only read when
# the browser is crossing from a public origin into a private network.
@app.middleware("http")
async def _allow_private_network(request, call_next):
response = await call_next(request)
response.headers["Access-Control-Allow-Private-Network"] = "true"
return response
@app.websocket("/ws/agents/{session_id}")
async def websocket_session(websocket: WebSocket, session_id: str):
await ws_manager.connect_session(session_id, websocket)
@@ -47,8 +47,8 @@ function isValidEmail(email: string): boolean {
const SUBSCRIPTION_PROVIDERS = [
{ id: 'openswarm-pro', name: 'OpenSwarm Pro', desc: 'One subscription — no setup, no Claude account needed', color: '#6366F1', preview: false, recommended: true },
{ id: 'claude', name: 'Claude', desc: 'Use your own Claude Pro/Max subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro & Flash', color: '#4285F4', preview: false },
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
];
const USE_CASES = [
@@ -150,14 +150,22 @@ const OnboardingModal: React.FC = () => {
};
}, []);
// Auto-dismiss when a subscription activates via deep link while the
// modal is open. activateSubscription refetches settings; we watch the
// connection_mode field flipping to openswarm-pro.
// Auto-dismiss ONLY on the inactive → active transition (i.e. Stripe
// checkout deep-link activation while the modal is open). We used to fire
// on any settings tick where Pro was active, which meant a returning user
// who already had Pro (but cleared onboarding_seen) would see the modal
// flash then immediately skip to the dashboard.
const initialProActiveRef = useRef<boolean | null>(null);
useEffect(() => {
if (!open) return;
if (!open) { initialProActiveRef.current = null; return; }
const mode = (settings.data as any).connection_mode;
const bearer = (settings.data as any).openswarm_bearer_token;
if (mode === 'openswarm-pro' && bearer) {
const isActive = mode === 'openswarm-pro' && !!bearer;
if (initialProActiveRef.current === null) {
initialProActiveRef.current = isActive;
return;
}
if (!initialProActiveRef.current && isActive) {
trackEvent('onboarding.openswarm_pro_activated');
dismiss();
}
@@ -68,13 +68,31 @@ const STEPS: WalkthroughStep[] = [
},
{
target: '',
title: "You're all set!",
description: 'Start chatting with your AI assistants. Explore your workspace, connect your tools, and make it yours.',
title: 'Try your first task',
description: "OpenSwarm is most useful when you give it something real. Open a new chat and try one of these:",
placement: 'bottom',
centerOverlay: true,
},
];
const EXAMPLE_PROMPTS: { emoji: string; label: string; prompt: string }[] = [
{
emoji: '🔎',
label: 'Research a topic',
prompt: 'Research the latest developments in AI agents and give me a short briefing',
},
{
emoji: '🛠️',
label: 'Build a mini app',
prompt: 'Build me a simple habit tracker with a clean UI',
},
{
emoji: '📄',
label: 'Summarize a webpage',
prompt: 'Browse https://news.ycombinator.com and summarize the top 5 stories',
},
];
interface Props {
onComplete: () => void;
}
@@ -117,7 +135,13 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
if (step.centerOverlay) {
setSpotlightRect(null);
setTooltipPos({ top: window.innerHeight / 2 - 100, left: window.innerWidth / 2 - 180 });
const isAnnouncement = currentStep === STEPS.length - 1;
const w = isAnnouncement ? 600 : 320;
const h = isAnnouncement ? 640 : 200;
setTooltipPos({
top: Math.max(24, window.innerHeight / 2 - h / 2),
left: Math.max(24, window.innerWidth / 2 - w / 2),
});
setVisible(true);
return;
}
@@ -225,6 +249,26 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
onComplete();
}, [onComplete, currentStep, step]);
const handleStartFirstChat = useCallback((example?: { label: string; prompt: string }) => {
trackEvent('walkthrough.first_chat_started', { example: example?.label || 'cta' });
if (example?.prompt) {
try {
sessionStorage.setItem('openswarm_first_prompt', example.prompt);
} catch {}
} else {
try {
sessionStorage.removeItem('openswarm_first_prompt');
} catch {}
}
onComplete();
setTimeout(() => {
const btn = document.querySelector(
'[data-onboarding="new-agent-button"]',
) as HTMLElement | null;
btn?.click();
}, 150);
}, [onComplete]);
// Allow clicking the spotlight target to advance for action steps
useEffect(() => {
if (!step?.actionHint || step.centerOverlay) return;
@@ -305,18 +349,188 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
position: 'absolute',
top: tooltipPos.top,
left: tooltipPos.left,
width: 320,
width: isLastStep ? 600 : 320,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
p: 2.5,
p: isLastStep ? 0 : 2.5,
overflow: 'hidden',
transition: 'top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s',
opacity: visible ? 1 : 0,
pointerEvents: 'auto',
zIndex: 10000,
}}
>
{isLastStep ? (
<>
{/* Hero image area — soft pastel multi-color blob */}
<Box
sx={{
position: 'relative',
width: '100%',
height: 320,
background: `
radial-gradient(circle at 18% 78%, #F5A574 0%, rgba(245,165,116,0) 48%),
radial-gradient(circle at 58% 55%, #E9A5D0 0%, rgba(233,165,208,0) 52%),
radial-gradient(circle at 82% 22%, #B9C9F4 0%, rgba(185,201,244,0) 58%),
linear-gradient(135deg, #C4D0F2 0%, #EDB3CC 50%, #F5B088 100%)
`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<Typography
sx={{
position: 'relative',
zIndex: 2,
fontSize: 72,
color: '#fff',
filter: 'drop-shadow(0 4px 16px rgba(0,0,0,0.18))',
lineHeight: 1,
}}
>
</Typography>
</Box>
<Box sx={{ p: 2.25 }}>
{/* Step counter dots */}
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.25, justifyContent: 'center' }}>
{STEPS.map((_, i) => (
<Box
key={i}
sx={{
width: i === currentStep ? 14 : 4,
height: 4,
borderRadius: 3,
bgcolor: i === currentStep ? c.accent.primary : i < currentStep ? c.accent.primary + '60' : c.border.medium,
transition: 'all 0.3s',
}}
/>
))}
</Box>
<Box
sx={{
display: 'inline-block',
fontSize: '0.58rem',
fontWeight: 700,
color: c.accent.primary,
bgcolor: c.accent.primary + '1f',
px: 0.85,
py: 0.2,
borderRadius: `${c.radius.xs}px`,
letterSpacing: '0.5px',
mb: 0.75,
fontFamily: c.font.sans,
}}
>
READY TO TRY
</Box>
<Typography
sx={{
fontSize: '0.92rem',
fontWeight: 700,
color: c.text.primary,
mb: 0.35,
fontFamily: c.font.sans,
}}
>
{step.title}
</Typography>
<Typography
sx={{
fontSize: '0.72rem',
color: c.text.secondary,
lineHeight: 1.45,
mb: 1.25,
fontFamily: c.font.sans,
}}
>
{step.description}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
{EXAMPLE_PROMPTS.map((ex) => (
<Box
key={ex.label}
onClick={() => handleStartFirstChat(ex)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.75,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.elevated || c.bg.surface,
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: c.accent.primary,
bgcolor: c.accent.primary + '10',
transform: 'translateX(2px)',
},
}}
>
<Box sx={{ fontSize: '0.95rem', lineHeight: 1 }}>{ex.emoji}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: c.text.primary, fontFamily: c.font.sans }}>
{ex.label}
</Typography>
<Typography sx={{ fontSize: '0.64rem', color: c.text.tertiary, fontFamily: c.font.sans, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ex.prompt}
</Typography>
</Box>
<Typography sx={{ fontSize: '0.8rem', color: c.text.tertiary, fontFamily: c.font.sans }}></Typography>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button
onClick={handleSkip}
sx={{
textTransform: 'none',
fontSize: '0.72rem',
fontWeight: 600,
color: c.text.tertiary,
borderRadius: `${c.radius.md}px`,
px: 1.25,
py: 0.5,
fontFamily: c.font.sans,
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
Not now
</Button>
<Button
onClick={() => handleStartFirstChat()}
sx={{
textTransform: 'none',
fontSize: '0.72rem',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: `${c.radius.md}px`,
px: 2,
py: 0.5,
fontFamily: c.font.sans,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
}}
>
Start a new chat
</Button>
</Box>
</Box>
</>
) : (
<>
{/* Step counter dots */}
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.5, justifyContent: 'center' }}>
{STEPS.map((_, i) => (
@@ -409,6 +623,8 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
{isLastStep ? 'Get Started' : 'Next'}
</Button>
</Box>
</>
)}
</Box>
</Box>
);
+103 -12
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react';
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle } from 'react';
import Box from '@mui/material/Box';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
@@ -192,6 +192,43 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}, []);
const [hasContent, setHasContent] = useState(() => !!_draftStore.get(ownerId));
// Ghost prompt suggestion from onboarding — typed out animated, click or press Enter to run.
const [ghostPrompt, setGhostPrompt] = useState<string | null>(null);
const [ghostTyped, setGhostTyped] = useState(0);
const ghostOverlayRef = useRef<HTMLDivElement>(null);
const [ghostHeight, setGhostHeight] = useState(0);
useEffect(() => {
let pending: string | null = null;
try {
pending = sessionStorage.getItem('openswarm_first_prompt');
if (pending) sessionStorage.removeItem('openswarm_first_prompt');
} catch {}
if (!pending) return;
if (_draftStore.get(ownerId)) return; // user already has a draft; don't overwrite
setGhostPrompt(pending);
setGhostTyped(0);
let i = 0;
const id = window.setInterval(() => {
i += 1;
setGhostTyped(i);
if (i >= pending!.length) window.clearInterval(id);
}, 29);
return () => window.clearInterval(id);
// Only on mount; ownerId is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Measure the ghost overlay so the editor can reserve matching height and
// the toolbar row below doesn't get overlapped when the ghost text wraps.
useLayoutEffect(() => {
if (!ghostPrompt || hasContent) { setGhostHeight(0); return; }
const el = ghostOverlayRef.current;
if (!el) return;
const h = el.offsetHeight;
setGhostHeight((prev) => (prev === h ? prev : h));
}, [ghostPrompt, ghostTyped, hasContent]);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const attachedSkillsRef = useRef(attachedSkills);
attachedSkillsRef.current = attachedSkills;
@@ -227,23 +264,22 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
const MCP_WARNING_THRESHOLD = 20;
// Build flat model list with provider grouping. When in openswarm-pro mode
// the Anthropic section is renamed to "OpenSwarm Pro" so the user sees their
// paid subscription is powering the Claude models.
// Build flat model list with provider grouping. Group names come from the
// backend's /agents/models response verbatim — "OpenSwarm Pro" for
// proxy-routed Claude, "Anthropic" for direct/subscription-routed Claude,
// plus the non-Anthropic providers. Only the pre-load fallback still needs
// to pick a label since no models have been fetched yet.
const allModelOptions = useMemo(() => {
const isPro = connectionMode === 'openswarm-pro';
const renameAnthropic = (prov: string) => (isPro && prov === 'Anthropic' ? 'OpenSwarm Pro' : prov);
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
const key = renameAnthropic('Anthropic');
const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic';
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } };
}
const flat: Array<{ value: string; label: string; context_window: number; provider: string; reasoning: boolean }> = [];
const grouped: Record<string, Array<{ value: string; label: string; context_window: number; reasoning: boolean }>> = {};
for (const [prov, models] of Object.entries(modelsByProvider)) {
const key = renameAnthropic(prov);
grouped[key] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
for (const m of models) {
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: key, reasoning: !!m.reasoning });
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning });
}
}
return { flat, grouped };
@@ -472,6 +508,25 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
elementSelection?.clearOwnerElements(ownerId);
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]);
const acceptGhostPrompt = useCallback(() => {
const editor = editorRef.current;
if (!editor || !ghostPrompt) return;
editor.textContent = ghostPrompt;
setGhostPrompt(null);
setHasContent(true);
_draftStore.set(ownerId, editor.innerHTML);
setTimeout(() => {
editor.focus();
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
handleSend();
}, 0);
}, [ghostPrompt, ownerId, handleSend]);
const detectTrigger = useCallback(() => {
const result = detectEditorTrigger();
if (result) {
@@ -576,6 +631,10 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}
if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) {
e.preventDefault();
if (ghostPrompt && !hasContent) {
acceptGhostPrompt();
return;
}
handleSend();
}
};
@@ -967,7 +1026,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
onPaste={handlePaste}
style={{
width: '100%',
minHeight: '1.5em',
minHeight: ghostPrompt && !hasContent && ghostHeight > 0 ? `${ghostHeight}px` : '1.5em',
maxHeight: 200,
overflowY: 'auto',
background: 'transparent',
@@ -981,7 +1040,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
whiteSpace: 'pre-wrap',
}}
/>
{!hasContent && (
{!hasContent && !ghostPrompt && (
<div
style={{
position: 'absolute',
@@ -1000,6 +1059,38 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeConf.label}, @ for context, / for commands`}
</div>
)}
{!hasContent && ghostPrompt && (
<div
ref={ghostOverlayRef}
onClick={acceptGhostPrompt}
title="Press Enter or click to run"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: `${hasAttachments ? 4 : 10}px 12px`,
color: c.text.secondary,
opacity: 0.55,
fontSize: '0.875rem',
lineHeight: '1.5',
fontFamily: 'inherit',
cursor: 'pointer',
userSelect: 'none',
}}
>
{ghostPrompt.slice(0, ghostTyped)}
{ghostTyped < ghostPrompt.length && (
<span style={{ opacity: 0.8, animation: 'openswarm-ghost-caret 0.9s steps(1) infinite' }}></span>
)}
{ghostTyped >= ghostPrompt.length && (
<span style={{ marginLeft: 8, fontSize: '0.7rem', color: c.text.tertiary, opacity: 0.9 }}>
to run
</span>
)}
<style>{`@keyframes openswarm-ghost-caret { 50% { opacity: 0; } }`}</style>
</div>
)}
</Box>
<Box
+35 -17
View File
@@ -59,7 +59,6 @@ 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 },
];
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 }) => {
@@ -326,15 +325,6 @@ const OpenSwarmProCard: React.FC = () => {
>
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
</Button>
<Button
onClick={handleDisconnect}
disabled={busy !== null}
size="small"
variant="text"
sx={{ textTransform: 'none', fontSize: '0.78rem', color: c.text.muted }}
>
{busy === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
</Button>
</Box>
</>
) : (
@@ -365,12 +355,25 @@ const SubscriptionCards: React.FC = () => {
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
const fetchStatus = () => {
fetch(`${API_BASE}/agents/subscriptions/status`)
.then(r => r.json())
.then(setStatus)
.catch(() => setStatus({ running: false, providers: [], models: [] }));
};
// `preserveTransient` keeps a previously-seen `running: true` state when a
// refresh comes back with `running: false`. The backend's is_running() probe
// has a short sync timeout that can be exceeded while 9Router is streaming
// inference, producing false negatives that would otherwise flip these
// cards into a "Starting subscription service..." spinner mid-session.
const fetchStatus = useCallback(async (opts?: { preserveTransient?: boolean }) => {
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
const data = await r.json();
setStatus((prev: any) => {
if (opts?.preserveTransient && prev?.running && !data?.running) return prev;
return data;
});
return data;
} catch {
setStatus((prev: any) => prev ?? { running: false, providers: [], models: [] });
return null;
}
}, []);
// Refresh the chat model picker whenever subscription connection state
// changes — GET /agents/models intersects BUILTIN_MODELS with 9Router's
@@ -378,7 +381,22 @@ const SubscriptionCards: React.FC = () => {
// their models in the dropdown immediately.
const refreshPickerModels = () => { dispatch(fetchModels()); };
useEffect(() => { fetchStatus(); }, []);
useEffect(() => {
let cancelled = false;
(async () => {
// Retry initial load — a single transient probe miss on mount would
// otherwise wedge the UI on the loading spinner until the user closes
// and reopens Settings.
for (const delay of [0, 800, 2000]) {
if (cancelled) return;
if (delay) await new Promise(r => setTimeout(r, delay));
const data = await fetchStatus();
if (data?.running) break;
}
})();
const interval = setInterval(() => fetchStatus({ preserveTransient: true }), 30000);
return () => { cancelled = true; clearInterval(interval); };
}, [fetchStatus]);
const isConnected = (providerId: string) => {
if (!status?.providers) return false;
File diff suppressed because one or more lines are too long