[eric] reasoning UI + thinking-level controls: collapsible 'Thought for Ns' bubble with shimmer, per-session thinking level (off/low/med/high/auto) on

reasoning-capable models, gate GitHub Copilot as 'Coming soon', delete Twitter/xbird integration, fix model-picker first-click + 200K context cap on Sonnet/Opus, revive
  arrow-key dashboard nav after typing/clicking/zooming
This commit is contained in:
ciregenz
2026-04-15 12:47:34 -07:00
parent bc2ca1f33a
commit 14e1d02bbb
18 changed files with 499 additions and 161 deletions
+25 -1
View File
@@ -1166,6 +1166,27 @@ class AgentManager:
if session.cwd:
options_kwargs["cwd"] = session.cwd
# Apply the session's thinking_level. Claude SDK accepts both a
# `thinking` config and a simple `effort` level. "auto" is the
# default path — we still enable adaptive thinking for Claude
# 4.6 so reasoning bubbles surface. For non-Claude models, the
# reasoning params are applied by 9Router (see resolve_model_id).
try:
level = getattr(session, "thinking_level", "auto") or "auto"
if api_type == "anthropic":
if level == "off":
options_kwargs["thinking"] = {"type": "disabled"}
elif level == "auto":
# Keep existing behavior — let the SDK / Claude Code
# preset decide. Don't force adaptive here because
# some 9Router-relayed paths may choke on unknown
# thinking config shapes.
pass
elif level in ("low", "medium", "high"):
options_kwargs["effort"] = level
except Exception as e:
logger.debug(f"thinking_level param injection skipped: {e}")
if session.sdk_session_id:
options_kwargs["resume"] = session.sdk_session_id
if fork_session or session.needs_fork:
@@ -1973,9 +1994,12 @@ class AgentManager:
if not session:
raise ValueError(f"Session {session_id} not found")
allowed = {"system_prompt", "name"}
allowed = {"system_prompt", "name", "thinking_level"}
for key, value in fields.items():
if key in allowed:
# Defend against bad thinking_level values
if key == "thinking_level" and value not in ("off", "low", "medium", "high", "auto"):
continue
setattr(session, key, value)
await ws_manager.send_to_session(session_id, "agent:status", {
+4
View File
@@ -328,6 +328,9 @@ async def list_models():
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":
continue
if m.get("subscription_only"):
if not nine_router_up or api not in connected:
continue
@@ -339,6 +342,7 @@ async def list_models():
"value": m["value"],
"label": m["label"],
"context_window": m.get("context_window", 128_000),
"reasoning": bool(m.get("reasoning", False)),
})
if visible:
result[provider_name] = visible
+10
View File
@@ -77,3 +77,13 @@ class AgentSession(BaseModel):
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
needs_fork: bool = False
# How much the model should "think" before answering. Provider-agnostic
# value that gets translated per-API in agent_manager:
# off — no thinking
# low — minimal thinking (fastest)
# medium — balanced
# high — extensive thinking (slowest, smartest)
# auto — let the model / provider default decide (recommended)
# Only applies to models flagged with reasoning: True in the registry.
# Existing sessions without this field will default to "auto".
thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
+58 -3
View File
@@ -64,11 +64,11 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# production flagships in their respective size tiers.
"Anthropic": [
{"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"},
"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"},
"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"},
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
],
# OpenAI: ChatGPT Plus/Pro (Codex) subscription. gpt-5.4 is the
# current flagship — combines GPT-5.3 Codex coding capabilities with
@@ -151,6 +151,61 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
],
}
# ---------------------------------------------------------------------------
# Thinking level translation
# ---------------------------------------------------------------------------
# Each provider has a different API shape for "how hard should the model
# think." We expose a single provider-agnostic level (off/low/medium/high/
# auto) on the session and translate here.
#
# Returns the provider-specific payload to merge into request params, or
# None if no special thinking params should be sent (use defaults).
def thinking_params_for(api: str, level: str, model_id: str = "") -> dict | None:
"""Translate a provider-agnostic thinking level to per-provider API params.
Args:
api: "anthropic" | "codex" | "gemini-cli" | "github-copilot"
level: "off" | "low" | "medium" | "high" | "auto"
model_id: optional, used to pick adaptive vs legacy for Claude
Returns a dict to merge into request params, or None for "use defaults".
"""
if level == "auto":
# Let provider use its own default. For Claude 4.6 we still want
# adaptive thinking on by default so users see reasoning.
if api == "anthropic":
return {"thinking": {"type": "adaptive"}}
return None
if level == "off":
if api == "anthropic":
return {"thinking": {"type": "disabled"}}
if api == "codex":
return {"reasoning": {"effort": "none"}}
# Gemini: lowest available level
if api == "gemini-cli":
return {"thinkingConfig": {"thinkingLevel": "LOW"}}
return None
# Claude 4.6 models use adaptive thinking (no manual budget). For older
# Claude models we'd use budget_tokens; we don't ship those today.
if api == "anthropic":
return {"thinking": {"type": "adaptive"}}
if api == "codex":
effort_map = {"low": "low", "medium": "medium", "high": "high"}
return {"reasoning": {"effort": effort_map[level]}}
if api == "gemini-cli":
level_map = {"low": "LOW", "medium": "MEDIUM", "high": "HIGH"}
return {"thinkingConfig": {"thinkingLevel": level_map[level]}}
# github-copilot goes through 9Router and doesn't expose a thinking
# param in its Copilot catalog — leave untouched.
return None
# ---------------------------------------------------------------------------
# OpenRouter: built-in integration for 300+ models
# ---------------------------------------------------------------------------
-79
View File
@@ -353,80 +353,11 @@ async def create_tool(body: ToolCreate):
return {"ok": True, "tool": tool.model_dump()}
_XBIRD_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "xbird")
_XBIRD_CONFIG_PATH = os.path.join(_XBIRD_CONFIG_DIR, "config.json")
async def _fetch_twitter_screen_name(auth_token: str, ct0: str) -> str | None:
"""Fetch the logged-in Twitter/X screen name using session cookies."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://api.twitter.com/1.1/account/verify_credentials.json",
headers={"x-csrf-token": ct0},
cookies={"auth_token": auth_token, "ct0": ct0},
)
if resp.status_code == 200:
screen_name = resp.json().get("screen_name")
return f"@{screen_name}" if screen_name else None
except Exception as e:
logger.warning("Failed to fetch Twitter screen name: %s", e)
return None
def _sync_external_config(tool: ToolDefinition):
"""Write credentials to external config files for tools that need them.
xbird reads auth from ~/.config/xbird/config.json rather than env vars,
so we sync credentials there when the user connects via the UI.
"""
if tool.name == "xbird" and tool.credentials:
auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "")
ct0 = tool.credentials.get("TWITTER_CT0", "")
if auth_token and ct0:
os.makedirs(_XBIRD_CONFIG_DIR, exist_ok=True)
config = {}
if os.path.exists(_XBIRD_CONFIG_PATH):
try:
with open(_XBIRD_CONFIG_PATH) as f:
config = json.load(f)
except Exception:
pass
config["auth_token"] = auth_token
config["ct0"] = ct0
with open(_XBIRD_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
os.chmod(_XBIRD_CONFIG_PATH, 0o600)
logger.info("Synced xbird credentials to %s", _XBIRD_CONFIG_PATH)
elif tool.name == "xbird" and not tool.credentials:
if os.path.exists(_XBIRD_CONFIG_PATH):
try:
with open(_XBIRD_CONFIG_PATH) as f:
config = json.load(f)
config.pop("auth_token", None)
config.pop("ct0", None)
with open(_XBIRD_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
logger.info("Cleared xbird credentials from %s", _XBIRD_CONFIG_PATH)
except Exception:
pass
@tools_lib.router.put("/{tool_id}")
async def update_tool(tool_id: str, body: ToolUpdate):
tool = _load(tool_id)
for k, v in body.model_dump(exclude_none=True).items():
setattr(tool, k, v)
_sync_external_config(tool)
if tool.name == "xbird" and tool.auth_status == "connected" and tool.credentials:
auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "")
ct0 = tool.credentials.get("TWITTER_CT0", "")
if auth_token and ct0:
screen_name = await _fetch_twitter_screen_name(auth_token, ct0)
if screen_name:
tool.connected_account_email = screen_name
_save(tool)
return {"ok": True, "tool": tool.model_dump()}
@@ -674,16 +605,6 @@ _SERVICE_RULES: list[tuple[list[str], str, str]] = [
(["post_detail"], "Posts", "Reddit"),
(["user_analysis"], "Users", "Reddit"),
(["reddit_explain"], "Reference", "Reddit"),
# Twitter / X
(["tweet", "thread", "reply", "replies", "quote", "retweet", "article"], "Tweets", "Twitter"),
(["timeline", "home", "news", "trending"], "Timeline", "Twitter"),
(["follower", "following", "follow", "unfollow"], "Network", "Twitter"),
(["like", "unlike", "bookmark"], "Engagement", "Twitter"),
(["mention"], "Mentions", "Twitter"),
(["user", "profile"], "Users", "Twitter"),
(["media", "upload", "image", "video"], "Media", "Twitter"),
(["search"], "Search", "Twitter"),
(["list", "list_member"], "Lists", "Twitter"),
]
@@ -19,12 +19,6 @@ import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { fetchSkills } from '@/shared/state/skillsSlice';
const XLogoIcon: React.FC<{ sx?: object }> = ({ sx }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</SvgIcon>
);
const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
@@ -41,7 +35,6 @@ const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => (
);
const TOOL_GROUP_ICONS: Record<string, React.FC<{ sx?: object }>> = {
Twitter: XLogoIcon,
Google: GoogleIcon,
Reddit: RedditIcon,
Web: LanguageIcon,
+21 -2
View File
@@ -29,6 +29,8 @@ import {
setActiveSession,
updateSessionModel,
updateSessionMode,
updateSessionThinkingLevel,
updateThinkingLevel,
fetchSession,
AgentMessage,
} from '@/shared/state/agentsSlice';
@@ -135,6 +137,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
@@ -364,6 +367,12 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
}, [id, isDraft, dispatch]);
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
if (!id) return;
dispatch(updateSessionThinkingLevel({ sessionId: id, level }));
if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level }));
}, [id, isDraft, dispatch]);
const handleApprove = (requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
};
@@ -482,7 +491,15 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}, [id, dispatch, onBranch, session?.dashboard_id]);
const contextEstimate = useMemo(() => {
const limit = CONTEXT_WINDOWS[model] || 200_000;
// Look up the actual context window from the models store (backend
// registry is the source of truth). Fall back to the legacy hardcoded
// map for any model that isn't in the store yet.
let limit = 0;
for (const ms of Object.values(modelsByProvider)) {
const hit = ms.find((m) => m.value === model);
if (hit?.context_window) { limit = hit.context_window; break; }
}
if (!limit) limit = CONTEXT_WINDOWS[model] || 200_000;
let totalChars = 0;
if (session?.system_prompt) totalChars += session.system_prompt.length;
for (const msg of activeBranchMessages) {
@@ -493,7 +510,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}
const used = Math.round(totalChars / 4);
return { used, limit };
}, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]);
}, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model, modelsByProvider]);
const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval';
@@ -1177,6 +1194,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
contextEstimate={contextEstimate}
sessionId={id}
autoFocus={autoFocus}
thinkingLevel={session?.thinking_level ?? 'auto'}
onThinkingLevelChange={handleThinkingLevelChange}
/>
</Box>
</ClickAwayListener>
@@ -51,7 +51,6 @@ const RedditIcon = (
const INTEGRATION_META: Record<string, IntegrationMeta> = {
'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon },
'xbird': { label: 'X / Twitter', color: '#1DA1F2', icon: <span style={{ fontSize: 14, fontWeight: 700 }}>𝕏</span> },
'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon },
};
+124 -35
View File
@@ -14,6 +14,7 @@ import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import StopIcon from '@mui/icons-material/Stop';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
@@ -77,6 +78,8 @@ interface Props {
autoFocus?: boolean;
sessionId?: string;
queueLength?: number;
thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto';
onThinkingLevelChange?: (level: 'off' | 'low' | 'medium' | 'high' | 'auto') => void;
}
export interface ChatInputHandle {
@@ -100,9 +103,9 @@ const ICON_MAP: Record<string, React.ReactNode> = {
const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
const FALLBACK_MODELS = [
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 },
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 },
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 },
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000, reasoning: true },
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000, reasoning: true },
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000, reasoning: true },
];
function formatTokenCount(n: number): string {
@@ -139,7 +142,23 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
// Brand colors for provider headers in the model picker — these match
// the SubscriptionCard colors in Settings and help users distinguish
// groups at a glance.
const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
openai: '#74AA9C',
google: '#4285F4',
gemini: '#4285F4',
xai: '#8B949E',
meta: '#0866FF',
deepseek: '#4D6BFE',
mistral: '#FF7000',
qwen: '#A974FF',
cohere: '#FF7759',
};
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -212,12 +231,12 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
}
const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = [];
const grouped: Record<string, Array<{ value: string; label: string; context_window: number }>> = {};
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)) {
grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 }));
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: prov });
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning });
}
}
return { flat, grouped };
@@ -231,7 +250,11 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
// 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] }));
// Toggle based on the *effective* collapsed state (which can come from
// the default), not the raw stored value. Otherwise the first click on
// a group that was defaulted-collapsed is a no-op (undefined → true).
const toggleGroup = (prov: string, currentlyCollapsed: boolean) =>
setCollapsedGroups(prev => ({ ...prev, [prov]: !currentlyCollapsed }));
const [images, setImages] = useState<AttachedImage[]>([]);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
@@ -260,6 +283,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const [modeAnchor, setModeAnchor] = useState<HTMLElement | null>(null);
const [modelAnchor, setModelAnchor] = useState<HTMLElement | null>(null);
const [thinkingAnchor, setThinkingAnchor] = useState<HTMLElement | null>(null);
const currentMode = modesMap[mode];
const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary };
@@ -1067,39 +1091,39 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
slotProps={{ paper: menuPaperProps }}
>
{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;
// Non-interactive provider headers followed by their models.
// All groups always shown — no collapse/expand. Keeping the
// menu layout static avoids the "cursor chases a moving
// target" problem that happens when items above the cursor
// appear/disappear.
const brandColor = PROVIDER_COLORS[prov.toLowerCase()] ?? c.text.tertiary;
return [
// Clickable group header with expand/collapse arrow
<MenuItem
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` } }}
disabled
sx={{ opacity: '1 !important', py: 0.75, px: 1.5, minHeight: 'auto', pointerEvents: 'none' }}
>
<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 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: brandColor,
boxShadow: `0 0 6px ${brandColor}80`,
flexShrink: 0,
}} />
<Typography sx={{
fontSize: '0.7rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: brandColor,
}}>
{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) => (
...models.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
@@ -1135,11 +1159,76 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)) : []),
)),
];
}).flat()}
</Menu>
{/* Thinking-level picker — only rendered for reasoning-capable models */}
{(() => {
const currentModel = allModelOptions.flat.find((m: any) => m.value === model) as any;
if (!currentModel?.reasoning || !onThinkingLevelChange) return null;
const levels: Array<{ value: 'off' | 'low' | 'medium' | 'high' | 'auto'; label: string; desc: string }> = [
{ value: 'auto', label: 'Auto', desc: 'Model decides (recommended)' },
{ value: 'off', label: 'Off', desc: 'No thinking (fastest)' },
{ value: 'low', label: 'Low', desc: 'Minimal thinking' },
{ value: 'medium', label: 'Medium', desc: 'Balanced' },
{ value: 'high', label: 'High', desc: 'Extensive thinking (slowest)' },
];
const current = levels.find((l) => l.value === thinkingLevel) || levels[0];
return (
<>
<Box
onClick={(e) => setThinkingAnchor(e.currentTarget)}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.25,
px: 0.75, py: 0.25, borderRadius: '6px', cursor: 'pointer', userSelect: 'none',
color: c.text.muted,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' },
transition: 'background 0.15s',
}}
>
<PsychologyOutlinedIcon sx={{ fontSize: 13, opacity: 0.7 }} />
<Typography sx={{ fontSize: '0.72rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{current.label}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
</Box>
<Menu
anchorEl={thinkingAnchor}
open={Boolean(thinkingAnchor)}
onClose={() => setThinkingAnchor(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
>
<MenuItem disabled sx={{ opacity: '1 !important', py: 0.5, px: 1.5, minHeight: 'auto', pointerEvents: 'none' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: c.text.tertiary }}>
Thinking Level
</Typography>
</MenuItem>
{levels.map((lvl) => (
<MenuItem
key={lvl.value}
selected={thinkingLevel === lvl.value}
onClick={() => { onThinkingLevelChange(lvl.value); setThinkingAnchor(null); }}
sx={{ py: 0.6 }}
>
<Box>
<Typography sx={{ fontSize: '0.8rem', color: thinkingLevel === lvl.value ? c.text.primary : c.text.muted }}>
{lvl.label}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, mt: 0.1 }}>
{lvl.desc}
</Typography>
</Box>
</MenuItem>
))}
</Menu>
</>
);
})()}
<Box sx={{ flex: 1 }} />
{contextEstimate && (
@@ -29,6 +29,17 @@ const streamingCursorKeyframes = `
}
`;
// Claude.ai-style shimmer that sweeps left → right across text while the
// model is actively thinking. Uses background-clip: text to mask a moving
// linear gradient onto the text glyphs so the effect looks like a light
// wave traveling through the letters.
const thinkingShimmerKeyframes = `
@keyframes thinking-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`;
const StreamingCursor: React.FC = () => {
const c = useClaudeTokens();
return (
@@ -388,6 +399,145 @@ const MessageImageThumbnails: React.FC<{
);
};
// ── ThinkingBubble ──────────────────────────────────────────────────
// Collapsible reasoning section styled after Claude.ai / ChatGPT /
// Gemini. Defaults to expanded so thinking is always visible when
// present. User can click the header to collapse. If we observed the
// stream live we show "Thought for Ns"; otherwise (history replay) we
// just show "Thoughts".
const ThinkingBubble: React.FC<{
content: string;
isStreaming?: boolean;
timestamp?: string;
}> = ({ content, isStreaming }) => {
const c = useClaudeTokens();
// Only time a think-session that we actually saw start live. For saved
// messages loaded from history, we don't have reliable start/end, so
// we fall back to a generic "Thoughts" label.
const [startedStreamingAt, setStartedStreamingAt] = useState<number | null>(
isStreaming ? Date.now() : null
);
const [elapsed, setElapsed] = useState<number>(0);
const [frozenElapsed, setFrozenElapsed] = useState<number | null>(null);
// Record start time the first time we see streaming
React.useEffect(() => {
if (isStreaming && startedStreamingAt === null) {
setStartedStreamingAt(Date.now());
}
}, [isStreaming, startedStreamingAt]);
// Tick the timer while streaming
React.useEffect(() => {
if (!isStreaming || startedStreamingAt === null) return;
const iv = setInterval(() => {
setElapsed(Math.floor((Date.now() - startedStreamingAt) / 1000));
}, 250);
return () => clearInterval(iv);
}, [isStreaming, startedStreamingAt]);
// Freeze elapsed when streaming ends
React.useEffect(() => {
if (!isStreaming && startedStreamingAt !== null && frozenElapsed === null) {
setFrozenElapsed(Math.max(1, Math.floor((Date.now() - startedStreamingAt) / 1000)));
}
}, [isStreaming, startedStreamingAt, frozenElapsed]);
// Always default to expanded — user can click to collapse
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const expanded = userOverride ?? true;
const toggle = () => setUserOverride(!expanded);
const displayedSeconds = frozenElapsed ?? elapsed;
const label = isStreaming
? 'Thinking...'
: startedStreamingAt !== null
? `Thought for ${displayedSeconds}s`
: 'Thoughts';
const text = typeof content === 'string' ? content : JSON.stringify(content);
// Shimmer colors — use a bright mid-tone against the muted base to make
// the sweep visible without being loud. The base color matches the
// static "Thought for Ns" state so the only visible change is the moving
// highlight band.
const shimmerBase = c.text.tertiary;
const shimmerHighlight = c.text.primary;
return (
<Box sx={{ my: 0.5 }}>
<style>{thinkingShimmerKeyframes}</style>
<Box
onClick={toggle}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
cursor: 'pointer',
color: c.text.tertiary,
fontSize: '0.78rem',
py: 0.5,
px: 1,
ml: -1,
borderRadius: `${c.radius.sm}px`,
transition: 'all 0.15s ease',
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
userSelect: 'none',
}}
>
<PsychologyOutlinedIcon sx={{ fontSize: 14, opacity: 0.75 }} />
<Typography
sx={{
fontSize: '0.78rem',
fontWeight: 500,
...(isStreaming ? {
// Moving gradient masked onto the text glyphs
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: 'transparent',
animation: 'thinking-shimmer 2s linear infinite',
} : { color: 'inherit' }),
}}
>
{label}
</Typography>
<ExpandMoreIcon
sx={{
fontSize: 16,
opacity: 0.6,
transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s ease',
}}
/>
</Box>
<Collapse in={expanded} timeout={200}>
<Box
sx={{
mt: 0.5,
ml: 0.5,
pl: 1.5,
borderLeft: `2px solid ${c.border.subtle}`,
color: c.text.tertiary,
fontSize: '0.85rem',
lineHeight: 1.55,
fontStyle: 'normal',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: c.font.sans,
}}
>
{text}
{isStreaming && <StreamingCursor />}
</Box>
</Collapse>
</Box>
);
};
interface Props {
message: AgentMessage;
editing?: boolean;
@@ -411,6 +561,16 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
);
}
if (role === 'thinking') {
return (
<ThinkingBubble
content={typeof content === 'string' ? content : JSON.stringify(content)}
isStreaming={isStreaming}
timestamp={message.timestamp}
/>
);
}
if (role === 'tool_call') {
const toolData = typeof content === 'object' ? content : {};
const toolInput = toolData.input || {};
+66 -12
View File
@@ -378,6 +378,14 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
// Canvas click — drop any lingering input focus so arrow-key nav
// works immediately without the user having to press Escape first.
const active = document.activeElement as HTMLElement | null;
const activeTag = active?.tagName;
if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (active as any)?.isContentEditable) {
active?.blur?.();
}
if (isElementSelectMode) {
if (e.metaKey || e.ctrlKey) {
canvas.handlers.onMouseDown(e);
@@ -960,7 +968,10 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
// Compute which directions have neighbors from the focused card
const neighborDirections = useMemo(() => {
if (!focusedCardId || canvas.zoom < 0.9) return { left: false, right: false, up: false, down: false };
// Lowered the zoom floor from 0.9 to 0.4 so arrow nav still works
// when users zoom out to see the whole canvas. Below 0.4 the cards
// are too small to be a useful navigation target.
if (!focusedCardId || canvas.zoom < 0.4) return { left: false, right: false, up: false, down: false };
return {
left: !!findNearestCard(focusedCardId, 'left'),
right: !!findNearestCard(focusedCardId, 'right'),
@@ -980,14 +991,38 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
canvasZoomRef.current = canvas.zoom;
useEffect(() => {
const handleArrowNav = (e: KeyboardEvent) => {
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
const currentFocused = focusedCardIdRef.current;
if (!currentFocused || canvasZoomRef.current < 0.9) return;
// Helper: is the currently-focused element a text-entry field the
// user is actively editing? We only want to suppress dashboard
// navigation when the user is genuinely typing, not just because an
// input somewhere happens to have focus from a click long ago.
const isActivelyEditing = (target: EventTarget | null): boolean => {
const el = (target as HTMLElement) || (document.activeElement as HTMLElement | null);
if (!el) return false;
const tag = el.tagName;
const editable = (el as any).isContentEditable;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !editable) return false;
// Only suppress when the input actually has content to navigate
// within. An empty input doesn't need arrow keys for cursor
// movement, so we can safely repurpose arrows for dashboard nav.
const val = (el as HTMLInputElement | HTMLTextAreaElement).value;
if (typeof val === 'string' && val.length === 0) return false;
if (editable && (el.textContent ?? '').length === 0) return false;
return true;
};
// Skip if typing in an input
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
const handleKey = (e: KeyboardEvent) => {
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
// Escape blurs any active input and restores focus to the canvas —
// so you can quickly "unstick" keyboard focus and start navigating.
if (e.key === 'Escape') {
const active = document.activeElement as HTMLElement | null;
const tag = active?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (active as any)?.isContentEditable) {
active?.blur?.();
}
return;
}
let direction: 'left' | 'right' | 'up' | 'down' | null = null;
switch (e.key) {
@@ -998,6 +1033,22 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
default: return;
}
// Don't hijack arrows when the user is actually typing
if (isActivelyEditing(e.target)) return;
// Lowered zoom floor from 0.9 → 0.4 so nav still works zoomed out
if (canvasZoomRef.current < 0.4) return;
// If no card is focused, pick the front-most one as a fallback so
// nav works after the user clicked on empty canvas.
let currentFocused = focusedCardIdRef.current;
if (!currentFocused) {
const anyCardId = Object.keys(cards)[0] || Object.keys(viewCards)[0] || Object.keys(browserCards)[0];
if (!anyCardId) return;
currentFocused = anyCardId;
setFocusedCardId(anyCardId);
}
e.preventDefault();
const target = findNearestCard(currentFocused, direction);
@@ -1027,9 +1078,12 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, 100);
};
window.addEventListener('keydown', handleArrowNav);
return () => window.removeEventListener('keydown', handleArrowNav);
}, [findNearestCard, getCardRect, canvas.actions, dispatch]);
// Capture phase so we beat MUI Menus/Selects that also listen for
// arrows. We still bail early on isActivelyEditing, so this doesn't
// interfere with typing.
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [findNearestCard, getCardRect, canvas.actions, dispatch, isActive, cards, viewCards, browserCards]);
const handleBranchFromCard = useCallback(
(sourceSessionId: string, newSessionId: string) => {
@@ -1906,7 +1960,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
</Box>
{/* Arrow navigation hints when zoomed in on a card */}
{focusedCardId && canvas.zoom >= 0.9 && (
{focusedCardId && canvas.zoom >= 0.4 && (
<DirectionHints
hasLeft={neighborDirections.left}
hasRight={neighborDirections.right}
@@ -95,6 +95,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
const [thinkingLevel, setThinkingLevel] = useState<'off' | 'low' | 'medium' | 'high' | 'auto'>('auto');
const settingsApplied = useRef(false);
useEffect(() => {
if (!settingsApplied.current) {
@@ -368,6 +369,8 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
/>
</div>
) : historyOpen ? (
+1 -1
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: 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 }) => {
-15
View File
@@ -114,21 +114,6 @@ interface Integration {
}
const INTEGRATIONS: Integration[] = [
{
id: 'xbird',
name: 'xbird',
description: 'Twitter/X research — search tweets, read profiles, threads, timelines.',
mcp_config: { type: 'stdio', command: 'bunx', args: ['@checkra1n/xbird'] },
color: '#1DA1F2',
website: 'https://xbird.dev',
icon: '𝕏',
connectLabel: 'Connect 𝕏',
connectInstructions: 'Open x.com in your browser, press F12 → Application → Cookies → x.com, and copy the values for auth_token and ct0.',
credentialFields: [
{ key: 'TWITTER_AUTH_TOKEN', label: 'auth_token', placeholder: 'Paste auth_token cookie value' },
{ key: 'TWITTER_CT0', label: 'ct0', placeholder: 'Paste ct0 cookie value' },
],
},
{
id: 'reddit',
name: 'Reddit',
+24 -3
View File
@@ -5,7 +5,7 @@ const AGENTS_API = `${API_BASE}/agents`;
export interface AgentMessage {
id: string;
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system';
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system' | 'thinking';
content: any;
timestamp: string;
branch_id: string;
@@ -34,7 +34,7 @@ export interface MessageBranch {
export interface StreamingMessage {
id: string;
role: 'assistant' | 'tool_call';
role: 'assistant' | 'tool_call' | 'thinking';
content: string;
tool_name?: string;
}
@@ -73,6 +73,7 @@ export interface AgentSession {
dashboard_id?: string;
browser_id?: string | null;
parent_session_id?: string | null;
thinking_level?: 'off' | 'low' | 'medium' | 'high' | 'auto';
}
export interface AgentConfig {
@@ -310,6 +311,18 @@ export const updateSystemPrompt = createAsyncThunk(
}
);
export const updateThinkingLevel = createAsyncThunk(
'agents/updateThinkingLevel',
async ({ sessionId, level }: { sessionId: string; level: 'off' | 'low' | 'medium' | 'high' | 'auto' }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thinking_level: level }),
});
return { sessionId, level };
}
);
export const handleApproval = createAsyncThunk(
'agents/handleApproval',
async ({
@@ -593,7 +606,7 @@ const agentsSlice = createSlice({
streamStart(
state,
action: PayloadAction<{ sessionId: string; messageId: string; role: 'assistant' | 'tool_call'; toolName?: string }>
action: PayloadAction<{ sessionId: string; messageId: string; role: 'assistant' | 'tool_call' | 'thinking'; toolName?: string }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -700,6 +713,13 @@ const agentsSlice = createSlice({
}
},
updateSessionThinkingLevel(state, action: PayloadAction<{ sessionId: string; level: 'off' | 'low' | 'medium' | 'high' | 'auto' }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.thinking_level = action.payload.level;
}
},
closeSessionFromWs(state, action: PayloadAction<HistorySession>) {
const entry = action.payload;
state.history[entry.id] = entry;
@@ -1029,6 +1049,7 @@ export const {
updateSessionProvider,
updateSessionModel,
updateSessionMode,
updateSessionThinkingLevel,
closeSessionFromWs,
removeDraftSession,
clearHistorySearch,
+1
View File
@@ -8,6 +8,7 @@ export interface ModelOption {
label: string;
version?: string;
context_window: number;
reasoning?: boolean;
}
interface ModelsState {
+1 -1
View File
@@ -8,7 +8,7 @@ export const DEFAULT_SYSTEM_PROMPT =
`## Tool Priority\n` +
`When a dedicated MCP tool exists for a task, use it directly — do not use the browser for things MCP tools can handle.\n` +
`Priority order:\n` +
`1. MCP tools first (Reddit, Google Workspace, Twitter, etc.) — fastest and most reliable\n` +
`1. MCP tools first (Reddit, Google Workspace, etc.) — fastest and most reliable\n` +
`2. WebSearch / WebFetch — for general web lookups without a dedicated MCP\n` +
`3. BrowserAgent — only when you need to visually interact with a website, fill forms, or do something no other tool can handle\n\n` +
`## Tool Call Style\n` +
File diff suppressed because one or more lines are too long