import { useEffect, useState } from "react"; import { addMcpServer, allowUser, connectConnector, connectManaged, connectMcpBacked, connectMcp, deleteMcpServer, disallowUser, getMcpServers, getMcpTools, signoutMcp, getSettings, getSubscriptions, removeModel, resolveUnauthorized, unsubscribeChannel, patchMcpServer, reloadMcp, setDefaultModel, updateConnectorTools, type CloudStatus, type Connector, type Subscription, type McpServer, type ModelSettings, type ProviderInfo, } from "../api"; import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn"; import { ModelChecklist } from "./ModelChecklist"; import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup"; import { Toggle } from "./Toggle"; // "2h ago"-style label for the providers' Last-used line (null when never used). const relTime = (epoch?: number | null): string | null => { if (!epoch) return null; const secs = Math.max(0, Math.floor(Date.now() / 1000 - epoch)); if (secs < 90) return "just now"; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 48) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; }; // Shared tab bodies for the Settings and Integrations pages (the old top-tab ManageModal was retired // when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab + // McpTab → Integrations ▸ Connectors / MCP servers. const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold"; const CARD = "rounded-xl2 border border-line bg-panel"; const BTN_BORDERED = "text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50"; const BTN_DANGER = "text-[12.5px] text-danger/80 hover:text-danger shrink-0"; /** Two-letter initials for a chip/avatar (first+last word, else first two chars). */ function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } const EXAMPLE = `{ "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"], "enabled": true } }`; // -- Configure Models tab (UX-021: the shared provider gallery + key form) ---- // Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two // surfaces can't drift. Settings-only extras: per-card "used Nh ago", a "Remove // key…" affordance, the global composer-picker card (gallery view), and the // per-provider ModelChecklist / read-only model preview (form view). export function ModelsTab() { const [settings, setSettings] = useState(null); const refreshSettings = () => getSettings().then(setSettings).catch(() => setSettings(null)); const ps = useProviderSetup({ onSaved: refreshSettings }); useEffect(() => { refreshSettings(); }, []); if (!settings) return
Loading…
; const info = ps.info; const knownNames = ps.providers.map((p) => p.name); if (ps.sel === null) { return (
); } return (
{ if (window.confirm(`Remove the ${info?.title} key from this computer?`)) ps.removeKey(); }} > Remove key… ) : null } /> {ps.sel === "openai" && settings.source === "env" && (

A key is set via OPENAI_API_KEY in this server's environment. You can override it above; the stored key is used only when the environment variable is absent.

)} {info?.configured ? (
Models

Ticked models show in the composer's picker; the black badge marks the default for new sessions.

setSettings((s) => (s ? { ...s, models: next.models, model: next.model } : s))} />
) : ( // Unconfigured providers still show their curated models as a read-only preview — what a // key unlocks is part of deciding to get one at all (owner ask, 2026-07-04). (info?.suggested_models?.length || 0) > 0 && (
Included models

Curated, agent-capable models this provider serves — add your key above to enable them.

{(info?.suggested_models || []).map((m) => { const full = ps.sel === "openai" ? m : `${ps.sel}:${m}`; return (
{settings.model_labels?.[full] || m}
); })}
) )}
); } // The gallery view's "In the composer's picker" card: every curated model across providers, // with its provider tag. Unticking removes it from the picker; adding happens from a // provider's card (the ModelChecklist there has the suggested list + free-type add). function ComposerPickerCard({ settings, providers, onChanged, }: { settings: ModelSettings; providers: ProviderInfo[]; onChanged: () => void; }) { const names = providers.map((p) => p.name); const provOf = (id: string) => { const i = id.indexOf(":"); return i > 0 && names.includes(id.slice(0, i)) ? id.slice(0, i) : "openai"; }; const tag = (id: string) => { const p = providers.find((x) => x.name === provOf(id)); return (p?.title || provOf(id)).split(" (")[0]; }; return (
In the composer's picker

The models offered when starting a session; the black badge marks the default. Add more from a provider's card above.

