From c60d26e631d127a5919f9351603d3e63f1fa40e2 Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 25 Jun 2026 18:04:53 -0700 Subject: [PATCH 01/21] [aidan] fix/free-trial: clear trial on subscription connect so a connected sub takes over immediately --- backend/apps/agents/agents.py | 5 +++++ backend/apps/subscription/free_trial.py | 13 ++++++++++++- backend/main.py | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 5e5094a6..fc685dee 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -425,6 +425,8 @@ async def subscriptions_poll(body: dict): from backend.apps.service.client import sync as _sync from backend.apps.settings.settings import load_settings _sync(load_settings().model_dump()) + from backend.apps.subscription.free_trial import clear_free_trial_on_connect + await clear_free_trial_on_connect() return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -459,6 +461,9 @@ async def subscriptions_exchange(body: dict): from backend.apps.service.client import sync as do_sync from backend.apps.settings.settings import load_settings do_sync(load_settings().model_dump()) + # A connected subscription takes precedence over the free trial right away. + from backend.apps.subscription.free_trial import clear_free_trial_on_connect + await clear_free_trial_on_connect() return result except Exception as e: if state and state in completed_oauth: diff --git a/backend/apps/subscription/free_trial.py b/backend/apps/subscription/free_trial.py index 4f8786f1..24155902 100644 --- a/backend/apps/subscription/free_trial.py +++ b/backend/apps/subscription/free_trial.py @@ -22,7 +22,7 @@ import time import httpx from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL -from backend.apps.settings.settings import save_settings_async +from backend.apps.settings.settings import load_settings, save_settings_async logger = logging.getLogger(__name__) @@ -152,6 +152,17 @@ async def clear_free_trial(settings_obj) -> None: await _sync_routing(settings_obj) +async def clear_free_trial_on_connect() -> None: + """Hand the wheel back to a just-connected subscription immediately, instead of + waiting for the next-boot arm_free_trial reconcile. Subscriptions live in 9Router, + not settings, so `apply_settings_update`'s `_has_own_model` clear (which covers keys + + custom providers) can't see them; this is the connect-time equivalent for subs.""" + try: + await clear_free_trial(load_settings()) + except Exception as e: + logger.debug("clear_free_trial_on_connect skipped: %s", e) + + async def arm_free_trial(settings_obj) -> dict: """Mint (or re-fetch) the machine's grant and, if runs remain, flip into free-trial mode. Guarded: never arms over a real key/subscription.""" diff --git a/backend/main.py b/backend/main.py index 457c47d5..6933f555 100644 --- a/backend/main.py +++ b/backend/main.py @@ -511,6 +511,12 @@ async def subscriptions_callback(request: Request): _mark_oauth_completed(state) logger.info(f"OAuth exchange succeeded for provider={pending.get('provider')}") + # A connected subscription takes precedence over the free trial right away. + try: + from backend.apps.subscription.free_trial import clear_free_trial_on_connect + await clear_free_trial_on_connect() + except Exception: + pass return HTMLResponse(_SUCCESS_HTML) From fcc337c848d05c9dbf8bec61c1673e2c6308dde2 Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 25 Jun 2026 18:04:57 -0700 Subject: [PATCH 02/21] [aidan] fix/oauth: drop hardcoded "Claude" from the connect success page --- backend/apps/agents/9router_gpt5_patch.js | 2 +- backend/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index f5295d54..c236c2c7 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -55,7 +55,7 @@ const _http = require('http'); const closePage = '' + - 'You can close this tab, and any other Claude login tab still open.'; + 'You can close this tab, and any other login tab still open.'; http.Server.prototype.emit = function patchedEmit(event, req, res) { if (event === 'request' && req && res) { try { diff --git a/backend/main.py b/backend/main.py index 6933f555..470c9ded 100644 --- a/backend/main.py +++ b/backend/main.py @@ -454,7 +454,7 @@ _SUCCESS_HTML = ( '
' '
' '

Connected!

' - '

You can close this tab, and any other Claude login tab still open.

' + '

You can close this tab, and any other login tab still open.

