Merge branch 'aidan/fix/provider-auth-and-workflows-ui' of https://github.com/openswarm-ai/openswarm into integration/pr-110

This commit is contained in:
ciregenz
2026-06-26 07:59:50 -07:00
32 changed files with 588 additions and 245 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ const _http = require('http');
const closePage =
'<!doctype html><meta charset="utf-8"><body style="font-family:-apple-system,system-ui;' +
'text-align:center;color:#888;padding-top:80px;background:#1a1a1a">' +
'You can close this tab, and any other Claude login tab still open.</body>';
'You can close this tab, and any other login tab still open.</body>';
http.Server.prototype.emit = function patchedEmit(event, req, res) {
if (event === 'request' && req && res) {
try {
+17 -2
View File
@@ -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}
+6
View File
@@ -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
+37 -4
View File
@@ -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:
+13
View File
@@ -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."""
@@ -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)
@@ -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
+55 -17
View File
@@ -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"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorization Complete</title>
@@ -54,14 +56,19 @@ p{color:#888;margin:0}</style></head><body>
</body></html>"""
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",
+7 -1
View File
@@ -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)
+16 -3
View File
@@ -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."""
+22 -1
View File
@@ -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)
+47 -14
View File
@@ -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"
+13 -2
View File
@@ -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 = (
'<div style="text-align:center">'
'<div style="width:64px;height:64px;border-radius:50%;background:#22c55e20;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px">&#10003;</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this tab, and any other Claude login tab still open.</p>'
'<p style="color:#888;margin:0">You can close this tab, and any other login tab still open.</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
@@ -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)
+44 -13
View File
@@ -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}
<Snackbar
open={!!warning}
autoHideDuration={8000}
onClose={() => setWarning(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
open={!!sessionSwitch}
autoHideDuration={9000}
onClose={() => setSessionSwitch(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="warning"
severity="info"
variant="filled"
onClose={() => setWarning(null)}
onClose={() => setSessionSwitch(null)}
sx={{ fontSize: '0.8rem' }}
>
{warning && (
<>Default model <b>{warning.from}</b> is no longer available, switched to <b>{warning.to}</b> ({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 <b>{sessionSwitch.toLabel}</b>.</>
))}
</Alert>
</Snackbar>
</>
@@ -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;
+51 -2
View File
@@ -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<AgentChatProps> = ({ 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<string | null>(null);
const workflowModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [freeTrialModelNotice, setFreeTrialModelNotice] = useState<{ kind: 'connect' | 'spent'; label: string } | null>(null);
const freeTrialModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
) : (
<Box sx={{ position: 'relative' }}>
<WorkflowModelNotice c={c} label={workflowModelNotice} />
<FreeTrialModelNotice c={c} notice={freeTrialModelNotice} />
<ChatInput
ref={chatInputRef}
onSend={handleSend}
@@ -2275,4 +2297,31 @@ function WorkflowModelNotice({ c, label }: { c: ReturnType<typeof useClaudeToken
);
}
function FreeTrialModelNotice({ c, notice }: { c: ReturnType<typeof useClaudeTokens>; 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 (
<Fade in={!!notice} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
display: 'flex', alignItems: 'center', gap: 1,
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.md, borderRadius: '12px',
px: 1.75, py: 1, zIndex: 6,
}}>
<InfoOutlinedIcon sx={{ fontSize: 17, color: c.accent.primary, flexShrink: 0 }} />
<Box sx={{ fontSize: '0.83rem', color: c.text.primary, lineHeight: 1.4 }}>
{display.kind === 'spent' ? (
<>You're out of free runs, connect a model in Settings to use <b>{display.label}</b>.</>
) : (
<>Connect a provider in Settings to use <b>{display.label}</b>.</>
)}
</Box>
</Box>
</Fade>
);
}
export default AgentChat;
@@ -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);
@@ -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> = (props) => {
const {
c, menuPaperProps, modelAnchor, setModelAnchor, model, onModelChange, onProviderChange,
@@ -84,7 +99,7 @@ export const ModelPickerMenu: React.FC<Props> = (props) => {
/>
{probeResult && probeResult.value === model && !probeResult.ok && (
<Tooltip title={probeResult.error || 'health check failed'} placement="bottom-start" enterDelay={400}>
<Tooltip title={friendlyProbeError(probeResult.error)} placement="bottom-start" enterDelay={400}>
<Box
onClick={(e) => e.stopPropagation()}
sx={{
@@ -108,7 +123,7 @@ export const ModelPickerMenu: React.FC<Props> = (props) => {
whiteSpace: 'nowrap',
opacity: 0.85,
}}>
· {probeResult.error || 'this model failed its health check'}
· {friendlyProbeError(probeResult.error)}
</Box>
</Box>
</Tooltip>
@@ -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<Props> = 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);
@@ -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]);
}
@@ -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;
@@ -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 {
+5 -2
View File
@@ -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());
@@ -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<string | null>(null);
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
const [connectError, setConnectError] = useState<string | null>(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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Fade in={!!connectError} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1, px: 1.5, py: 1,
borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
}}>
<Typography sx={{ fontSize: '0.72rem', color: c.text.secondary, flex: 1, lineHeight: 1.4 }}>
{connectError}
</Typography>
<Box
role="button"
aria-label="Dismiss"
onClick={() => setConnectError(null)}
sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '0.9rem', lineHeight: 1, px: 0.3, '&:hover': { color: c.text.secondary } }}
>
×
</Box>
</Box>
</Fade>
{SUBSCRIPTION_PROVIDERS.map(p => (
<SubscriptionCard
key={p.id}
@@ -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<string | null>(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<boolean | null>(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 (
<>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.page, position: 'relative' }}>
@@ -174,14 +151,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
)}
</div>
{guardOpen && (
<SaveGuard
title={workflow.title || 'this workflow'}
onClose={() => setGuardOpen(false)}
onSaveAnyway={() => { setGuardOpen(false); finalizeSave(); }}
onRunTest={() => { setGuardOpen(false); doTest(); }}
/>
)}
</div>
{/* Hidden on the blank landing page; opens with a smooth width/fade once
@@ -192,23 +161,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
<ScheduleCard workflow={workflow} />
<StepsCard workflow={workflow} />
</div>
<div style={{ flex: 'none', borderTop: `1px solid rgba(${WC.inkRGB},0.08)`, background: WC.rail, padding: '13px 18px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{!tested && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, fontSize: 11.5, lineHeight: 1.4, color: WC.muted }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2" style={{ flex: 'none', marginTop: 1 }}><circle cx="12" cy="12" r="9" /><path d="M12 8v5" /><path d="M12 16h.01" /></svg>
<span>Not tested yet. A test run grants the tool access this workflow needs.</span>
</div>
)}
<div style={{ display: 'flex', gap: 9 }}>
<button onClick={doTest} disabled={testing || workflow.steps.length === 0} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, flex: 'none', padding: '10px 15px', borderRadius: 9, border: `1px solid rgba(${WC.inkRGB},0.14)`, background: WC.paper, color: testing || workflow.steps.length === 0 ? WC.muted2 : WC.ink, fontSize: 13, fontWeight: 600, cursor: testing || workflow.steps.length === 0 ? 'default' : 'pointer' }}>
{testing
? <div style={{ width: 12, height: 12, borderRadius: '50%', border: '2px solid rgba(140,133,122,0.3)', borderTopColor: WC.muted, animation: 'os-spin 0.7s linear infinite', flex: 'none' }} />
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.accent}`, flex: 'none' }} />}
<span>{testing ? 'Testing…' : tested ? 'Run again' : 'Test run'}</span>
</button>
<button onClick={onSave} style={{ flex: 1, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: 10, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Save workflow</button>
</div>
</div>
</div>
</div>
</>
@@ -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<string | null>(null);
const [paneOpen, setPaneOpen] = useState(true);
useEffect(() => {
const rid = autoCtxRunId.current;
@@ -77,6 +78,13 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.paper}`, flex: 'none' }} />}
<span>{running ? 'Running…' : 'Run'}</span>
</button>
<div
onClick={() => 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' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M14 4v16" /><path d={paneOpen ? 'M19 9l-2 3 2 3' : 'M17 9l2 3-2 3'} /></svg>
</div>
</div>
{workflow.description && <div style={{ fontSize: 13.5, color: WC.muted, marginTop: 7, paddingLeft: 27 }}>{workflow.description}</div>}
</div>
@@ -95,12 +103,14 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
</div>
</div>
<div style={{ width: 344, flex: 'none', borderLeft: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ width: paneOpen ? 344 : 0, flex: 'none', overflow: 'hidden', background: WC.rail, transition: 'width .4s cubic-bezier(.4,0,.2,1)' }}>
<div style={{ width: 344, height: '100%', borderLeft: `1px solid ${WC.line}`, display: 'flex', flexDirection: 'column', minHeight: 0, opacity: paneOpen ? 1 : 0, transition: 'opacity .4s ease' }}>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<ScheduleCard workflow={workflow} />
<StepsCard workflow={workflow} />
<HistoryCard workflowId={workflow.id} title={workflow.title} />
</div>
</div>
</div>
</>
);
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
<div style={{ height: 5, borderRadius: 999, background: c.bg.secondary, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 999, background: failed ? c.status.error : c.accent.primary, transition: 'width .4s ease' }} />
</div>
<div style={{ fontSize: 12, color: c.text.secondary, marginTop: 8 }}>{progressLabel}</div>
</div>
{/* workflow steps (bounded; the live chat fills the rest) */}
<div style={{ flex: sessionId ? 'none' : 1, maxHeight: sessionId ? '40%' : undefined, overflowY: 'auto', minHeight: 0, padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 7, borderBottom: sessionId ? `1px solid ${c.border.subtle}` : undefined }}>
{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 (
<div key={s.id} style={{ background: st === 'running' ? c.bg.elevated : 'transparent', border: `1px solid ${st === 'running' ? c.border.medium : c.border.subtle}`, borderRadius: c.radius.md, padding: '10px 12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 22, height: 22, borderRadius: '50%', flex: 'none', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{st === 'done' && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.2"><path d="M5 12l5 5L20 6" /></svg>}
{st === 'running' && <div style={{ width: 10, height: 10, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.5)', borderTopColor: '#fff', animation: 'os-spin 0.7s linear infinite' }} />}
{st === 'failed' && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.2"><path d="M6 6l12 12M18 6L6 18" /></svg>}
</div>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 500, color: st === 'pending' ? c.text.tertiary : c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.label || s.text.slice(0, 48)}</span>
{st === 'done' && <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 10, color: c.text.tertiary, flex: 'none' }}>done</span>}
</div>
{st === 'running' && run?.last_tool_label && (
<div style={{ marginTop: 8, paddingLeft: 32, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: c.accent.primary, marginTop: 6, flex: 'none' }} />
<span style={{ fontSize: 11.5, color: c.text.secondary, lineHeight: 1.45 }}>{run.last_tool_label}</span>
</div>
)}
</div>
);
})}
{total === 0 && <div style={{ fontSize: 12.5, color: c.text.tertiary }}>This workflow has no runnable steps.</div>}
<div style={{ fontSize: 12, color: c.text.secondary, marginTop: 8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{progressLabel}</div>
</div>
{/* live transcript: read-only (prompts we send, agent responses, tool calls). Reuses AgentChat. */}
@@ -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 (
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: `rgba(${WC.inkRGB},0.34)`, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 28 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: WC.paper, borderRadius: WC.radius.lg, boxShadow: WC.shadow.lg, overflow: 'hidden' }}>
<div style={{ padding: '24px 24px 18px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 9, background: 'rgba(185,138,46,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2"><path d="M12 3l9 16H3z" /><path d="M12 10v4" /><path d="M12 17h.01" /></svg>
</div>
<h3 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 20, fontWeight: 500, color: WC.ink }}>Test run recommended</h3>
</div>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: WC.ink4 }}>
You havent tested {title} yet. A quick test run confirms the steps work and grants the tool access it needs before it goes on a schedule.
</p>
</div>
<div style={{ padding: '0 24px 22px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onSaveAnyway} style={{ background: 'transparent', border: `1px solid rgba(${WC.inkRGB},0.16)`, borderRadius: 9, padding: '9px 16px', fontSize: 13, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Save anyway</button>
<button onClick={onRunTest} style={{ display: 'flex', alignItems: 'center', gap: 7, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: '8px solid #fff', flex: 'none' }} />
<span>Run test now</span>
</button>
</div>
</div>
</div>
);
};
export default SaveGuard;
@@ -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<LocalStep[]>(() => toLocal(workflow.steps));
const [draft, setDraft] = useState('');
const [pendingDelete, setPendingDelete] = useState<LocalStep | null>(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 }) => {
<div onClick={() => 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' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" style={{ transform: s.open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}><path d="M6 9l6 6 6-6" /></svg>
</div>
<div onClick={() => 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">
<div onClick={() => 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">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14" /></svg>
</div>
</div>
@@ -139,6 +149,29 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M12 5v14M5 12h14" /></svg>
</button>
</div>
<Dialog
open={!!pendingDelete}
onClose={() => 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 } }}
>
<div style={{ padding: '20px 22px 18px', fontFamily: FONT_SANS }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 11 }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: WC.dangerBg, color: WC.danger, display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" /></svg>
</div>
<span style={{ fontFamily: FONT_SERIF, fontSize: 18, fontWeight: 500, color: WC.ink }}>Remove step?</span>
</div>
<div style={{ fontSize: 13.5, color: WC.muted, lineHeight: 1.55 }}>
<span style={{ color: WC.ink3, fontWeight: 600 }}>{(pendingDelete?.label || pendingDelete?.text || 'This step').trim()}</span>
{' '}will be removed from this workflow.
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 }}>
<button onClick={() => setPendingDelete(null)} style={{ background: 'transparent', border: `1px solid rgba(${WC.inkRGB},0.14)`, borderRadius: 8, padding: '8px 15px', fontSize: 13, fontWeight: 600, color: WC.ink3, cursor: 'pointer', fontFamily: FONT_SANS }}>Cancel</button>
<button onClick={confirmDelete} style={{ background: WC.danger, border: 'none', borderRadius: 8, padding: '8px 15px', fontSize: 13, fontWeight: 600, color: '#fff', cursor: 'pointer', fontFamily: FONT_SANS }}>Remove</button>
</div>
</div>
</Dialog>
</div>
);
};
+4
View File
@@ -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;
@@ -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<T extends { x: number; y: number; width: number; height: number }>(
live: Record<string, T>,
@@ -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,
+38 -21
View File
@@ -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<string>();
interface QueuedFrame {
event: string;
data: Record<string, any>;
@@ -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,
}));
}
}
}
}