mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] OpenSwarm Pro lifecycle fixes: cancel-in-grace shows access-until-X with Resubscribe, distinct ended and reconnect states, app-launch /sync reconciles
with Stripe, auto-revert to own_key on revoke/expire, PostHog identify segments events by plan, browser agent + capacity UX: browser sub-agents inherit the parent's Anthropic pick properly, spawned browser cards auto-close on natural completion, OpenSwarm-servers-maxed card with Discord waitlist CTA, auto-clear local subscription state on cloud revoke/expire
This commit is contained in:
@@ -878,7 +878,7 @@ async def run_browser_agent(
|
||||
logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}")
|
||||
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.settings.credentials import get_anthropic_client
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.agents.providers.registry import (
|
||||
_find_builtin_model,
|
||||
resolve_model_id_for_sdk,
|
||||
@@ -925,7 +925,13 @@ async def run_browser_agent(
|
||||
"action_log": [],
|
||||
"final_screenshot": None,
|
||||
}
|
||||
client = get_anthropic_client(browser_settings)
|
||||
# Route the client based on the resolved model id, not just
|
||||
# connection_mode. Without this, a pinned-route value like "sonnet-cc"
|
||||
# resolves to "cc/claude-sonnet-4-6" but the old get_anthropic_client()
|
||||
# still returned an OpenSwarm-proxy client (because connection_mode was
|
||||
# openswarm-pro), which then rejected the cc/ prefix and surfaced as a
|
||||
# misleading "OpenSwarm servers are busy" error.
|
||||
client = get_anthropic_client_for_model(browser_settings, api_model)
|
||||
|
||||
# Resume prior conversation on this browser if we have one cached. This
|
||||
# lets the sub-agent skip the "take a screenshot to figure out where I am"
|
||||
@@ -1372,6 +1378,7 @@ async def _create_browser_card(dashboard_id: str, url: str, parent_session_id: s
|
||||
y=100,
|
||||
width=1280,
|
||||
height=800,
|
||||
spawned_by=parent_session_id,
|
||||
)
|
||||
dashboard.layout.browser_cards[browser_id] = card
|
||||
dashboard.updated_at = datetime.now()
|
||||
|
||||
@@ -155,6 +155,22 @@ async def analytics_lifespan():
|
||||
id_props["use_case"] = settings.user_use_case
|
||||
if getattr(settings, "user_referral_source", None):
|
||||
id_props["referral_source"] = settings.user_referral_source
|
||||
|
||||
# Subscription context so every event from this installation can be
|
||||
# sliced by plan / paying-vs-free in PostHog. Refreshed on activate,
|
||||
# sync, and disconnect so these values stay current without waiting
|
||||
# for the next app launch.
|
||||
mode = getattr(settings, "connection_mode", "own_key")
|
||||
plan = getattr(settings, "openswarm_subscription_plan", None)
|
||||
is_paying = mode == "openswarm-pro" and bool(
|
||||
getattr(settings, "openswarm_bearer_token", None)
|
||||
)
|
||||
id_props["connection_mode"] = mode
|
||||
id_props["plan"] = plan if is_paying else "free"
|
||||
id_props["is_paying_customer"] = is_paying
|
||||
if is_paying and getattr(settings, "openswarm_subscription_expires", None):
|
||||
id_props["subscription_expires"] = settings.openswarm_subscription_expires
|
||||
|
||||
identify(id_props)
|
||||
except Exception as e:
|
||||
logger.debug(f"Analytics startup event failed (non-critical): {e}")
|
||||
|
||||
@@ -36,6 +36,10 @@ class BrowserCardPosition(BaseModel):
|
||||
y: float = 0
|
||||
width: float = 1280
|
||||
height: float = 800
|
||||
# Agent session id that spawned this browser, or None for user-created.
|
||||
# Used by the frontend to auto-remove the browser when its owner agent
|
||||
# reaches a terminal completed/error state.
|
||||
spawned_by: Optional[str] = None
|
||||
|
||||
|
||||
class DashboardLayout(BaseModel):
|
||||
|
||||
@@ -152,3 +152,23 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
|
||||
)
|
||||
|
||||
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
|
||||
|
||||
|
||||
def get_anthropic_client_for_model(settings: AppSettings, api_model: str) -> anthropic.AsyncAnthropic:
|
||||
"""Return a client configured for the given resolved model id.
|
||||
|
||||
When api_model carries a 9Router prefix (cc/, cx/, gc/, gh/), the client
|
||||
targets 9Router directly — even if connection_mode is openswarm-pro. This
|
||||
is what lets pinned-route models like "sonnet-cc" actually reach the
|
||||
user's own subscription instead of getting sent through the managed proxy
|
||||
with an unrecognizable model id.
|
||||
Otherwise delegates to get_anthropic_client() for the default mode-driven
|
||||
routing.
|
||||
"""
|
||||
import anthropic
|
||||
if isinstance(api_model, str) and api_model.startswith(("cc/", "cx/", "gc/", "gh/")):
|
||||
return anthropic.AsyncAnthropic(
|
||||
api_key="9router",
|
||||
base_url="http://localhost:20128",
|
||||
)
|
||||
return get_anthropic_client(settings)
|
||||
|
||||
@@ -44,6 +44,48 @@ def _write_settings(settings_obj) -> None:
|
||||
json.dump(settings_obj.model_dump(), f, indent=2)
|
||||
|
||||
|
||||
def _clear_subscription(settings_obj) -> None:
|
||||
"""Revert to own_key mode and drop all OpenSwarm Pro state. Used by the
|
||||
explicit /disconnect endpoint and by /status when the cloud reports the
|
||||
bearer as revoked (401) or the subscription as past its grace period
|
||||
(402) — so a canceled/expired user flips back to BYO routing cleanly
|
||||
instead of hammering a dead token."""
|
||||
settings_obj.connection_mode = "own_key"
|
||||
settings_obj.openswarm_bearer_token = None
|
||||
settings_obj.openswarm_subscription_plan = None
|
||||
settings_obj.openswarm_subscription_expires = None
|
||||
settings_obj.openswarm_usage_cached = None
|
||||
_write_settings(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
|
||||
|
||||
def _sync_subscription_identity(settings_obj) -> None:
|
||||
"""Push the installation's current subscription state into PostHog person
|
||||
properties so every event from this user is segmentable by plan /
|
||||
paying-vs-free. Safe to call from hot paths — PostHog is fire-and-forget
|
||||
and swallows errors internally."""
|
||||
try:
|
||||
from backend.apps.analytics.collector import identify as _identify
|
||||
except Exception:
|
||||
return
|
||||
mode = getattr(settings_obj, "connection_mode", "own_key")
|
||||
is_paying = mode == "openswarm-pro" and bool(
|
||||
getattr(settings_obj, "openswarm_bearer_token", None)
|
||||
)
|
||||
props = {
|
||||
"connection_mode": mode,
|
||||
"plan": getattr(settings_obj, "openswarm_subscription_plan", None) if is_paying else "free",
|
||||
"is_paying_customer": is_paying,
|
||||
}
|
||||
expires = getattr(settings_obj, "openswarm_subscription_expires", None)
|
||||
if is_paying and expires:
|
||||
props["subscription_expires"] = expires
|
||||
try:
|
||||
_identify(props)
|
||||
except Exception as e:
|
||||
logger.debug("identify sync failed: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/activate
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,6 +154,7 @@ async def activate(body: ActivateRequest):
|
||||
settings_obj.openswarm_usage_cached = usage
|
||||
|
||||
_write_settings(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
|
||||
|
||||
|
||||
@@ -136,14 +179,18 @@ async def status():
|
||||
}
|
||||
|
||||
# Best-effort live fetch — surface stale cache if cloud is unreachable.
|
||||
# Network errors leave upstream_code=None so we keep the cached state;
|
||||
# only explicit 401/402 from the cloud trigger a local clear.
|
||||
live_usage = None
|
||||
live_status = None
|
||||
upstream_code: Optional[int] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
f"{_proxy_url()}/api/me",
|
||||
headers={"Authorization": f"Bearer {bearer}"},
|
||||
)
|
||||
upstream_code = r.status_code
|
||||
if r.status_code == 200:
|
||||
me = r.json()
|
||||
live_usage = me.get("usage")
|
||||
@@ -155,6 +202,19 @@ async def status():
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("subscription/status live fetch failed: %s", e)
|
||||
|
||||
# Cloud says the bearer is gone (401) or the sub is past its grace
|
||||
# period (402) — drop local credentials so the desktop stops routing
|
||||
# through a dead subscription. Settings UI sees connected=False and
|
||||
# falls back to the Subscribe CTA; chat reverts to own_key routing.
|
||||
if upstream_code in (401, 402):
|
||||
_clear_subscription(settings_obj)
|
||||
return {
|
||||
"connected": False,
|
||||
"connection_mode": "own_key",
|
||||
"reason": "revoked" if upstream_code == 401 else "expired",
|
||||
"last_plan": plan,
|
||||
}
|
||||
|
||||
return {
|
||||
"connected": True,
|
||||
"connection_mode": mode,
|
||||
@@ -165,6 +225,77 @@ async def status():
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/sync")
|
||||
async def sync():
|
||||
"""Reconciles local subscription state with Stripe via the cloud router.
|
||||
Called once per app launch from the renderer so a missed webhook (or a
|
||||
webhook processed by older code) doesn't leave a user wedged in a stale
|
||||
state forever.
|
||||
|
||||
No-op when not in openswarm-pro mode. Best-effort: network failures are
|
||||
swallowed — the caller still gets a 200 with whatever local state we
|
||||
already had."""
|
||||
settings_obj = load_settings()
|
||||
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
|
||||
mode = getattr(settings_obj, "connection_mode", "own_key")
|
||||
|
||||
if mode != "openswarm-pro" or not bearer:
|
||||
return {"ok": True, "synced": False, "connection_mode": mode}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(
|
||||
f"{_proxy_url()}/api/subscription/sync",
|
||||
headers={"Authorization": f"Bearer {bearer}"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("subscription/sync live fetch failed: %s", e)
|
||||
return {"ok": True, "synced": False, "reason": "network"}
|
||||
|
||||
# Same 401/402 handling as /status: if Stripe-side reconciliation proves
|
||||
# the bearer is dead or the sub expired, clear local state so the app
|
||||
# reverts to own_key instead of hammering a useless token.
|
||||
if r.status_code in (401, 402):
|
||||
_clear_subscription(settings_obj)
|
||||
return {
|
||||
"ok": True,
|
||||
"synced": False,
|
||||
"connected": False,
|
||||
"reason": "revoked" if r.status_code == 401 else "expired",
|
||||
}
|
||||
|
||||
if r.status_code != 200:
|
||||
logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200])
|
||||
return {"ok": True, "synced": False, "reason": "upstream"}
|
||||
|
||||
data = r.json()
|
||||
cloud_plan = data.get("plan")
|
||||
period_end_ms = data.get("current_period_end")
|
||||
|
||||
# Only touch local fields the cloud explicitly confirmed — don't paper
|
||||
# over missing keys with defaults that would downgrade an older record.
|
||||
if cloud_plan:
|
||||
settings_obj.openswarm_subscription_plan = cloud_plan
|
||||
if isinstance(period_end_ms, (int, float)) and period_end_ms > 0:
|
||||
from datetime import datetime, timezone
|
||||
settings_obj.openswarm_subscription_expires = (
|
||||
datetime.fromtimestamp(period_end_ms / 1000, tz=timezone.utc).isoformat()
|
||||
)
|
||||
_write_settings(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
return {
|
||||
"ok": True,
|
||||
"synced": bool(data.get("synced")),
|
||||
"plan": cloud_plan,
|
||||
"status": data.get("status"),
|
||||
"expires": settings_obj.openswarm_subscription_expires,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/portal
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -198,11 +329,5 @@ async def disconnect():
|
||||
"""Clears local bearer + reverts to own_key mode. Does NOT cancel the
|
||||
Stripe subscription (use the portal for that). Useful when a user wants
|
||||
to temporarily route through their own API key."""
|
||||
settings_obj = load_settings()
|
||||
settings_obj.connection_mode = "own_key"
|
||||
settings_obj.openswarm_bearer_token = None
|
||||
settings_obj.openswarm_subscription_plan = None
|
||||
settings_obj.openswarm_subscription_expires = None
|
||||
settings_obj.openswarm_usage_cached = None
|
||||
_write_settings(settings_obj)
|
||||
_clear_subscription(load_settings())
|
||||
return {"ok": True}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { store } from '../shared/state/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchSettings } from '@/shared/state/settingsSlice';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import {
|
||||
setAppVersion,
|
||||
setUpdateAvailable,
|
||||
@@ -169,6 +170,15 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
useEffect(() => {
|
||||
dispatch(fetchSettings());
|
||||
dispatch(fetchModels());
|
||||
// Reconcile OpenSwarm Pro state with Stripe on every launch so a
|
||||
// missed webhook (cancel, upgrade, renewal) can't leave the user
|
||||
// wedged on stale info. Fire-and-forget; if the cloud is unreachable
|
||||
// we simply keep whatever local state we already had.
|
||||
fetch(`${API_BASE}/subscription/sync`, { method: 'POST' })
|
||||
.then((r) => {
|
||||
if (r.ok) dispatch(fetchSettings());
|
||||
})
|
||||
.catch(() => { /* offline — next launch will reconcile */ });
|
||||
}, [dispatch]);
|
||||
useEffect(() => {
|
||||
if (loaded) setThemeMode(theme as 'light' | 'dark');
|
||||
|
||||
@@ -67,7 +67,7 @@ interface OpenSwarmErrorInfo {
|
||||
title: string;
|
||||
detail: string;
|
||||
ctaLabel?: string;
|
||||
ctaAction?: 'upgrade' | 'retry' | 'settings';
|
||||
ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist';
|
||||
}
|
||||
|
||||
// Turn a raw Claude-CLI / cloud error string into a user-friendly card.
|
||||
@@ -92,10 +92,10 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
|
||||
if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) {
|
||||
return {
|
||||
kind: 'capacity',
|
||||
title: 'OpenSwarm servers are busy',
|
||||
detail: "We're hitting capacity on our end — please retry in a moment. If this keeps happening, contact support.",
|
||||
ctaLabel: 'Try again',
|
||||
ctaAction: 'retry',
|
||||
title: 'OpenSwarm servers maxed',
|
||||
detail: "We're at full capacity right now. Thanks for your patience — we'll get you back in as soon as we can. Join our Discord and we'll let you know the moment things open up.",
|
||||
ctaLabel: 'Join waitlist',
|
||||
ctaAction: 'waitlist',
|
||||
};
|
||||
}
|
||||
// Auth / subscription problems
|
||||
@@ -888,6 +888,10 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
} else if (openswarmError.ctaAction === 'settings') {
|
||||
// Best-effort: dispatch a DOM event the Settings modal listens to
|
||||
window.dispatchEvent(new CustomEvent('openswarm:open-settings', { detail: { tab: 'models' } }));
|
||||
} else if (openswarmError.ctaAction === 'waitlist') {
|
||||
const url = 'https://discord.com/channels/1486442924391796896/1486442927554170892';
|
||||
if (api?.openExternal) api.openExternal(url);
|
||||
else window.open(url, '_blank');
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
|
||||
@@ -137,6 +137,12 @@ interface OpenSwarmProStatus {
|
||||
plan?: string | null;
|
||||
status?: string | null;
|
||||
expires?: string | null;
|
||||
// When the cloud reports the bearer as revoked (401) or the sub as past
|
||||
// its grace period (402), backend clears local state and returns
|
||||
// connected=false with a reason + last_plan so the UI can distinguish
|
||||
// "your subscription ended" from "never subscribed."
|
||||
reason?: 'revoked' | 'expired' | null;
|
||||
last_plan?: string | null;
|
||||
usage?: {
|
||||
// Live utilization from Claude's /api/oauth/usage — 0-100 percent of the
|
||||
// shared pool subscription's 5h window consumed. Updated every ~30s.
|
||||
@@ -256,11 +262,13 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
OpenSwarm Pro
|
||||
</Typography>
|
||||
{isConnected && (
|
||||
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}20` }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.accent.primary, fontWeight: 600 }}>
|
||||
{planLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
component="img"
|
||||
src="./logo.png"
|
||||
alt={planLabel}
|
||||
title={planLabel}
|
||||
sx={{ width: 18, height: 18, borderRadius: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}15` }}>
|
||||
@@ -274,6 +282,20 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
|
||||
{isConnected ? (
|
||||
<>
|
||||
{/* Canceled-in-grace banner: user canceled in Stripe but still
|
||||
inside the paid period. Show a clear "scheduled to cancel"
|
||||
state so they're not surprised when access stops. */}
|
||||
{status.status === 'canceled' && (
|
||||
<Box sx={{
|
||||
px: 1.2, py: 0.6, mb: 1.2, borderRadius: `${c.radius.sm}px`,
|
||||
bgcolor: `${c.status.warning}15`, border: `1px solid ${c.status.warning}40`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.status.warning, fontWeight: 500 }}>
|
||||
Subscription canceled — you still have access until {expiresLabel || 'the end of the period'}.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Usage bar — percentage only, no raw counts */}
|
||||
<Box sx={{ mb: 1.2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
|
||||
@@ -310,24 +332,69 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{expiresLabel && (
|
||||
{expiresLabel && status.status !== 'canceled' && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mb: 1.5 }}>
|
||||
{status.status === 'canceled' ? 'Expires' : 'Renews'} on {expiresLabel}
|
||||
Renews on {expiresLabel}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{status.status === 'canceled' && (
|
||||
<Button
|
||||
onClick={handleSubscribe}
|
||||
disabled={busy !== null}
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
Resubscribe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleManage}
|
||||
disabled={busy !== null}
|
||||
size="small"
|
||||
variant="contained"
|
||||
variant={status.status === 'canceled' ? 'outlined' : 'contained'}
|
||||
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : status.reason === 'expired' && status.last_plan ? (
|
||||
// Truly expired: the bearer's subscription ended past its grace
|
||||
// period. Don't show the new-user "Subscribe" prompt — make it
|
||||
// clear what happened and invite them back.
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
|
||||
Your OpenSwarm Pro subscription has ended. Resubscribe to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={handleSubscribe}
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
Resubscribe
|
||||
</Button>
|
||||
</>
|
||||
) : status.reason === 'revoked' && status.last_plan ? (
|
||||
// Token revoked but subscription existed — different CTA language
|
||||
// so the user knows this isn't a billing issue.
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
|
||||
Your OpenSwarm Pro access token was revoked. Sign back in to reconnect.
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={handleSubscribe}
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Genuine new user — never had a subscription on this machine.
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
|
||||
One subscription, no Claude account needed. We handle everything behind the scenes.
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface BrowserCardPosition {
|
||||
width: number;
|
||||
height: number;
|
||||
zOrder: number;
|
||||
// Agent session id that spawned this browser. null/undefined for
|
||||
// user-created. Used to auto-remove the browser when its owner agent
|
||||
// reaches a terminal completed/error state.
|
||||
spawned_by?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
closeSessionFromWs,
|
||||
trackAgentNotification,
|
||||
} from '../state/agentsSlice';
|
||||
import { addBrowserCardFromBackend, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
|
||||
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
|
||||
|
||||
type WSEvent = {
|
||||
event: string;
|
||||
@@ -129,6 +129,20 @@ class WebSocketManager {
|
||||
if (data.status === 'running' && session_id) {
|
||||
store.dispatch(trackAgentNotification(session_id));
|
||||
}
|
||||
// Auto-remove browsers spawned by this agent when it reaches a
|
||||
// terminal state on its own. agent:closed only fires when the user
|
||||
// clicks X to close the session, so without this hook the browser
|
||||
// cards would linger after natural completion or error. 'stopped'
|
||||
// is intentionally skipped to preserve the "inspect after manual
|
||||
// stop" affordance — matching the agent:closed branch below.
|
||||
if (session_id && (data.status === 'completed' || data.status === 'error')) {
|
||||
const browserCards = store.getState().dashboardLayout.browserCards;
|
||||
for (const card of Object.values(browserCards)) {
|
||||
if (card.spawned_by === session_id) {
|
||||
store.dispatch(removeBrowserCard(card.browser_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:message':
|
||||
@@ -222,10 +236,11 @@ class WebSocketManager {
|
||||
|
||||
case 'agent:closed':
|
||||
if (session_id) {
|
||||
const closedStatus = data.status ?? 'stopped';
|
||||
store.dispatch(closeSessionFromWs({
|
||||
id: session_id,
|
||||
name: data.name ?? 'Untitled',
|
||||
status: data.status ?? 'stopped',
|
||||
status: closedStatus,
|
||||
model: data.model ?? '',
|
||||
mode: data.mode ?? '',
|
||||
created_at: data.created_at ?? new Date().toISOString(),
|
||||
@@ -233,6 +248,17 @@ class WebSocketManager {
|
||||
cost_usd: data.cost_usd ?? 0,
|
||||
dashboard_id: data.dashboard_id,
|
||||
}));
|
||||
// Auto-delete browsers spawned by this agent when it finishes
|
||||
// normally or errors out. We intentionally skip 'stopped' — the
|
||||
// user may want to inspect the browser after manually stopping.
|
||||
if (closedStatus === 'completed' || closedStatus === 'error') {
|
||||
const browserCards = store.getState().dashboardLayout.browserCards;
|
||||
for (const card of Object.values(browserCards)) {
|
||||
if (card.spawned_by === session_id) {
|
||||
store.dispatch(removeBrowserCard(card.browser_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user