' '
' '' '' From 5f9ae71ff452d94dc98b88012609435a61c93230 Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 25 Jun 2026 18:05:00 -0700 Subject: [PATCH 03/21] [aidan] fix/codex-auth: fall back to port 1457 when 1455 is held, surface connect errors --- backend/apps/nine_router/oauth.py | 85 +++++++++++++++---- .../subscription/SubscriptionCards.tsx | 32 ++++++- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py index ebb8d1df..bbf8e0a7 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -22,7 +22,13 @@ logger = logging.getLogger(__name__) # 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so # the frontend's existing popup + msgHandler flow works unchanged. -_CODEX_CALLBACK_PORT = 1455 +# OpenAI's Codex OAuth client registers BOTH of these loopback redirect ports in its +# Hydra allow-list (1455 default, 1457 fallback); the official Codex CLI falls back to 1457 +# for this exact "another app holds 1455" case (openai/codex PR #19334). We try them in +# order so a running Codex CLI / ChatGPT VS Code extension / Cursor sitting on 1455 doesn't +# block us. OpenAI rejects any port not on this list, so don't add speculative ones. +_CODEX_CALLBACK_PORTS = (1455, 1457) +_CODEX_CALLBACK_PORT = _CODEX_CALLBACK_PORTS[0] _CODEX_CALLBACK_PATH = "/auth/callback" _CODEX_CALLBACK_HTML = b""" Authorization Complete @@ -59,14 +65,21 @@ p{color:#888;margin:0} """ -async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base_events.Server | None: - """Spawn a one-shot HTTP listener on 127.0.0.1:1455 for the Codex OAuth callback. +# Tracks the live Codex callback listener so a fresh connect attempt can reclaim port +# 1455 from a still-bound prior attempt (each lingers up to `timeout`s), instead of +# failing to bind and leaving OpenAI's redirect with nothing to answer it. +_codex_listener_server: "asyncio.base_events.Server | None" = None - Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the - callback (or after `timeout` seconds with no callback) the listener - closes itself in a background task. Safe to call even if 1455 is busy , - logs the collision and returns None so start_oauth can still proceed and - surface whatever error OpenAI returns. + +async def _start_codex_callback_listener(timeout: float = 300.0) -> int | None: + """Spawn a one-shot HTTP listener on the first free Codex callback port and return it. + + Tries each of _CODEX_CALLBACK_PORTS (1455 then 1457, both on OpenAI's allow-list) and + binds the first that's free, returning the bound port so the caller builds the matching + redirect_uri. Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the + callback (or after `timeout` seconds with no callback) the listener closes itself in a + background task. Returns None only when EVERY allow-listed port is held by another app, + so start_oauth can fail fast with an actionable message instead of a dead-end flow. Also performs the OAuth exchange server-side before serving the HTML. Relying on the frontend's postMessage path alone breaks on Windows where @@ -164,16 +177,38 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base except Exception: pass - try: - server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT) - except OSError as e: - # Port already in use; probably another Codex connect attempt still - # running, or an actual Codex CLI process holding 1455. Log and bail. + global _codex_listener_server + # A new connect supersedes any abandoned one: close our own still-bound prior + # listener first so this attempt can take 1455 instead of colliding with it. + if _codex_listener_server is not None: + try: + _codex_listener_server.close() + await _codex_listener_server.wait_closed() + except Exception: + pass + _codex_listener_server = None + + # Try each allow-listed port; the first free one wins. A running Codex CLI / ChatGPT + # extension typically holds 1455, so we land on 1457. + server = None + bound_port = None + for port in _CODEX_CALLBACK_PORTS: + try: + server = await asyncio.start_server(_handle, "127.0.0.1", port) + bound_port = port + break + except OSError: + continue + if server is None: + # Every allow-listed port is held by another app. OpenAI accepts only these two + # redirect ports, so we can't pick a third; bail and let the UI tell the user. + ports = "/".join(str(p) for p in _CODEX_CALLBACK_PORTS) logger.warning( - f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. " - "If another connection attempt is in progress, wait for it to finish or time out." + f"Could not start Codex callback listener: ports {ports} are all in use by " + f"another app (Codex CLI / ChatGPT extension). Close it (lsof -i :{_CODEX_CALLBACK_PORTS[0]}) and retry." ) return None + _codex_listener_server = server async def _lifecycle(): try: @@ -192,10 +227,13 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base await server.wait_closed() except Exception: pass + global _codex_listener_server + if _codex_listener_server is server: + _codex_listener_server = None asyncio.create_task(_lifecycle()) - logger.info(f"Started Codex callback listener on http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}") - return server + logger.info(f"Started Codex callback listener on http://localhost:{bound_port}{_CODEX_CALLBACK_PATH}") + return bound_port # Providers whose OAuth flow MUST run in the user's real browser via @@ -281,7 +319,18 @@ async def start_oauth(provider: str) -> dict: callback_url = _callback_uri_for_provider(provider) if provider == "codex": - await _start_codex_callback_listener() + # Codex's redirect must be one of OpenAI's allow-listed loopback ports. Bind the + # first free one (1455, else 1457) and use ITS redirect_uri so authorize + token + # exchange agree. Only fail if BOTH are held by another app (Codex CLI / ChatGPT + # extension), in which case the auth would redirect into that app, not us. + bound_port = await _start_codex_callback_listener() + if bound_port is None: + raise RuntimeError( + "Can't start the ChatGPT login: the Codex login ports (1455 and 1457) are " + "both in use by another app (the Codex CLI or its VS Code extension). " + "Quit that app, then try again." + ) + callback_url = f"http://localhost:{bound_port}{_CODEX_CALLBACK_PATH}" r = await client.get( f"{NINE_ROUTER_API}/oauth/{provider}/authorize", diff --git a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx index d3dba0d5..5c84936b 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; +import Fade from '@mui/material/Fade'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { fetchModels } from '@/shared/state/modelsSlice'; @@ -26,6 +27,7 @@ const SubscriptionCards: React.FC = () => { const [disconnecting, setDisconnecting] = useState(null); const [userCode, setUserCode] = useState(''); const [pollTimer, setPollTimer] = useState(null); + const [connectError, setConnectError] = useState(null); // Thin wrapper that returns the resolved status so call sites inspecting the payload keep working. const fetchStatus = useCallback( @@ -65,6 +67,7 @@ const SubscriptionCards: React.FC = () => { const handleConnect = async (providerId: string) => { if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); } + setConnectError(null); setConnecting(providerId); setUserCode(''); @@ -76,7 +79,15 @@ const SubscriptionCards: React.FC = () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: providerId }), }); - if (!r.ok) { setConnecting(null); return; } + if (!r.ok) { + // Surface an actionable reason (e.g. the ChatGPT :1455 port is held by another app) + // instead of silently dropping the spinner. + let detail = ''; + try { detail = (await r.json())?.detail || ''; } catch {} + setConnectError(detail || 'Could not start the login. Please try again.'); + setConnecting(null); + return; + } const data = await r.json(); runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels, markConnected }); } catch { setConnecting(null); } @@ -159,6 +170,25 @@ const SubscriptionCards: React.FC = () => { return ( + + + + {connectError} + + setConnectError(null)} + sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '0.9rem', lineHeight: 1, px: 0.3, '&:hover': { color: c.text.secondary } }} + > + × + + + {SUBSCRIPTION_PROVIDERS.map(p => ( Date: Thu, 25 Jun 2026 22:23:01 -0700 Subject: [PATCH 04/21] [aidan] fix/openai-passthrough: mount the subapp so cp-openai gpt-5 routes resolve --- backend/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index 470c9ded..9d7edf98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -46,11 +46,12 @@ from backend.apps.subscription.router import subscription from backend.apps.auth.router import auth from backend.apps.web.web import web from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy +from backend.apps.agents.core.openai_passthrough import openai_passthrough from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, anthropic_proxy]) +main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, anthropic_proxy, openai_passthrough]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the From 462ed04f976cc87ec5aec0a853168defa1404428 Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 25 Jun 2026 22:23:01 -0700 Subject: [PATCH 05/21] [aidan] fix/openai-passthrough: bare gpt-5 id, floor reasoning tokens, log upstream 4xx --- .../apps/agents/core/openai_passthrough.py | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/apps/agents/core/openai_passthrough.py b/backend/apps/agents/core/openai_passthrough.py index dc5ac2fd..97bc2c7e 100644 --- a/backend/apps/agents/core/openai_passthrough.py +++ b/backend/apps/agents/core/openai_passthrough.py @@ -5,7 +5,7 @@ import logging from contextlib import asynccontextmanager import httpx -from fastapi import Request +from fastapi import Request, Response from fastapi.responses import JSONResponse, StreamingResponse from backend.config.Apps import SubApp @@ -51,25 +51,50 @@ _GPT5_UNSUPPORTED_PARAMS = ( "logprobs", "top_logprobs", "logit_bias", ) +# Our OpenAI lane's 9Router node prefix. 0.3.60 intermittently forwards the model +# WITH this prefix (`cp-openai/gpt-5.5`) instead of stripping it, and OpenAI 400s +# "invalid model ID". We're the last hop to api.openai.com, so the model must be +# the bare id regardless of what 9Router sent. +_CP_OPENAI_PREFIX = "cp-openai/" + +# GPT-5 burns 8-30K hidden reasoning tokens before any output; under that, OpenAI 400s +# "max_tokens reached" instead of truncating. The CLI defaults to 4096. The 9router_gpt5 +# patch floors this, but it hooks 9Router's calls to api.openai.com and on our lane 9Router +# calls THIS passthrough instead, so the patch never fires here. Floor it ourselves. Match +# the patch's value. Only raise, never lower. +_GPT5_MIN_COMPLETION_TOKENS = 32768 + def _scrub_gpt5_params(body: bytes) -> bytes: - """For GPT-5: rename max_tokens→max_completion_tokens and drop the sampling - params the reasoning models reject. Bytes in/out, never raises.""" + """Prep an OpenAI chat body: normalize the model id (drop a leaked `cp-openai/` + routing prefix) and, for GPT-5, rename max_tokens→max_completion_tokens and drop the + sampling params the reasoning models reject. Bytes in/out, never raises.""" if not body: return body try: parsed = json.loads(body) except Exception: return body - if not isinstance(parsed, dict) or not _is_gpt5(str(parsed.get("model") or "")): + if not isinstance(parsed, dict): return body mutated = False + model = str(parsed.get("model") or "") + if model.startswith(_CP_OPENAI_PREFIX): + model = model[len(_CP_OPENAI_PREFIX):] + parsed["model"] = model + mutated = True + if not _is_gpt5(model): + return json.dumps(parsed).encode("utf-8") if mutated else body if "max_tokens" in parsed: if "max_completion_tokens" not in parsed: parsed["max_completion_tokens"] = parsed.pop("max_tokens") else: parsed.pop("max_tokens", None) mutated = True + mct = parsed.get("max_completion_tokens") + if isinstance(mct, (int, float)) and not isinstance(mct, bool) and mct < _GPT5_MIN_COMPLETION_TOKENS: + parsed["max_completion_tokens"] = _GPT5_MIN_COMPLETION_TOKENS + mutated = True if "temperature" in parsed and parsed["temperature"] != 1: parsed.pop("temperature", None) mutated = True @@ -115,6 +140,22 @@ async def passthrough(rest: str, request: Request): status_code=502, ) + # OpenAI sends 4xx/5xx as a small JSON error, not a stream. Surface its real + # complaint (we used to swallow it) and return it decoded so the caller sees why. + if upstream_resp.status_code >= 400: + raw = await upstream_resp.aread() + await upstream_resp.aclose() + await client.aclose() + logger.warning( + "openai-passthrough upstream %s on /%s: %s", + upstream_resp.status_code, rest, raw.decode("utf-8", "replace")[:400], + ) + return Response( + content=raw, + status_code=upstream_resp.status_code, + media_type=upstream_resp.headers.get("content-type", "application/json"), + ) + response_headers: dict[str, str] = {} for k, v in upstream_resp.headers.items(): if k.lower() in _HOP_HEADERS: From 445a82e0251a038600b94057510eb374575f52ea Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 25 Jun 2026 22:23:01 -0700 Subject: [PATCH 06/21] [aidan] fix/model-reconcile: switch stale session model off a disconnected provider --- frontend/src/app/Main.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 7e4cb477..f4c88294 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchSettings, updateSettingsPatch, markFreeTrialArmSettled } from '@/shared/state/settingsSlice'; import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; +import { updateSessionModel } from '@/shared/state/agentsSlice'; import { API_BASE } from '@/shared/config'; import { setAppVersion, @@ -340,6 +341,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } // then would clobber a real sub user's default down to a fallback (and persist it). // Only reconcile against the complete list. const nineRouterUp = useAppSelector((s) => s.subscriptions.status?.running === true); + const sessions = useAppSelector((s) => s.agents.sessions); const [warning, setWarning] = useState<{ from: string; to: string; provider: string } | null>(null); const pendingRef = useRef(false); @@ -365,6 +367,25 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } setWarning({ from: fromLabel, to: fallback.label, provider: fallback.provider }); }, [settingsLoaded, modelsLoaded, nineRouterUp, byProvider, settings, dispatch]); + // Same staleness, per session: a session pinned to a now-gone model (e.g. gpt-5.5-api + // after the OpenAI key is disconnected) makes the picker show an out-of-range value and + // the next send snags, since the send carries the session's model. The guard above only + // fixes the global default, so reconcile open sessions to the (valid) default or fallback. + useEffect(() => { + if (!settingsLoaded || !modelsLoaded || !nineRouterUp) return; + if (Object.keys(byProvider).length === 0) return; + const valid = new Set(Object.values(byProvider).flat().map((m) => m.value)); + if (valid.size === 0) return; + const fallback = pickFallbackModel(byProvider); + if (!fallback) return; + const target = valid.has(settings.default_model) ? settings.default_model : fallback.value; + for (const sess of Object.values(sessions)) { + if (sess.model && !valid.has(sess.model)) { + dispatch(updateSessionModel({ sessionId: sess.id, model: target })); + } + } + }, [settingsLoaded, modelsLoaded, nineRouterUp, byProvider, sessions, settings, dispatch]); + return ( <> {children} From b2ceb4b4665a5a37f7689b537cdf13933b4ea354 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 03:39:48 -0700 Subject: [PATCH 07/21] [aidan] fix/model-fallback: reconcile free trial and provider states --- backend/apps/agents/agents.py | 10 ++++ backend/apps/agents/providers/registry.py | 3 ++ backend/apps/nine_router/process.py | 8 ++- backend/apps/subscription/free_trial.py | 6 ++- frontend/package-lock.json | 19 ++++++- frontend/src/app/Main.tsx | 50 ++++++++++------- .../src/app/components/Layout/AppShell.tsx | 6 +-- .../src/app/pages/AgentChat/AgentChat.tsx | 53 ++++++++++++++++++- .../ChatInput/hooks/useModelPicker.ts | 1 + .../model-picker/ModelPickerMenu.tsx | 19 ++++++- .../pages/AgentChat/bubbles/MessageBubble.tsx | 3 +- frontend/src/app/pages/Settings/Settings.tsx | 7 ++- 12 files changed, 151 insertions(+), 34 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 2973d588..50f3b25c 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -810,6 +810,16 @@ async def list_models(): if entries: result[cp_name] = entries + # Free lane: nothing of the user's own is connected, so surface the funded Haiku as the free-trial face. The picker shows "Claude Haiku" and the session/default reconcile to it, instead of the picker going empty and the model staying stuck on a dead last-used id (active = it runs; spent = the send is gated by the out-of-runs UI). + if not result: + haiku_entry = next((m for m in anthropic_models if m.get("value") == "haiku"), None) + if haiku_entry: + haiku_rows = p_serialize([haiku_entry]) + for hr in haiku_rows: + hr["is_free"] = True + hr["billing_kind"] = "free" + result["Anthropic"] = haiku_rows + return {"models": result, "notes": notes} diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 49458a7b..0d593468 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -217,6 +217,9 @@ def p_antigravity_connected() -> bool: def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: """Short model name → id string for ClaudeAgentOptions.""" + # Free trial funds only Haiku via the cloud proxy; force it so a session left on a gpt-*/sub model can't escape to a lane the trial can't fund (which snags as a 401/404). + if getattr(settings, "connection_mode", "own_key") == "free-trial": + short_name = "haiku" entry = find_builtin_model(short_name) if entry is None: return short_name diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index d6e36fd2..7b94d2c8 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -357,12 +357,16 @@ async def ensure_running(): async def p_ensure_running_impl(): """Start 9Router if not already running.""" - global p_process + global p_process, p_is_running_last_ok p_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" if is_running(): # In dev mode, kill stale standalone servers (from previous builds) so we can start `next dev` which always uses latest source code if not p_is_packaged: + # But never kill the instance WE already started: a second ensure call (another sub-app's lifespan races settings') would pkill our fresh next-server, leaving a dead window the boot key-sync fails into, so the cp-openai node never registers and gpt-5.* own-key dies. + if p_process is not None and p_process.poll() is None: + logger.info("9Router already running (ours) on port %d", NINE_ROUTER_PORT) + return import subprocess as p_sp try: result = p_sp.run( @@ -372,6 +376,8 @@ async def p_ensure_running_impl(): if result.stdout.strip(): logger.info("Dev mode: killing stale standalone 9Router to use next dev instead") p_sp.run(["pkill", "-f", "next-server"], timeout=5) + # The port is about to go dead; drop the positive-cache so the start-loop below actually re-probes instead of trusting the killed server's stale "ready". + p_is_running_last_ok = 0.0 await asyncio.sleep(2) else: logger.info("9Router already running on port %d", NINE_ROUTER_PORT) diff --git a/backend/apps/subscription/free_trial.py b/backend/apps/subscription/free_trial.py index ee2ac232..889f2215 100644 --- a/backend/apps/subscription/free_trial.py +++ b/backend/apps/subscription/free_trial.py @@ -132,8 +132,10 @@ async def clear_free_trial(settings_obj) -> None: (so the UI knows it's spent) and never touches a real paid mode.""" if getattr(settings_obj, "connection_mode", "own_key") == "free-trial": settings_obj.connection_mode = "own_key" - # arm() pinned default_model to "haiku" for the free run; once the wheel is handed back, don't let that forced pick linger (it'd silently default a real subscription user to Haiku). "sonnet" is the fresh default; the frontend's DefaultModelGuard reconciles it to a reachable model if the connected provider isn't Anthropic. - if getattr(settings_obj, "default_model", None) == "haiku": + # Keep Haiku as the face of the free lane while the user has no model of their own (a spent trial still shows "Claude Haiku", with the send gated by the out-of-runs UI); only fall back to "sonnet" once a real key/sub connects so we never pin a paying user to Haiku. + if getattr(settings_obj, "default_model", None) == "haiku" and ( + has_own_model(settings_obj) or await p_has_connected_subscription() + ): settings_obj.default_model = "sonnet" settings_obj.free_trial_token = None await save_settings_async(settings_obj) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92d511d7..8e97f035 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,6 +84,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1964,6 +1965,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2007,6 +2009,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2242,6 +2245,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.10", @@ -3374,6 +3378,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3770,6 +3775,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3809,6 +3815,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4137,6 +4144,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8184,6 +8192,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8240,6 +8249,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8458,6 +8468,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8470,6 +8481,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8516,6 +8528,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8654,7 +8667,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8966,6 +8980,7 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10069,6 +10084,7 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10117,6 +10133,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 3c45618f..49acecb3 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -324,8 +324,10 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } // Until 9Router answers, /models omits subscription models, so the saved default can look "no longer available" when it's really just not loaded yet. Reconciling then would clobber a real sub user's default down to a fallback (and persist it). Only reconcile against the complete list. const nineRouterUp = useAppSelector((s) => s.subscriptions.status?.running === true); const sessions = useAppSelector((s) => s.agents.sessions); + const connectionMode = useAppSelector((s) => s.settings.data.connection_mode); + const freeTrialRemaining = useAppSelector((s) => s.settings.data.free_trial_remaining); - const [warning, setWarning] = useState<{ from: string; to: string; provider: string } | null>(null); + const [sessionSwitch, setSessionSwitch] = useState<{ toFreeTrial: boolean; runs: number | null; toLabel: string } | null>(null); const pendingRef = useRef(false); useEffect(() => { @@ -340,52 +342,60 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } const fallback = pickFallbackModel(byProvider); if (!fallback || fallback.value === settings.default_model) return; - const fromLabel = flat.find((m) => m.value === settings.default_model)?.label ?? settings.default_model; + // Persist the fallback so the stored default is never a dead model, and surface the same blue banner the per-session reconcile uses (no separate yellow notice, it just doubled up). pendingRef.current = true; dispatch(updateSettingsPatch({ default_model: fallback.value })) .finally(() => { pendingRef.current = false; }); - setWarning({ from: fromLabel, to: fallback.label, provider: fallback.provider }); - }, [settingsLoaded, modelsLoaded, nineRouterUp, byProvider, settings, dispatch]); + setSessionSwitch({ toFreeTrial: connectionMode === 'free-trial', runs: freeTrialRemaining ?? null, toLabel: fallback.label }); + }, [settingsLoaded, modelsLoaded, nineRouterUp, connectionMode, freeTrialRemaining, byProvider, settings, dispatch]); - // Same staleness, per session: a session pinned to a now-gone model (e.g. gpt-5.5-api - // after the OpenAI key is disconnected) makes the picker show an out-of-range value and - // the next send snags, since the send carries the session's model. The guard above only - // fixes the global default, so reconcile open sessions to the (valid) default or fallback. + // Same staleness per session: a session pinned to a now-gone model (e.g. gpt-5.4-api after its key is disconnected) snags on the next send since the send carries that model, so reconcile open sessions to the valid default/fallback and warn once. useEffect(() => { - if (!settingsLoaded || !modelsLoaded || !nineRouterUp) return; + if (!settingsLoaded || !modelsLoaded) return; + // free-trial/pro model lists don't wait on 9Router sub enumeration, so don't gate them on nineRouterUp (often false on the free lane) or a stranded session never recovers. + if (!nineRouterUp && connectionMode !== 'free-trial' && connectionMode !== 'openswarm-pro') return; if (Object.keys(byProvider).length === 0) return; - const valid = new Set(Object.values(byProvider).flat().map((m) => m.value)); + const flat = Object.values(byProvider).flat(); + const valid = new Set(flat.map((m) => m.value)); if (valid.size === 0) return; const fallback = pickFallbackModel(byProvider); if (!fallback) return; const target = valid.has(settings.default_model) ? settings.default_model : fallback.value; + let switched = false; for (const sess of Object.values(sessions)) { if (sess.model && !valid.has(sess.model)) { + switched = true; dispatch(updateSessionModel({ sessionId: sess.id, model: target })); } } - }, [settingsLoaded, modelsLoaded, nineRouterUp, byProvider, sessions, settings, dispatch]); + if (switched) { + const toLabel = flat.find((m) => m.value === target)?.label ?? target; + setSessionSwitch({ toFreeTrial: connectionMode === 'free-trial', runs: freeTrialRemaining ?? null, toLabel }); + } + }, [settingsLoaded, modelsLoaded, nineRouterUp, connectionMode, freeTrialRemaining, byProvider, sessions, settings, dispatch]); return ( <> {children} setWarning(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + open={!!sessionSwitch} + autoHideDuration={9000} + onClose={() => setSessionSwitch(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setWarning(null)} + onClose={() => setSessionSwitch(null)} sx={{ fontSize: '0.8rem' }} > - {warning && ( - <>Default model {warning.from} is no longer available, switched to {warning.to} ({warning.provider}). - )} + {sessionSwitch && (sessionSwitch.toFreeTrial ? ( + <>Your model isn't connected, you're on the free trial now{sessionSwitch.runs != null ? <> ({sessionSwitch.runs} runs left) : null}. + ) : ( + <>Your model isn't available anymore, switched to {sessionSwitch.toLabel}. + ))} diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 8e02cf0b..3a8e3211 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -36,6 +36,7 @@ import Dashboard from '@/app/pages/Dashboard/Dashboard'; import DashboardHost from '@/app/components/Layout/DashboardHost'; import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; import { shallowEqual } from 'react-redux'; import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; import { Typewriter } from '@/app/components/feedback/Animated'; @@ -133,10 +134,9 @@ const AppShell: React.FC = () => { }; }, []); - // /agents/models intersects BUILTIN_MODELS with API keys + 9Router state; non-empty means at least one usable model. - const modelsByProvider = useAppSelector((s) => s.models.byProvider); const modelsLoaded = useAppSelector((s) => s.models.loaded); - const hasModelConnected = Object.keys(modelsByProvider).length > 0; + // "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner. + const hasModelConnected = useAppSelector(selectHasModelConnected); // During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it. const freeTrialActive = useAppSelector((s) => { const d = s.settings.data as any; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 53dd106a..3011e733 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -8,6 +8,7 @@ import TextField from '@mui/material/TextField'; import ClickAwayListener from '@mui/material/ClickAwayListener'; import Fade from '@mui/material/Fade'; import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; @@ -336,6 +337,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // Workflow build chat only: brief "this model now runs the workflow" notice when the user switches models, so the run-model change isn't silent. const [workflowModelNotice, setWorkflowModelNotice] = useState(null); const workflowModelNoticeTimer = useRef | null>(null); + const [freeTrialModelNotice, setFreeTrialModelNotice] = useState<{ kind: 'connect' | 'spent'; label: string } | null>(null); + const freeTrialModelNoticeTimer = useRef | null>(null); + const freeTrialRemaining = useAppSelector((s) => s.settings.data.free_trial_remaining); // Read live in the stable handleSend/dispatchMessage closures without busting their memo (ChatInput leans on handleSend identity holding across renders). const runContextRef = useRef(runContext); @@ -844,16 +848,33 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }, [id, isDraft, dispatch]); const handleModelChange = useCallback((newModel: string) => { + // On the trial only Haiku is funded; picking anything else needs a connected provider, and once runs are spent nothing local works, so warn and keep the funded model instead of snagging. + if (connectionMode === 'free-trial') { + const kind: 'connect' | 'spent' | null = + (freeTrialRemaining ?? 0) <= 0 ? 'spent' : (newModel !== 'haiku' ? 'connect' : null); + if (kind) { + setFreeTrialModelNotice({ kind, label: resolveModelLabel(newModel) }); + if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current); + freeTrialModelNoticeTimer.current = setTimeout(() => setFreeTrialModelNotice(null), 6000); + return; + } + } if (workflowEditId && newModel !== model) { setWorkflowModelNotice(resolveModelLabel(newModel)); if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); workflowModelNoticeTimer.current = setTimeout(() => setWorkflowModelNotice(null), 5000); } + // Picked a usable model: drop any stale notice now (fades out in ~220ms) instead of letting it sit out its timer. + setFreeTrialModelNotice(null); + if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current); setModel(newModel); if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); - }, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel]); + }, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel, connectionMode, freeTrialRemaining]); - useEffect(() => () => { if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); }, []); + useEffect(() => () => { + if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); + if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current); + }, []); const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => { if (!id) return; @@ -2219,6 +2240,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ) : ( + ; notice: { kind: 'connect' | 'spent'; label: string } | null }) { + const last = React.useRef<{ kind: 'connect' | 'spent'; label: string } | null>(null); + if (notice) last.current = notice; + const display = last.current; + if (!display) return null; + return ( + + + + + {display.kind === 'spent' ? ( + <>You're out of free runs, connect a model in Settings to use {display.label}. + ) : ( + <>Connect a provider in Settings to use {display.label}. + )} + + + + ); +} + export default AgentChat; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useModelPicker.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useModelPicker.ts index 6c2b3f04..bbdc643c 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useModelPicker.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useModelPicker.ts @@ -179,6 +179,7 @@ export function useModelPicker( }); if (cancelled) return; const data = await res.json(); + if (!data.ok && data.error) console.warn('[model-probe]', model, data.error); setProbeResult({ value: model, ok: !!data.ok, error: data.error, latency_ms: data.latency_ms }); } catch {} }, 350); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerMenu.tsx b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerMenu.tsx index 232e06bc..1ab62e19 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerMenu.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerMenu.tsx @@ -43,6 +43,21 @@ interface Props { pendingPayloadEstimate: number; } +// Probe errors come back as raw upstream JSON ("Error code: 400 - {'error': {...}}"); never show that to users. Map to a short actionable line (the raw is console-logged in useModelPicker for devs). +function friendlyProbeError(raw?: string): string { + const r = (raw || '').toLowerCase(); + if (r.includes('no credentials') || r.includes('not connected') || r.includes('bad_request')) { + return "This model isn't connected, add its provider in Settings."; + } + if (r.includes('401') || r.includes('unauthorized') || r.includes('invalid api key') || r.includes('invalid_api_key')) { + return "This model's key looks invalid, check it in Settings."; + } + if (r.includes('402') || r.includes('quota') || r.includes('credit') || r.includes('billing')) { + return "This model is out of credits, check billing."; + } + return "This model isn't available right now."; +} + export const ModelPickerMenu: React.FC = (props) => { const { c, menuPaperProps, modelAnchor, setModelAnchor, model, onModelChange, onProviderChange, @@ -84,7 +99,7 @@ export const ModelPickerMenu: React.FC = (props) => { /> {probeResult && probeResult.value === model && !probeResult.ok && ( - + e.stopPropagation()} sx={{ @@ -108,7 +123,7 @@ export const ModelPickerMenu: React.FC = (props) => { whiteSpace: 'nowrap', opacity: 0.85, }}> - · {probeResult.error || 'this model failed its health check'} + · {friendlyProbeError(probeResult.error)} diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index 2110964e..e6473364 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -27,6 +27,7 @@ import { openSettingsModal } from '@/shared/state/settingsSlice'; import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; import { shallowEqual } from 'react-redux'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { SKILL_COLOR } from '@/app/components/editor/richEditorUtils'; import PlanPickerModal from '@/app/components/overlays/PlanPickerModal'; @@ -977,7 +978,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o frameworkOverhead: s.framework_overhead_tokens, activeMcpCount: s.active_mcps?.length ?? 0, messagesCount: s.messages?.length ?? 0, - hasModel: Object.keys(state.models.byProvider || {}).length > 0, + hasModel: selectHasModelConnected(state), } as OverflowContext; }, shallowEqual); const activeSessionId = useAppSelector((state) => state.agents.activeSessionId); diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 657143d4..e1612023 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -196,8 +196,11 @@ const Settings: React.FC = () => { if (saveTimer.current) clearTimeout(saveTimer.current); const payload = loaded ? buildSubmit() : null; if (payload) { - dispatch(updateSettingsPatch(payload.patch)); - dispatch(fetchModels()); + // Refetch only AFTER the patch lands, or it races the save and reads the pre-change list (stale Haiku until you reopen Settings). Not awaited, so the modal still closes instantly. + dispatch(updateSettingsPatch(payload.patch)) + .unwrap() + .then(() => dispatch(fetchModels())) + .catch(() => {}); baselineRef.current = form; } dispatch(closeSettingsModal()); From 6f15d40431ea70a65c2b81b89e3c90cd1ed9e04d Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 03:44:02 -0700 Subject: [PATCH 08/21] [aidan] chore/package-lock: remove lockfile churn --- frontend/package-lock.json | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e97f035..92d511d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,7 +84,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1965,7 +1964,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2009,7 +2007,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2245,7 +2242,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.10", @@ -3378,7 +3374,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3775,7 +3770,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3815,7 +3809,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4144,7 +4137,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8192,7 +8184,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8249,7 +8240,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8468,7 +8458,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8481,7 +8470,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8528,7 +8516,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8667,8 +8654,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8980,7 +8966,6 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10084,7 +10069,6 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10133,7 +10117,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", From a237393d30818aa8aa78a482d5f7ce871e3166ae Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 03:56:45 -0700 Subject: [PATCH 09/21] [aidan] fix/oauth-connect: hide internal startup errors --- backend/apps/agents/agents.py | 4 ++-- .../sections/subscription/SubscriptionCards.tsx | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 50f3b25c..081ab740 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -401,8 +401,8 @@ async def subscriptions_connect(body: dict): result = await start_oauth(provider) if result.get("flow") == "authorization_code" and result.get("state"): - from backend.main import p_pending_oauth - p_pending_oauth[result["state"]] = { + from backend.apps.oauth_state import pending_oauth + pending_oauth[result["state"]] = { "provider": provider, "code_verifier": result.get("code_verifier", ""), "redirect_uri": result.get("redirect_uri", ""), diff --git a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx index 5c84936b..200dc5e9 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx @@ -17,6 +17,17 @@ import { SUBSCRIPTION_PROVIDERS } from './subscriptionProviders'; import SubscriptionCard from './SubscriptionCard'; import { runConnectFlow } from './subscriptionConnect'; +function friendlyConnectError(detail: string): string { + const d = (detail || '').trim(); + const lower = d.toLowerCase(); + if (!d) return 'Could not start the login. Please try again.'; + if (lower.includes('1455') || lower.includes('1457') || lower.includes('codex login ports')) return d; + if (lower.includes('import name') || lower.includes('traceback') || lower.includes('/backend/') || lower.includes('backend.')) { + return 'Could not start the login. Please try again.'; + } + return d.length > 180 ? 'Could not start the login. Please try again.' : d; +} + const SubscriptionCards: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); @@ -84,7 +95,7 @@ const SubscriptionCards: React.FC = () => { // instead of silently dropping the spinner. let detail = ''; try { detail = (await r.json())?.detail || ''; } catch {} - setConnectError(detail || 'Could not start the login. Please try again.'); + setConnectError(friendlyConnectError(detail)); setConnecting(null); return; } From d5279ad1bb74db7176bc9fc196efd228ed671ca6 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 01:53:42 -0700 Subject: [PATCH 10/21] [aidan] workflows/compose: drop redundant test-run and save buttons --- .../app/pages/Workflows/app/ComposeView.tsx | 50 +------------------ .../src/app/pages/Workflows/app/SaveGuard.tsx | 38 -------------- 2 files changed, 1 insertion(+), 87 deletions(-) delete mode 100644 frontend/src/app/pages/Workflows/app/SaveGuard.tsx diff --git a/frontend/src/app/pages/Workflows/app/ComposeView.tsx b/frontend/src/app/pages/Workflows/app/ComposeView.tsx index 26ddab7c..f38e7a6b 100644 --- a/frontend/src/app/pages/Workflows/app/ComposeView.tsx +++ b/frontend/src/app/pages/Workflows/app/ComposeView.tsx @@ -2,8 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createWorkflow, updateWorkflow } from '@/shared/state/workflowsSlice'; import { sendMessage } from '@/shared/state/agentsSlice'; -import { defaultSchedule, stepsSignature, needsScheduleTestWarning } from '@/app/pages/Workflows/scheduleUtils'; -import { runWorkflowTest } from '@/app/pages/Workflows/runWorkflowTest'; +import { defaultSchedule } from '@/app/pages/Workflows/scheduleUtils'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; import InlineEditableTitle from '@/app/components/InlineEditableTitle'; import { Typewriter } from '@/app/components/feedback/Animated'; @@ -13,7 +12,6 @@ import { useEditAgentSession } from './useEditAgentSession'; import { useWorkflowPatch } from './useWorkflowPatch'; import ScheduleCard from './ScheduleCard'; import StepsCard from './StepsCard'; -import SaveGuard from './SaveGuard'; import type { AppNav } from './types'; // Short pill label for the clean cluster, plus the richer prompt actually sent so the agent gets real detail. Spread across personas (work, money, research, lifestyle, monitoring) so most people see one that fits. Keep labels similar length so they cluster two-per-row. @@ -30,8 +28,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { const dispatch = useAppDispatch(); const patch = useWorkflowPatch(); const [draftId, setDraftId] = useState(null); - const [testing, setTesting] = useState(false); - const [guardOpen, setGuardOpen] = useState(false); // null = follow the auto open-on-first-message behavior; true/false = user override. const [paneManual, setPaneManual] = useState(null); const created = useRef(false); @@ -100,25 +96,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { ); } - const tested = workflow.steps.length > 0 && stepsSignature(workflow.steps) === (workflow.tested_signature ?? ''); - - const doTest = async () => { - if (testing || workflow.steps.length === 0) return; - setTesting(true); - try { await runWorkflowTest(workflow.id, workflow.steps, async () => {}); } - finally { setTesting(false); } - }; - - // No steps / no title is fine, you can save a bare workflow and fill it in later. No If-Match: this is the user's own brand-new draft, so there's no concurrent edit to guard against and a stale stamp shouldn't block the save. - const finalizeSave = () => { - dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } })); - nav.selectWorkflow(workflow.id); - }; - const onSave = () => { - if (needsScheduleTestWarning(workflow)) { setGuardOpen(true); return; } - finalizeSave(); - }; - return ( <>
@@ -174,14 +151,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { )}
- {guardOpen && ( - setGuardOpen(false)} - onSaveAnyway={() => { setGuardOpen(false); finalizeSave(); }} - onRunTest={() => { setGuardOpen(false); doTest(); }} - /> - )} {/* Hidden on the blank landing page; opens with a smooth width/fade once @@ -192,23 +161,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { -
- {!tested && ( -
- - Not tested yet. A test run grants the tool access this workflow needs. -
- )} -
- - -
-
diff --git a/frontend/src/app/pages/Workflows/app/SaveGuard.tsx b/frontend/src/app/pages/Workflows/app/SaveGuard.tsx deleted file mode 100644 index 6b4734e1..00000000 --- a/frontend/src/app/pages/Workflows/app/SaveGuard.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { useWC } from './uiKit'; - -// Test-first nudge before scheduling: a test run grants the tool access the workflow needs, so unattended runs don't stall reaching for them. -const SaveGuard: React.FC<{ - title: string; - onClose: () => void; - onSaveAnyway: () => void; - onRunTest: () => void; -}> = ({ title, onClose, onSaveAnyway, onRunTest }) => { - const WC = useWC(); - return ( -
-
e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: WC.paper, borderRadius: WC.radius.lg, boxShadow: WC.shadow.lg, overflow: 'hidden' }}> -
-
-
- -
-

Test run recommended

-
-

- You haven’t tested “{title}” yet. A quick test run confirms the steps work and grants the tool access it needs before it goes on a schedule. -

-
-
- - -
-
-
- ); -}; - -export default SaveGuard; From 8236f7cc42e1d22e08a220ad158ee7c37d921f74 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 01:53:42 -0700 Subject: [PATCH 11/21] [aidan] workflows/detail: restore schedule-steps show/hide toggle on revisit --- .../src/app/pages/Workflows/app/DetailView.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Workflows/app/DetailView.tsx b/frontend/src/app/pages/Workflows/app/DetailView.tsx index 2000f7d4..cf0e290b 100644 --- a/frontend/src/app/pages/Workflows/app/DetailView.tsx +++ b/frontend/src/app/pages/Workflows/app/DetailView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { runWorkflowNow } from '@/shared/state/workflowsSlice'; import { openWorkflowMonitor, setWorkflowsRunContext, clearWorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; @@ -28,6 +28,7 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId const runContext = useAppSelector((s) => s.dashboardLayout.workflowsRunContext); // When you Run now from this chat, attach that run as a context chip once it finishes, so the next question rides on its transcript (removable, no popup). const autoCtxRunId = useRef(null); + const [paneOpen, setPaneOpen] = useState(true); useEffect(() => { const rid = autoCtxRunId.current; @@ -77,6 +78,13 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId :
} {running ? 'Running…' : 'Run'} +
setPaneOpen((v) => !v)} + title={paneOpen ? 'Hide schedule & steps' : 'Show schedule & steps'} + style={{ width: 28, height: 28, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: paneOpen ? WC.ink3 : WC.muted, flex: 'none' }} + > + +
{workflow.description &&
{workflow.description}
} @@ -95,12 +103,14 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId -
+
+
+
); From 3159d856d77a0d5530cfec96f810e0fc3e0550fa Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 01:53:42 -0700 Subject: [PATCH 12/21] [aidan] workflows/steps: confirm before removing a step --- .../src/app/pages/Workflows/app/StepsCard.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Workflows/app/StepsCard.tsx b/frontend/src/app/pages/Workflows/app/StepsCard.tsx index e4e7c686..e6092a56 100644 --- a/frontend/src/app/pages/Workflows/app/StepsCard.tsx +++ b/frontend/src/app/pages/Workflows/app/StepsCard.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import Dialog from '@mui/material/Dialog'; import { useAppDispatch } from '@/shared/hooks'; import { commitDraft } from '@/shared/state/workflowsSlice'; import type { Workflow, WorkflowStep } from '@/shared/state/workflowsSlice'; @@ -21,6 +22,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const patch = useWorkflowPatch(); const [local, setLocal] = useState(() => toLocal(workflow.steps)); const [draft, setDraft] = useState(''); + const [pendingDelete, setPendingDelete] = useState(null); // Agent-proposed step changes apply silently (no Apply/Discard popup): commit any staged draft as soon as it lands so the steps just update live. Guarded on real content, the edit session snapshots an empty draft on open and committing that 400s. useEffect(() => { @@ -76,9 +78,15 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { commit(next); }; const onDelete = (id: string) => { - const next = local.filter((s) => s.id !== id); + const step = local.find((s) => s.id === id); + if (step) setPendingDelete(step); + }; + const confirmDelete = () => { + if (!pendingDelete) return; + const next = local.filter((s) => s.id !== pendingDelete.id); setLocal(next); commit(next); + setPendingDelete(null); }; return ( @@ -139,6 +147,29 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
+ + setPendingDelete(null)} + PaperProps={{ style: { background: WC.paper, borderRadius: WC.radius.lg, border: `1px solid rgba(${WC.inkRGB},0.10)`, boxShadow: WC.shadow.lg, maxWidth: 340, margin: 16 } }} + > +
+
+
+ +
+ Remove step? +
+
+ {(pendingDelete?.label || pendingDelete?.text || 'This step').trim()} + {' '}will be removed from this workflow. +
+
+ + +
+
+
); }; From e91c06865466c3f775a5a3a5c5d0d54507812865 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 02:36:15 -0700 Subject: [PATCH 13/21] [aidan] workflows/steps: allow removing the last step (empty workflows permitted) --- backend/apps/workflows/workflows.py | 4 ---- frontend/src/app/pages/Workflows/app/StepsCard.tsx | 8 +++++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index ed2d6aa3..cf62f743 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -1092,8 +1092,6 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None) if not wf: raise HTTPException(status_code=404, detail="Workflow not found") if wf.draft_steps is None: - if not _has_nonempty_steps(wf.steps): - raise HTTPException(status_code=400, detail="Workflow must have at least one step") # Clicking Save is the user committing to this workflow, so reveal it in the hub (clears the "+ New" build-in-progress flag). wf.unsaved = False p_sync_model_on_save(wf, body.model if body else None) @@ -1102,8 +1100,6 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None) storage.save_workflow(wf) return _enriched(wf) before = wf.model_dump(mode="json") - if not _has_nonempty_steps(wf.draft_steps): - raise HTTPException(status_code=400, detail="Workflow must have at least one step") # Opening a workflow snapshots its own steps into the draft, and the card silently commits that draft. When it matches the live steps that's a no-op: clear it WITHOUT bumping updated_at, so merely viewing a workflow never reorders the "last edited" sidebar. Real edits fall through and bump. no_change = [s.model_dump(mode="json") for s in wf.draft_steps] == (before.get("steps") or []) wf.unsaved = False diff --git a/frontend/src/app/pages/Workflows/app/StepsCard.tsx b/frontend/src/app/pages/Workflows/app/StepsCard.tsx index e6092a56..e5aaec16 100644 --- a/frontend/src/app/pages/Workflows/app/StepsCard.tsx +++ b/frontend/src/app/pages/Workflows/app/StepsCard.tsx @@ -24,9 +24,11 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const [draft, setDraft] = useState(''); const [pendingDelete, setPendingDelete] = useState(null); - // Agent-proposed step changes apply silently (no Apply/Discard popup): commit any staged draft as soon as it lands so the steps just update live. Guarded on real content, the edit session snapshots an empty draft on open and committing that 400s. + // Agent-proposed step changes apply silently (no Apply/Discard popup): commit any staged draft as soon as it lands so the steps just update live. Still skip a draft that's all blank-text steps (agent mid-build), but DO commit an empty draft so removing the last step actually sticks. useEffect(() => { - if (workflow.has_draft && (workflow.draft_steps || []).some((s) => s.text && s.text.trim())) { + const draftSteps = workflow.draft_steps || []; + const draftReady = draftSteps.length === 0 || draftSteps.some((s) => s.text && s.text.trim()); + if (workflow.has_draft && draftReady) { dispatch(commitDraft({ id: workflow.id, keep_session: true })); } }, [workflow.has_draft, workflow.draft_steps, workflow.id, dispatch]); @@ -112,7 +114,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
update(s.id, { open: !s.open })} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.muted, flex: 'none' }}>
-
onDelete(s.id)} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }} aria-label="Delete step"> +
onDelete(s.id)} title="Remove step" style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }} aria-label="Delete step">
From 439b736e30bd35d8bf2e94de786c79fefd642b24 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 06:06:18 -0700 Subject: [PATCH 14/21] [aidan] feat/workflows-browser: spawn run browser cards on the dashboard the renderer is showing --- backend/apps/agents/core/ws_manager.py | 13 +++++++++++ backend/apps/workflows/executor.py | 22 ++++++++++++++++++- backend/main.py | 4 ++++ .../hooks/lifecycle/useDashboardLifecycle.ts | 8 +++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index bfe58269..d3e3aa27 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -1,6 +1,7 @@ import asyncio import json import logging +from typing import Optional from fastapi import WebSocket from backend.apps.agents.core.seq_log import TERMINAL_STATUSES, seq_log @@ -39,6 +40,9 @@ class ConnectionManager: def __init__(self): self.connections: dict[str, list[WebSocket]] = {} self.global_connections: list[WebSocket] = [] + # Which dashboard each global socket is currently showing, keyed by id(websocket). active_dashboard_id is the last one activated (the window the user is looking at most recently); a scheduled run targets it so its browser card spawns where the renderer can render it. + self.global_dashboard_ids: dict[int, str] = {} + self.active_dashboard_id: Optional[str] = None self.pending_futures: dict[str, asyncio.Future] = {} self.browser_futures: dict[str, asyncio.Future] = {} @@ -60,10 +64,19 @@ class ConnectionManager: if not self.connections[session_id]: del self.connections[session_id] + def set_active_dashboard(self, websocket: WebSocket, dashboard_id: str): + """Record which dashboard a renderer is showing; last activation wins.""" + self.global_dashboard_ids[id(websocket)] = dashboard_id + self.active_dashboard_id = dashboard_id + def disconnect_global(self, websocket: WebSocket): self.global_connections = [ ws for ws in self.global_connections if ws != websocket ] + # Drop this socket's active-dashboard pointer; if it owned the global one, fall back to any window still connected so a closed tab doesn't leave a stale target. + self.global_dashboard_ids.pop(id(websocket), None) + if self.active_dashboard_id not in self.global_dashboard_ids.values(): + self.active_dashboard_id = next(iter(self.global_dashboard_ids.values()), None) async def send_to_session(self, session_id: str, event: str, data: dict): """Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk.""" diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 99911767..81ea5088 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -64,6 +64,26 @@ def _resolve_allowed_tools(wf: Workflow) -> Optional[list[str]]: return list(wf.actions.configured_sets) +def p_resolve_run_dashboard_id(wf: Workflow) -> Optional[str]: + """Pick the dashboard this run's agent attaches to, so browser tools work like in chat. + + Browser cards render only on the dashboard the renderer is currently showing, so we + prefer the live active dashboard over anything stored. Resolved fresh each fire (a + stored id goes stale the moment the user switches or deletes a dashboard). Last resort + is the most-recently-updated dashboard; None just means no browser this run.""" + if wf.dashboard_id: + return wf.dashboard_id + from backend.apps.agents.core.ws_manager import ws_manager + if ws_manager.active_dashboard_id: + return ws_manager.active_dashboard_id + from backend.apps.dashboards.dashboards import load_all + dashboards = load_all() + if dashboards: + dashboards.sort(key=lambda d: d.updated_at or d.created_at, reverse=True) + return dashboards[0].id + return None + + def p_make_remember_approval(workflow_id: str): def p_remember_approval(tool_name: str, behavior: str) -> None: fresh = storage.get_workflow(workflow_id) @@ -242,7 +262,7 @@ async def execute( allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", ], - dashboard_id=wf.dashboard_id, + dashboard_id=p_resolve_run_dashboard_id(wf), ) session = await agent_manager.launch_agent(config) diff --git a/backend/main.py b/backend/main.py index 3eb9dc84..7905f39d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -355,6 +355,10 @@ async def websocket_dashboard(websocket: WebSocket): payload.get("request_id", ""), payload, ) + elif event == "dashboard:active": + dash_id = payload.get("dashboard_id") + if dash_id: + ws_manager.set_active_dashboard(websocket, dash_id) except WebSocketDisconnect: ws_manager.disconnect_global(websocket) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index d615c2c0..edb867e5 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -93,6 +93,12 @@ export function useDashboardLifecycle({ }; }, [dashboardId]); + // Tell the backend which dashboard is on screen, so a scheduled workflow run spawns its browser card on the dashboard the user can actually see. send queues until the socket opens, so firing before connect is fine. + useEffect(() => { + if (!dashboardId) return; + dashboardWs.send('dashboard:active', { dashboard_id: dashboardId }); + }, [dashboardId]); + useEffect(() => { if (!dashboardId) return; hasFittedRef.current = false; @@ -105,6 +111,8 @@ export function useDashboardLifecycle({ const cleanupBrowserHandler = initBrowserCommandHandler(); // Global broadcasts (spawned browser cards) skip the replay log, so a socket gap loses them; a reconnect refetch is the only way they return. const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => { + // A socket gap drops the backend's active-dashboard pointer; re-assert it so scheduled-run browser cards still target this dashboard after a reconnect. + dashboardWs.send('dashboard:active', { dashboard_id: dashboardId }); dispatch(fetchSessions({ dashboardId })); dispatch(fetchLayout({ dashboardId, isReconnect: true })); // workflow:run/updated/deleted are global broadcasts that skip the replay log, so a socket gap drops them: refetch to heal stale "running" cards, ghost workflows, and missed run history on reconnect. From 7cf88589e55db0a3d010dcdfd549ce8e59676963 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 06:06:18 -0700 Subject: [PATCH 15/21] [aidan] ui/workflows-run-card: fold step name into the progress bar, drop the redundant steps list --- .../app/pages/Workflows/app/RunMonitor.tsx | 44 +++---------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/frontend/src/app/pages/Workflows/app/RunMonitor.tsx b/frontend/src/app/pages/Workflows/app/RunMonitor.tsx index b79ef08d..ff0b6645 100644 --- a/frontend/src/app/pages/Workflows/app/RunMonitor.tsx +++ b/frontend/src/app/pages/Workflows/app/RunMonitor.tsx @@ -12,7 +12,6 @@ import { import type { CardType } from '@/shared/state/dashboardLayoutSlice'; import WorkflowTitle from './WorkflowTitle'; -type StepState = 'done' | 'running' | 'failed' | 'pending'; const DRAG_THRESHOLD = 3; function fmtClock(ms: number): string { @@ -131,13 +130,6 @@ const RunMonitor: React.FC = ({ workflow, cardX, cardY, cardWidth, cardHe const succeeded = run?.status === 'success' || run?.status === 'ran_late'; const sessionId = run?.session_id || null; - const stepState = (i: number): StepState => { - if (succeeded) return 'done'; - if (failed) return i < aidx ? 'done' : i === aidx ? 'failed' : 'pending'; - if (isRunning) return i < aidx ? 'done' : i === aidx ? 'running' : 'pending'; - return 'pending'; - }; - const pct = total > 0 ? Math.round((succeeded ? total : Math.min(aidx + (isRunning ? 0.5 : 0), total)) / total * 100) : (isRunning ? 10 : 0); @@ -150,8 +142,12 @@ const RunMonitor: React.FC = ({ workflow, cardX, cardY, cardWidth, cardHe const headColor = isRunning ? c.accent.primary : succeeded ? c.status.success : failed ? c.status.error : c.text.tertiary; const headBg = isRunning ? c.bg.secondary : succeeded ? c.status.successBg : failed ? c.status.errorBg : c.bg.secondary; + const activeStep = total > 0 ? steps[Math.min(aidx, total - 1)] : null; + const activeStepName = activeStep ? (activeStep.label || activeStep.text.trim().slice(0, 60)) : ''; + + const stepPrefix = `Step ${Math.min(aidx + 1, total)} of ${total}`; const progressLabel = isRunning - ? `Step ${Math.min(aidx + 1, total)} of ${total}` + ? (activeStepName ? `${stepPrefix}: ${activeStepName}` : stepPrefix) : succeeded ? `All ${total} steps complete` : failed ? `Failed at step ${Math.min(aidx + 1, total)}` : `${total} steps`; const close = () => dispatch(closeWorkflowMonitor()); @@ -200,35 +196,7 @@ const RunMonitor: React.FC = ({ workflow, cardX, cardY, cardWidth, cardHe
-
{progressLabel}
-
- - {/* workflow steps (bounded; the live chat fills the rest) */} -
- {steps.map((s, i) => { - const st = stepState(i); - const iconBg = st === 'done' ? c.status.success : st === 'failed' ? c.status.error : st === 'running' ? c.accent.primary : c.bg.secondary; - return ( -
-
-
- {st === 'done' && } - {st === 'running' &&
} - {st === 'failed' && } -
- {s.label || s.text.slice(0, 48)} - {st === 'done' && done} -
- {st === 'running' && run?.last_tool_label && ( -
-
- {run.last_tool_label} -
- )} -
- ); - })} - {total === 0 &&
This workflow has no runnable steps.
} +
{progressLabel}
{/* live transcript: read-only (prompts we send, agent responses, tool calls). Reuses AgentChat. */} From 9c5e20e4efce3433e03ae983ece48f73fe5a86e6 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 05:40:44 -0700 Subject: [PATCH 16/21] [aidan] feat/workflows-canvas: tag run and edit-chat sessions to suppress duplicate cards --- backend/apps/agents/core/models.py | 6 ++++++ backend/apps/agents/manager/AgentLaunch.py | 2 ++ backend/apps/workflows/executor.py | 5 +++-- .../hooks/lifecycle/useDashboardLifecycle.ts | 2 +- .../Dashboard/hooks/state/useDashboardController.ts | 13 +++++++++++++ frontend/src/shared/state/agentsSlice.ts | 4 ++++ 6 files changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index fae0caae..6d8f588d 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -13,6 +13,8 @@ class AgentConfig(BaseModel): max_turns: Optional[int] = None target_directory: Optional[str] = None dashboard_id: Optional[str] = None + workflow_run_id: Optional[str] = None + workflow_edit_id: Optional[str] = None # App cards the user picked to edit. When exactly one resolves, launch binds the chat's cwd to that app instead of seeding a new "Untitled App". selected_app_output_ids: Optional[list[str]] = None @@ -110,6 +112,10 @@ class AgentSession(BaseModel): dashboard_id: Optional[str] = None browser_id: Optional[str] = None parent_session_id: Optional[str] = None + # Set when this session IS a workflow run's agent; the run renders in the Workflows monitor card, so the canvas suppresses the duplicate standalone agent card. + workflow_run_id: Optional[str] = None + # Set when this session IS a workflow's embedded edit/compose chat; it lives in the Workflows hub window, so the canvas suppresses its standalone card and docks its browser below the hub. + workflow_edit_id: Optional[str] = None workflow_test_state: Optional[Literal["running", "complete", "error"]] = None # Browser memory signals, drive the subtle "remembered/learned" card chip so the user feels the agent getting smarter without lifting a finger. memory_recalled: bool = False diff --git a/backend/apps/agents/manager/AgentLaunch.py b/backend/apps/agents/manager/AgentLaunch.py index 40ff612b..c8d2a54a 100644 --- a/backend/apps/agents/manager/AgentLaunch.py +++ b/backend/apps/agents/manager/AgentLaunch.py @@ -115,6 +115,8 @@ class AgentLaunch(AgentManagerProtocol): repo_url=repo_url, branch=branch_name, dashboard_id=config.dashboard_id, + workflow_run_id=config.workflow_run_id, + workflow_edit_id=config.workflow_edit_id, thinking_level=getattr(global_settings, "default_thinking_level", "auto"), ) apply_context_window(session, global_settings) diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 81ea5088..357396ab 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -64,7 +64,7 @@ def _resolve_allowed_tools(wf: Workflow) -> Optional[list[str]]: return list(wf.actions.configured_sets) -def p_resolve_run_dashboard_id(wf: Workflow) -> Optional[str]: +def resolve_workflow_dashboard_id(wf: Workflow) -> Optional[str]: """Pick the dashboard this run's agent attaches to, so browser tools work like in chat. Browser cards render only on the dashboard the renderer is currently showing, so we @@ -262,7 +262,8 @@ async def execute( allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", ], - dashboard_id=p_resolve_run_dashboard_id(wf), + dashboard_id=resolve_workflow_dashboard_id(wf), + workflow_run_id=run.id, ) session = await agent_manager.launch_agent(config) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index edb867e5..b73b5af4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -279,7 +279,7 @@ export function useDashboardLifecycle({ useEffect(() => { if (!layoutInitialized) return; const dashboardSessionIds = Object.values(sessions) - .filter((s) => s.dashboard_id === dashboardId && s.mode !== 'browser-agent' && s.mode !== 'invoked-agent' && s.mode !== 'sub-agent') + .filter((s) => s.dashboard_id === dashboardId && !s.workflow_run_id && !s.workflow_edit_id && s.mode !== 'browser-agent' && s.mode !== 'invoked-agent' && s.mode !== 'sub-agent') .map((s) => s.id); const liveIds = dashboardSessionIds.sort().join(','); if (liveIds === prevSessionIdsRef.current) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 3740ff74..4fca9126 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -49,6 +49,18 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { !!workflowsMonitorId && s.workflows.active.some((a) => a.workflow_id === workflowsMonitorId)); const workflowsMonitorLabel = monitorIsLive ? 'Watching' : 'Viewing'; + // The session id of the run the monitor is showing, mirroring RunMonitor's pinned-or-latest pick, so its browser tether can anchor to the monitor card. + const workflowsMonitorRunId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorRunId); + const monitorRuns = useAppSelector((s) => (workflowsMonitorId ? s.workflows.runs[workflowsMonitorId] : undefined)); + const allRuns = useAppSelector((s) => s.workflows.allRuns); + const monitorRunSessionId = useMemo(() => { + if (!workflowsMonitorId) return null; + const run = workflowsMonitorRunId + ? (monitorRuns || []).find((r) => r.id === workflowsMonitorRunId) || allRuns.find((r) => r.id === workflowsMonitorRunId) + : (monitorRuns && monitorRuns[0]) || allRuns.find((r) => r.workflow_id === workflowsMonitorId); + return run?.session_id || null; + }, [workflowsMonitorId, workflowsMonitorRunId, monitorRuns, allRuns]); + const contentBounds = useMemo( () => computeContentBounds(cards, viewCards, browserCards, workflowCards, workflowsHub), [cards, viewCards, browserCards, workflowCards, workflowsHub], @@ -295,6 +307,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, + monitorRunSessionId, }); return { diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 4d586354..14a6f30d 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -93,6 +93,10 @@ export interface AgentSession { dashboard_id?: string; browser_id?: string | null; parent_session_id?: string | null; + /** Set when this session IS a workflow run's agent; the run shows in the Workflows monitor, so it gets no standalone canvas card. */ + workflow_run_id?: string | null; + /** Set when this session IS a workflow's embedded edit/compose chat; it lives in the Workflows hub, so it gets no standalone card and its browser docks below the hub. */ + workflow_edit_id?: string | null; /** Browser memory signals that drive the subtle "Remembered"/"Learned" card chip. */ memory_recalled?: boolean; memory_learned?: boolean; From ba002121a0cc4c7d9a46cf49032dbeb72fa205ca Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 05:40:48 -0700 Subject: [PATCH 17/21] [aidan] fix/workflows-edit-agent: draft steps on first prompt, never perform the task --- backend/apps/workflows/workflows.py | 48 ++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index cf62f743..389f9a6c 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -916,6 +916,26 @@ async def edit_agent_session(workflow_id: str): if wf.draft_steps is None: wf.draft_steps = list(wf.steps) storage.save_workflow(wf) + # Reattach: an edit session created before browser support (or last opened on another dashboard) lacks the markers, so its browser would spawn nowhere and the canvas couldn't dock it under the hub. Refresh the live session + rebroadcast so an already-built workflow gets the fix without recreating its chat. + from backend.apps.agents.agent_manager import agent_manager as p_am + from backend.apps.agents.core.ws_manager import ws_manager as p_wsm + p_sess = p_am.sessions.get(existing_id) + if p_sess is not None: + p_sess.dashboard_id = p_wsm.active_dashboard_id or executor.resolve_workflow_dashboard_id(wf) + p_sess.workflow_edit_id = wf.id + try: + from backend.apps.agents.manager.session.session_store import _save_session + _save_session(p_sess.id, p_sess.model_dump(mode="json")) + except Exception: + logger.debug("could not persist reattached edit-agent markers", exc_info=True) + try: + await p_wsm.send_to_session(existing_id, "agent:status", { + "session_id": existing_id, + "status": p_sess.status, + "session": p_sess.model_dump(mode="json"), + }) + except Exception: + logger.debug("could not rebroadcast reattached edit-agent", exc_info=True) return {"session_id": existing_id} # Fresh edit session: snapshot a clean draft from the current committed steps so the Edit Agent's edits stage there (never the live workflow) until the user clicks Save, and Discard reverts to exactly this. @@ -929,9 +949,15 @@ async def edit_agent_session(workflow_id: str): intro = ( "Help the user iterate on it." if wf.steps - else "This workflow is brand new and has no steps yet. Help the user " - "build it from scratch: ask what it should do, then add steps with " - "AddWorkflowStep." + else "This workflow is brand new and has no steps yet. The user's first " + "message tells you what it should do, so act on it: turn that request " + "into one or more steps with AddWorkflowStep instead of replying with " + "only text. Don't stall on open-ended 'what should this do' questions. " + "The one exception: if a step genuinely can't run without a specific " + "detail only the user has (their location, an account, a recipient, " + "etc.), ask for that one thing first with AskUserQuestion, then add " + "the step with it baked in, so you never leave behind a step you " + "already know won't run." ) steps_block = f"Current steps:\n{steps_lines}\n\n" if wf.steps else "It has no steps yet.\n\n" system_prompt = ( @@ -940,9 +966,13 @@ async def edit_agent_session(workflow_id: str): f"{wf.description or '(unspecified)'}.\n\n" f"{steps_block}" "How to work:\n" - "1. When the user describes a change, briefly confirm what you'll do.\n" - "2. If you need to look at files / search / activate an MCP / etc. to " - "verify your idea, use your tools.\n" + "1. You BUILD the workflow; you never perform it. Do NOT carry out the " + "user's actual task in this chat: don't open a browser, send email, or " + "do the real work yourself. Your job is to turn the request into steps; " + "running them is the Test Agent's job (see TestWorkflow below). When the " + "user describes a change, briefly confirm what you'll do, then make it.\n" + "2. You may use read-only tools (read files, search, MCPSearch) only to " + "check that a step is feasible, never to complete the task itself.\n" "3. To change the workflow's steps, call the matching tool. Your edits " "STAGE to a pending draft and are fully reversible; nothing touches the " "live workflow until the user clicks Save. The card shows your draft as " @@ -967,6 +997,9 @@ async def edit_agent_session(workflow_id: str): "objects at the user; that belongs in your EditWorkflowStep tool call, " "not the message." ) + # The edit chat lives in the Workflows hub on whatever dashboard the user is viewing, so its browser must spawn there (else BrowserAgent has no card to drive). Prefer the live active dashboard over the workflow's stored home. + from backend.apps.agents.core.ws_manager import ws_manager as p_wsm + edit_dashboard_id = p_wsm.active_dashboard_id or executor.resolve_workflow_dashboard_id(wf) config = AgentConfig( name=f"Edit Agent: {wf.title}", model=wf.model or "sonnet", @@ -974,7 +1007,8 @@ async def edit_agent_session(workflow_id: str): provider=wf.provider or "anthropic", system_prompt=system_prompt, allowed_tools=[], - dashboard_id=wf.dashboard_id, + dashboard_id=edit_dashboard_id, + workflow_edit_id=wf.id, ) session = await agent_manager.launch_agent(config) # launch_agent marks the session "running" assuming a turn fires immediately, but an edit-agent chat sits idle until the user sends something. Settle it to idle or the chat is stuck "thinking" forever. An existing workflow also gets a fixed (non-LLM) intro message; a brand-new build stays empty so the compose page can show its own starter prompts. From f8041ddf89a28b6190aff71608b8d91d91f8dc03 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 05:40:53 -0700 Subject: [PATCH 18/21] [aidan] ux/workflows-run-card: dock browsers, even spacing, aligned tether, auto-open on manual run --- .../Dashboard/geometry/dashboardTethers.ts | 30 ++++++---- .../src/shared/state/dashboardLayoutSlice.ts | 47 +++++++++++---- frontend/src/shared/ws/WebSocketManager.ts | 59 ++++++++++++------- 3 files changed, 94 insertions(+), 42 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 5215c470..142c9a77 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -91,6 +91,8 @@ interface UseTethersArgs { workflowsHub: WorkflowsHubPosition | null; workflowsMonitorCard: WorkflowsHubPosition | null; workflowsMonitorLabel: string; + /** Session id of the run the monitor is showing; its browser tethers to the monitor card, not a (suppressed) standalone agent card. */ + monitorRunSessionId: string | null; } export function useTethers({ @@ -111,8 +113,10 @@ export function useTethers({ workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, + monitorRunSessionId, }: UseTethersArgs): Tether[] { return useMemo(() => { + const sessionById = new Map(sessionList.map((s) => [s.id, s])); const wfHeight = (wc: WorkflowCardPosition): number => measuredHeightsRef.current![wc.workflow_id] ?? wc.height; const agentTethers = Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, label }]) => { @@ -163,13 +167,18 @@ export function useTethers({ label: string, fading: boolean, ): Tether | null { - const src = cards[sourceId]; + // Workflow chats have no standalone agent card: a run anchors to the monitor card, an edit/compose chat to the hub window, so the browser tether lands on the workflow surface instead of nothing. + const srcSession = sessionById.get(sourceId); + const srcIsMonitor = !!workflowsMonitorCard && sourceId === monitorRunSessionId; + const srcIsHub = !srcIsMonitor && !!workflowsHub && !!srcSession?.workflow_edit_id; + const src = srcIsMonitor ? workflowsMonitorCard : srcIsHub ? workflowsHub : cards[sourceId]; if (!src || !dst) return null; + const srcDragId = srcIsMonitor ? 'workflows-monitor' : srcIsHub ? 'workflows-hub' : sourceId; let srcX = src.x, srcY = src.y; let dstX = dst.x, dstY = dst.y; if (liveDragInfo) { - if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === srcDragId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } @@ -265,12 +274,14 @@ export function useTethers({ if (s.status !== 'running' && s.status !== 'waiting_approval') continue; if (!s.browser_id || !s.parent_session_id) continue; if (glowTethers.has(s.browser_id)) continue; + // A browser docked below the hub keeps a "Browser" pointer so the link reads at a glance; the right-docked agent/run cases stay label-free (their glow already said it on spawn). + const parent = sessionById.get(s.parent_session_id); const t = cardTether( browserCards[s.browser_id], s.browser_id, s.parent_session_id, `browser-${s.browser_id}`, - '', + parent?.workflow_edit_id ? 'Browser' : '', false, ); if (t) glowTethers.set(s.browser_id, t); @@ -407,7 +418,7 @@ export function useTethers({ }); } - // Run Monitor tether: the Workflows window to its spawned live-run card. Same border-anchor + elbow math as the sidecar "Watching" arrow. + // Run Monitor tether: the Workflows window to its spawned live-run card. const monitorTethers: Tether[] = []; if (workflowsHub && workflowsMonitorCard) { let hubX = workflowsHub.x, hubY = workflowsHub.y; @@ -417,12 +428,9 @@ export function useTethers({ if (liveDragInfo.cardId === 'workflows-hub') { hubX += liveDragInfo.dx; hubY += liveDragInfo.dy; } if (liveDragInfo.cardId === 'workflows-monitor') { monX += liveDragInfo.dx; monY += liveDragInfo.dy; } } - const hubRect = { x: hubX, y: hubY, width: workflowsHub.width, height: workflowsHub.height }; - const monRect = { x: monX, y: monY, width: workflowsMonitorCard.width, height: workflowsMonitorCard.height }; - const hubC = rectCenter(hubRect); - const monC = rectCenter(monRect); - const a = borderPoint(hubRect.x, hubRect.y, hubRect.width, hubRect.height, monC.x, monC.y); - const b = borderPoint(monRect.x, monRect.y, monRect.width, monRect.height, hubC.x, hubC.y); + // The monitor always spawns directly right of the hub, so anchor at the hub's right edge and the monitor's left edge at the same 0.54 height the browser/agent tethers use. Keeps the window->monitor line at the identical vertical spot as the monitor->browser line. + const a = { x: hubX + workflowsHub.width, y: hubY + workflowsHub.height * 0.54 }; + const b = { x: monX, y: monY + workflowsMonitorCard.height * 0.54 }; const midX = a.x + (b.x - a.x) / 2; const midY = a.y + (b.y - a.y) / 2; // The label box is left-anchored at labelX (rect starts there and grows right), so shift left by half the text width to truly center it on the line. @@ -466,5 +474,5 @@ export function useTethers({ return [...agentTethers, ...browserTethers, ...workflowTethers, ...viewTethers, ...monitorTethers]; // measuredHeightsTick re-runs the memo once ResizeObserver reports a new height after a collapse (the ref read is invisible to the dep checker). eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId]); } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index a7ed27f0..999e1515 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -25,6 +25,8 @@ export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W; export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H; export const EXPANDED_CARD_MIN_H = 620; export const GRID_GAP = 24; +// Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced. +export const WORKFLOW_CARD_GAP = 140; const GRID_ORIGIN = { x: 40, y: 100 }; const GRID_COLS_FALLBACK = 4; @@ -380,21 +382,18 @@ export function findOpenSpotNear( return findOpenGridCell(occupiedRects, newW, newH); } -export function placeInParentColumn( +// Dock a new card to the right of an anchor card, stacking under any cards already in that right-hand column. Anchor is any rect, so a browser can dock beside a normal agent card OR a workflow run/monitor card that has no session entry in state.cards. +export function placeBesideCard( state: DashboardLayoutState, - parentSessionId: string | null | undefined, + anchor: { x: number; y: number; width: number; height: number }, newW: number, newH: number, expandedSessionIds?: string[], exclude?: CardPlacementExclusion, + gap: number = GRID_GAP * 12, ): { x: number; y: number } { const rects = collectOccupiedRects(state, expandedSessionIds, exclude); - const parentCard = parentSessionId ? state.cards[parentSessionId] : null; - if (!parentCard) { - return findOpenGridCell(rects, newW, newH); - } - - const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; + const targetX = anchor.x + anchor.width + gap; const columnCards = [ ...Object.values(state.browserCards).filter( (c) => !(exclude?.type === 'browser' && exclude.id === c.browser_id), @@ -405,11 +404,39 @@ export function placeInParentColumn( ].filter((c) => Math.abs(c.x - targetX) < 50); const targetY = columnCards.length > 0 ? Math.max(...columnCards.map((c) => c.y + c.height)) + GRID_GAP - : parentCard.y; + : anchor.y; return findOpenSpotNear(targetX, targetY, rects, newW, newH); } +// Dock a new card directly below an anchor card (left edges aligned). Used for a browser spawned by a Workflows-hub chat, which has no agent card to sit beside. +export function placeBelowCard( + state: DashboardLayoutState, + anchor: { x: number; y: number; width: number; height: number }, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { x: number; y: number } { + const rects = collectOccupiedRects(state, expandedSessionIds, exclude); + return findOpenSpotNear(anchor.x, anchor.y + anchor.height + GRID_GAP, rects, newW, newH); +} + +export function placeInParentColumn( + state: DashboardLayoutState, + parentSessionId: string | null | undefined, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { x: number; y: number } { + const parentCard = parentSessionId ? state.cards[parentSessionId] : null; + if (!parentCard) { + return findOpenGridCell(collectOccupiedRects(state, expandedSessionIds, exclude), newW, newH); + } + return placeBesideCard(state, parentCard, newW, newH, expandedSessionIds, exclude); +} + // Reconnect-refetch merge: ADD only the cards the snapshot carries that the client is missing (e.g. a spawned browser whose broadcast was lost in a socket gap), collision-resolving each against the live layout so a recovered card can't land on a card already on canvas, and NEVER touch a card the client already has (that's exactly what preserves its live, collision-placed position). The shared `occupied` list carries placements forward so two recovered cards in the same pass also avoid each other. function addMissingCards( live: Record, @@ -945,7 +972,7 @@ const dashboardLayoutSlice = createSlice({ // Keep the existing card position when just switching the run shown. if (!state.workflowsMonitorCard) { state.workflowsMonitorCard = { - x: hub ? hub.x + hub.width + 140 : 220, + x: hub ? hub.x + hub.width + WORKFLOW_CARD_GAP : 220, y: hub ? hub.y : 160, width: 520, height: hub ? hub.height : 560, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index f52b07cf..ec571478 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -25,7 +25,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, openWorkflowsApp } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { fetchSettings } from '../state/settingsSlice'; import { displaySessionName } from '../state/sessionDisplay'; @@ -68,6 +68,9 @@ interface WSManagerOptions { const HEARTBEAT_INTERVAL_MS = 25_000; const HEARTBEAT_TIMEOUT_MS = 10_000; +// Manual runs whose monitor card we've already popped open, so the repeated "workflow:run" updates that stream during a run don't re-pin or re-stack the card. +const autoOpenedRunIds = new Set(); + interface QueuedFrame { event: string; data: Record; @@ -673,7 +676,13 @@ class WebSocketManager { case 'workflow:run': if (data.run) { - store.dispatch(upsertRun(data.run)); + const run = data.run; + store.dispatch(upsertRun(run)); + // A manual run (the Run button OR the edit agent's RunWorkflowNow) should surface its live card the moment it starts. Fire once per run so the run's later tool-call updates don't keep re-pinning the monitor; scheduled runs stay quiet so they never hijack the canvas. + if (run.status === 'running' && run.triggered_by === 'manual' && run.id && !autoOpenedRunIds.has(run.id)) { + autoOpenedRunIds.add(run.id); + store.dispatch(openWorkflowMonitor({ workflowId: run.workflow_id, runId: run.id })); + } } break; @@ -758,25 +767,33 @@ class WebSocketManager { if (parentId) { const layoutState = store.getState().dashboardLayout; const browserCard = layoutState.browserCards[data.browser_card.browser_id]; - if (layoutState.cards[parentId] && browserCard) { - const pos = placeInParentColumn( - layoutState, - parentId, - browserCard.width, - browserCard.height, - undefined, - { type: 'browser', id: browserCard.browser_id }, - ); - store.dispatch(setBrowserCardPosition({ - browserId: data.browser_card.browser_id, - x: pos.x, - y: pos.y, - })); - store.dispatch(setGlowingBrowserCards({ - browserIds: [data.browser_card.browser_id], - sessionId: parentId, - label: 'Use Browser', - })); + if (browserCard) { + const exclude = { type: 'browser' as const, id: browserCard.browser_id }; + const parentCard = layoutState.cards[parentId]; + // Workflow chats have no standalone agent card: a run lives in the monitor (dock beside it), an edit/compose chat lives in the hub window (dock below it). Without this the browser keeps the backend's default spot, which overlaps the Workflows window. + const sess = store.getState().agents.sessions[parentId]; + let pos: { x: number; y: number } | null = null; + let glowLabel = 'Use Browser'; + if (parentCard) { + pos = placeBesideCard(layoutState, parentCard, browserCard.width, browserCard.height, undefined, exclude); + } else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) { + pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP); + } else if (sess?.workflow_edit_id && layoutState.workflowsHub) { + pos = placeBelowCard(layoutState, layoutState.workflowsHub, browserCard.width, browserCard.height, undefined, exclude); + glowLabel = 'Browser'; + } + if (pos) { + store.dispatch(setBrowserCardPosition({ + browserId: data.browser_card.browser_id, + x: pos.x, + y: pos.y, + })); + store.dispatch(setGlowingBrowserCards({ + browserIds: [data.browser_card.browser_id], + sessionId: parentId, + label: glowLabel, + })); + } } } } From 4d57ce32e243cb27d5179be001fbe7bbece50b96 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 05:59:49 -0700 Subject: [PATCH 19/21] [aidan] fix/workflows-test-run: attach test agent to the active dashboard so browser tools have a card --- backend/apps/workflows/workflows.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 389f9a6c..c97c95aa 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -1226,8 +1226,8 @@ async def test_run_workflow(workflow_id: str, body: dict): raise HTTPException(status_code=400, detail="Workflow has no steps to test") from backend.apps.agents.core.models import AgentConfig - from backend.apps.agents.agent_manager import ( - agent_manager, + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.manager.permissions.workflow_approval import ( clear_workflow_approval_memory, get_workflow_step_usage, set_workflow_approval_memory, @@ -1235,6 +1235,9 @@ async def test_run_workflow(workflow_id: str, body: dict): ) from backend.apps.workflows import executor + # Like a real run, the test must attach to the dashboard the user is watching, else its browser tools have no card to drive and the test "runs" but visibly does nothing. Prefer the live active dashboard over the workflow's stored home. + from backend.apps.agents.core.ws_manager import ws_manager as p_wsm + test_dashboard_id = p_wsm.active_dashboard_id or executor.resolve_workflow_dashboard_id(wf) resolved_allowed_tools = executor._resolve_allowed_tools(wf) config = AgentConfig( name=f"{wf.title or 'Workflow'} (test)", @@ -1245,7 +1248,7 @@ async def test_run_workflow(workflow_id: str, body: dict): allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", ], - dashboard_id=wf.dashboard_id, + dashboard_id=test_dashboard_id, ) session = await agent_manager.launch_agent(config) session.workflow_test_state = "running" From 5af876e8b5504625e5a42527606e9aba0607f3c1 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 05:59:50 -0700 Subject: [PATCH 20/21] [aidan] ux/workflows-run-card: keep the exact gap when docking a run's browser --- frontend/src/shared/state/dashboardLayoutSlice.ts | 5 +++++ frontend/src/shared/ws/WebSocketManager.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 999e1515..75f9b8b3 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -391,6 +391,7 @@ export function placeBesideCard( expandedSessionIds?: string[], exclude?: CardPlacementExclusion, gap: number = GRID_GAP * 12, + exact: boolean = false, ): { x: number; y: number } { const rects = collectOccupiedRects(state, expandedSessionIds, exclude); const targetX = anchor.x + anchor.width + gap; @@ -406,6 +407,10 @@ export function placeBesideCard( ? Math.max(...columnCards.map((c) => c.y + c.height)) + GRID_GAP : anchor.y; + // exact keeps the precise gap (so the card mirrors however its anchor was placed, e.g. a run browser matching the hub->monitor gap); grid-snapping would knock that gap off. Fall back to the snapped search only if the exact spot is taken. + if (exact && !rects.some((r) => rectsOverlap({ x: targetX, y: targetY, w: newW, h: newH }, r))) { + return { x: targetX, y: targetY }; + } return findOpenSpotNear(targetX, targetY, rects, newW, newH); } diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index ec571478..b2a3ffcd 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -777,7 +777,7 @@ class WebSocketManager { if (parentCard) { pos = placeBesideCard(layoutState, parentCard, browserCard.width, browserCard.height, undefined, exclude); } else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) { - pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP); + pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP, true); } else if (sess?.workflow_edit_id && layoutState.workflowsHub) { pos = placeBelowCard(layoutState, layoutState.workflowsHub, browserCard.width, browserCard.height, undefined, exclude); glowLabel = 'Browser'; From 61c8b020261d6affb4608793bff3ff97f1163508 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 05:59:50 -0700 Subject: [PATCH 21/21] [aidan] ux/canvas-tether: center the browser and view tether label on the line --- .../src/app/pages/Dashboard/geometry/dashboardTethers.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 142c9a77..d168c5cf 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -243,8 +243,9 @@ export function useTethers({ const midX = x1 + (x2 - x1) / 2; const midY = y1 + (y2 - y1) / 2; - const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15; - const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; + // Center the pill on the line midpoint: the box is left-anchored at labelX, so back off half its text width (same trick as the monitor "Watching" label). + const labelX = midX - (label.length * 7.5) / 2; + const labelY = midY; return { key,