{settings.models.map((id) => { const isDefault = id === settings.model; return (
{tag(id)} {isDefault ? ( default ) : ( )}
); })}
); } // Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 + DCR) — // no keys to paste, tokens stay in the local secret store. First: Granola. const MCP_PRESETS: { name: string; label: string; blurb: string; config: Record }[] = [ { name: "granola", label: "Granola", blurb: "Meeting notes & transcripts — sign in with your Granola account.", config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" }, }, ]; export function McpTab() { const [servers, setServers] = useState([]); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); const refresh = () => getMcpServers().then(setServers).catch(() => setServers([])); useEffect(() => { refresh(); }, []); // While a browser sign-in is in flight, poll so the row flips to connected (or // surfaces the error) without the user having to touch anything. const authorizing = servers.some((s) => s.status === "authorizing"); useEffect(() => { if (!authorizing) return; const t = window.setInterval(refresh, 2000); return () => window.clearInterval(t); }, [authorizing]); const toggle = async (s: McpServer) => { await patchMcpServer(s.name, { enabled: !s.enabled }); refresh(); }; const remove = async (s: McpServer) => { await deleteMcpServer(s.name); refresh(); }; return (

External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are permission-gated. Changes apply to new sessions —{" "} .

{servers.length === 0 && !adding ? (
No MCP servers configured.{" "}
) : (
{servers.map((s) => ( toggle(s)} onRemove={() => remove(s)} onRefresh={refresh} /> ))}
)} {/* One-click OAuth presets not yet configured. */} {MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => (
{p.label}
{p.blurb}
))} {adding ? ( { setAdding(false); setError(null); }} onError={setError} onAdded={() => { setAdding(false); setError(null); refresh(); }} /> ) : servers.length > 0 ? ( ) : null} {error &&
{error}
}
); } function McpRow({ server, onToggle, onRemove, onRefresh, }: { server: McpServer; onToggle: () => void; onRemove: () => void; onRefresh: () => void; }) { const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null); const [busy, setBusy] = useState(false); const [toolErr, setToolErr] = useState(null); const isOauth = server.auth === "oauth"; const authorizing = server.status === "authorizing"; const signIn = async () => { await connectMcp(server.name); // browser opens; the tab's poll flips the status onRefresh(); }; const signOut = async () => { await signoutMcp(server.name); onRefresh(); }; const loadTools = async () => { if (tools) { setTools(null); return; } setBusy(true); setToolErr(null); const res = await getMcpTools(server.name); setBusy(false); if (res.ok) setTools(res.tools); else setToolErr(res.error || "failed to connect"); }; return (
{server.name}
{server.transport} · {authorizing ? "signing in…" : server.status.replace("_", " ")} {server.tool_count != null ? ` · ${server.tool_count} tools` : ""} {server.requires_approval ? " · asks" : ""} {isOauth ? " · oauth" : ""}
{isOauth && (server.status === "needs_auth" ? ( ) : authorizing ? ( waiting for browser… ) : server.status === "connected" ? ( ) : null)}
{server.last_error && server.status !== "connected" && (
{server.last_error}
)} {toolErr &&
{toolErr}
} {tools && (
{tools.length === 0 &&
No tools.
} {tools.map((t) => ( {t.name} ))}
)}
); } function AddForm({ onCancel, onAdded, onError, }: { onCancel: () => void; onAdded: () => void; onError: (e: string | null) => void; }) { const [text, setText] = useState(EXAMPLE); const save = async () => { onError(null); let parsed: any; try { parsed = JSON.parse(text); } catch (e: any) { onError("Invalid JSON: " + e.message); return; } // Accept either {mcpServers:{...}}, {name:{...}}, or a single bare config. const map = parsed.mcpServers || parsed; const entries = map && typeof map === "object" && !map.command && !map.url ? Object.entries(map) : null; if (!entries || entries.length === 0) { onError('Paste a `{ "": { … } }` object (or a full mcpServers block).'); return; } for (const [name, config] of entries) { await addMcpServer(name, config as Record); } onAdded(); }; return (
Paste server JSON (name → config):