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/apps/agents/agents.py b/backend/apps/agents/agents.py index 62ea20ce..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", ""), @@ -432,6 +432,8 @@ async def subscriptions_poll(body: dict): from backend.apps.service.client import sync as p_sync from backend.apps.settings.settings import load_settings p_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)) @@ -465,6 +467,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: @@ -805,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/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/core/openai_passthrough.py b/backend/apps/agents/core/openai_passthrough.py index ad23b942..20c6db99 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 @@ -48,25 +48,43 @@ P_GPT5_UNSUPPORTED_PARAMS = ( "logprobs", "top_logprobs", "logit_bias", ) +# Our OpenAI lane's 9Router node prefix; 0.3.60 intermittently forwards the model WITH it (cp-openai/gpt-5.5) so OpenAI 400s "invalid model ID", and as the last hop we strip it to the bare id. +P_CP_OPENAI_PREFIX = "cp-openai/" + +# GPT-5 burns 8-30K hidden reasoning tokens before output and OpenAI 400s "max_tokens reached" under that; the 9router_gpt5 patch's floor never fires on our lane (9Router calls this passthrough, not api.openai.com), so floor it here, only raising. +P_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 p_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(P_CP_OPENAI_PREFIX): + model = model[len(P_CP_OPENAI_PREFIX):] + parsed["model"] = model + mutated = True + if not p_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 < P_GPT5_MIN_COMPLETION_TOKENS: + parsed["max_completion_tokens"] = P_GPT5_MIN_COMPLETION_TOKENS + mutated = True if "temperature" in parsed and parsed["temperature"] != 1: parsed.pop("temperature", None) mutated = True @@ -112,6 +130,21 @@ 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 P_HOP_HEADERS: 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/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/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/oauth.py b/backend/apps/nine_router/oauth.py index 239d67cd..538bade6 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -17,7 +17,9 @@ logger = logging.getLogger(__name__) # OpenAI's Codex OAuth client is registered with a fixed redirect URI `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`. Anthropic and Google's clients accept arbitrary localhost callbacks (we use 9Router's 20128 callback page). For Codex we spawn a one-shot listener on 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so the frontend's existing popup + msgHandler flow works unchanged. -P_CODEX_CALLBACK_PORT = 1455 +# OpenAI's Codex OAuth client registers BOTH loopback redirect ports in its Hydra allow-list (1455 default, 1457 fallback) and the official Codex CLI falls back to 1457 for the "another app holds 1455" case (openai/codex PR #19334), so we try them in order and reject anything off the list. +P_CODEX_CALLBACK_PORTS = (1455, 1457) +P_CODEX_CALLBACK_PORT = P_CODEX_CALLBACK_PORTS[0] P_CODEX_CALLBACK_PATH = "/auth/callback" P_CODEX_CALLBACK_HTML = b""" Authorization Complete @@ -54,14 +56,19 @@ p{color:#888;margin:0} """ -async def p_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 can reclaim a port from a still-bound prior attempt instead of failing to bind and leaving OpenAI's redirect unanswered. +p_codex_listener_server: "asyncio.base_events.Server | None" = None - Serves GET /auth/callback with P_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 p_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 P_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 P_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 @@ -150,15 +157,35 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas except Exception: pass - try: - server = await asyncio.start_server(p_handle, "127.0.0.1", P_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 p_codex_listener_server + # A new connect supersedes any abandoned one: close our own still-bound prior listener first so this attempt can take the port instead of colliding. + if p_codex_listener_server is not None: + try: + p_codex_listener_server.close() + await p_codex_listener_server.wait_closed() + except Exception: + pass + p_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 P_CODEX_CALLBACK_PORTS: + try: + server = await asyncio.start_server(p_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 P_CODEX_CALLBACK_PORTS) logger.warning( - f"Could not start Codex callback listener on port {P_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 :{P_CODEX_CALLBACK_PORTS[0]}) and retry." ) return None + p_codex_listener_server = server async def p_lifecycle(): try: @@ -175,10 +202,13 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas await server.wait_closed() except Exception: pass + global p_codex_listener_server + if p_codex_listener_server is server: + p_codex_listener_server = None asyncio.create_task(p_lifecycle()) - logger.info(f"Started Codex callback listener on http://localhost:{P_CODEX_CALLBACK_PORT}{P_CODEX_CALLBACK_PATH}") - return server + logger.info(f"Started Codex callback listener on http://localhost:{bound_port}{P_CODEX_CALLBACK_PATH}") + return bound_port # Providers whose OAuth flow MUST run in the user's real browser via shell.openExternal, not the in-Electron window.open popup: - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's own Desktop-app OAuth guidance both prescribe the system browser. - codex: auth.openai.com renders blank in our popup on some machines (newer embed detection + regional checks); system browser surfaces the real error. - claude: email magic-link opens in the user's default browser, which is a different cookie jar from the embedded popup, so the popup can never receive the auth. Forcing the OAuth flow into the system browser keeps everything in one cookie jar. The callback for gemini-cli/antigravity lands on /api/subscriptions/callback and runs the exchange server-side; codex uses its fixed 1455 listener; claude is special-cased in p_callback_uri_for_provider below. @@ -250,7 +280,15 @@ async def start_oauth(provider: str) -> dict: callback_url = p_callback_uri_for_provider(provider) if provider == "codex": - await p_start_codex_callback_listener() + # Codex's redirect must be an OpenAI allow-listed loopback port; bind the first free one (1455 else 1457) and use ITS redirect_uri so authorize + token exchange agree. + bound_port = await p_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}{P_CODEX_CALLBACK_PATH}" r = await client.get( f"{NINE_ROUTER_API}/oauth/{provider}/authorize", 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 a363fc8f..889f2215 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__) @@ -132,14 +132,27 @@ 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) await p_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/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 99911767..357396ab 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 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 + 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,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=wf.dashboard_id, + dashboard_id=resolve_workflow_dashboard_id(wf), + workflow_run_id=run.id, ) session = await agent_manager.launch_agent(config) diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index ed2d6aa3..c97c95aa 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. @@ -1092,8 +1126,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 +1134,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 @@ -1196,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, @@ -1205,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)", @@ -1215,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" diff --git a/backend/main.py b/backend/main.py index dfc1817e..7905f39d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -43,12 +43,13 @@ 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 backend.apps.workflows.workflows import workflows 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, workflows]) +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, workflows, openai_passthrough]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py. @@ -354,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) @@ -406,7 +411,7 @@ P_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.

' '
' '' '' @@ -454,6 +459,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(P_SUCCESS_HTML) diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 865b257e..49acecb3 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, @@ -322,8 +323,11 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } const modelsLoaded = useAppSelector((s) => s.models.loaded); // 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(() => { @@ -338,33 +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.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) 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 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 })); + } + } + 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/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 5215c470..d168c5cf 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; } } @@ -234,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, @@ -265,12 +275,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 +419,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 +429,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 +475,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/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index d615c2c0..b73b5af4 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. @@ -271,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/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()); diff --git a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx index d3dba0d5..200dc5e9 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'; @@ -16,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(); @@ -26,6 +38,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 +78,7 @@ const SubscriptionCards: React.FC = () => { const handleConnect = async (providerId: string) => { if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); } + setConnectError(null); setConnecting(providerId); setUserCode(''); @@ -76,7 +90,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(friendlyConnectError(detail)); + setConnecting(null); + return; + } const data = await r.json(); runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels, markConnected }); } catch { setConnecting(null); } @@ -159,6 +181,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 => ( = ({ 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/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 -
+
+
+
); 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. */} 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; diff --git a/frontend/src/app/pages/Workflows/app/StepsCard.tsx b/frontend/src/app/pages/Workflows/app/StepsCard.tsx index e4e7c686..e5aaec16 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,10 +22,13 @@ 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. + // 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]); @@ -76,9 +80,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 ( @@ -104,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">
@@ -139,6 +149,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. +
+
+ + +
+
+
); }; 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; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index a7ed27f0..75f9b8b3 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,19 @@ 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, + exact: boolean = false, ): { 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 +405,43 @@ 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; + // 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); } +// 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 +977,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..b2a3ffcd 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, true); + } 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, + })); + } } } }