diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 09249120..6bce0431 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -717,6 +717,46 @@ class AgentManager: os.makedirs(effective_cwd, exist_ok=True) + # Canvas-chat App Builder launch: when the user picks "App Builder" + # mode from the chat-input dropdown (no preexisting workspace, no + # target_directory passed in), the legacy code path only created an + # empty folder, so the agent could write files but the app never + # showed up in the Apps sidebar (no Output row, which is what the + # sidebar reads). Mirror the /workspace/seed endpoint's behavior + # here: seed the React template + register an Output row with + # workspace_id = session_id. Idempotent; safe if the session is + # ever re-launched with the same id. + if config.mode == "view-builder" and not config.target_directory: + try: + from backend.apps.outputs.outputs import ( + ensure_webapp_workspace_seeded_and_registered, + _load, + ) + output_id = ensure_webapp_workspace_seeded_and_registered( + workspace_id=session_id, + folder=effective_cwd, + session_id=session_id, + ) + if output_id: + # Broadcast the new row so the Apps sidebar lights up + # immediately, even before the user clicks into it. The + # row name is still the placeholder ("Untitled App") at + # this point; the post-session meta-sync below fires a + # second upsert with the real name once the agent has + # written meta.json. + try: + new_output = _load(output_id) + await ws_manager.broadcast_global("agent:output_upserted", { + "output": new_output.model_dump(mode="json"), + }) + except Exception: + logger.exception("post-seed output_upserted broadcast failed") + except Exception: + logger.exception( + "view-builder workspace seed/register failed; session will " + "still launch but the app may not appear in Apps sidebar" + ) + # If the fallback chain landed on the user's home directory (no # project dir, no default_folder set), re-route to a dedicated # scratch workspace under ~/.openswarm/workspaces/. @@ -3599,6 +3639,31 @@ class AgentManager: }) finally: if session_id in self.sessions: + # For canvas-launched App Builder sessions, the workspace + # folder IS the session_id (see launch_agent), so meta.json + # lives at outputs_workspace//meta.json. Read it + # and propagate name/description into the Output row before + # the terminal status fires; without this, the row stays + # "Untitled App" forever because no React component polls + # the file on the canvas path. Best-effort, only acts when + # the row's name is still the default placeholder. + if session.mode == "view-builder": + try: + from backend.apps.outputs.outputs import sync_output_from_meta_json, _load_all + if sync_output_from_meta_json(session_id): + # Broadcast the renamed row so the sidebar + # flips from "Untitled App" to the real name + # without waiting for the next mount. + try: + matching = [o for o in _load_all() if o.workspace_id == session_id] + if matching: + await ws_manager.broadcast_global("agent:output_upserted", { + "output": matching[0].model_dump(mode="json"), + }) + except Exception: + logger.exception("post-sync output_upserted broadcast failed") + except Exception: + logger.exception("post-session meta sync failed") await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, "status": session.status, diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index e223f9ef..68f4b91f 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -41,9 +41,23 @@ async def list_sessions(dashboard_id: str = ""): @agents.router.get("/sessions/{session_id}") async def get_session(session_id: str): + """Returns the session by id. + + Falls back to a disk load when the session isn't in the in-memory + dict. Without this, any surface that queries a session before the + dashboard has restored it (Apps editor opened cold, deep link to a + chat, a workflow step inspecting an old session) hits a 404 even + though the JSON file is sitting on disk. The disk-load path is + O(1) memory hit after the first call: resume_session moves the + session into agent_manager.sessions and the next GET short-circuits + on the in-memory check. + """ session = agent_manager.get_session(session_id) if not session: - raise HTTPException(status_code=404, detail="Session not found") + try: + session = await agent_manager.resume_session(session_id) + except ValueError: + raise HTTPException(status_code=404, detail="Session not found") return session.model_dump(mode="json") @agents.router.post("/launch") diff --git a/backend/apps/agents/ws_manager.py b/backend/apps/agents/ws_manager.py index 02f12047..86ac28c4 100644 --- a/backend/apps/agents/ws_manager.py +++ b/backend/apps/agents/ws_manager.py @@ -132,6 +132,22 @@ class ConnectionManager: } if events: + # Drop already-resolved approval requests from the replay. The + # ring buffer holds every event we ever stamped, including the + # original `agent:approval_request`. Without this filter, a + # client that reconnects (e.g. after navigating away and back, + # which re-mounts AgentChat with last_seq=0) re-fires every + # past approval as if it were live, but the backing future was + # popped from pending_futures the moment the user answered, so + # the resurrected card is a dead no-op. Lifecycle is simple: + # send_approval_request() inserts into pending_futures BEFORE + # the event is stamped, and resolve_approval()/timeout/cancel + # all pop it; so "in pending_futures" is the authoritative + # is-still-live signal for the request_id. A process restart + # wipes pending_futures, which is correct because + # reconcile_on_startup also marks waiting_approval sessions as + # stopped so there's nothing to answer anyway. + events = self._filter_stale_approvals(events) for s in events: try: await websocket.send_text(s) @@ -161,6 +177,28 @@ class ConnectionManager: "current_seq": newest if newest is not None else 0, } + def _filter_stale_approvals(self, events: list[str]) -> list[str]: + """Return events minus any `agent:approval_request` whose request_id + is no longer in pending_futures. JSON parse is per-event but replay + only runs on (re)connect, so it isn't a hot path. + """ + alive = self.pending_futures + out: list[str] = [] + for payload_str in events: + try: + parsed = json.loads(payload_str) + except (ValueError, TypeError): + out.append(payload_str) + continue + if parsed.get("event") != "agent:approval_request": + out.append(payload_str) + continue + data = parsed.get("data") or {} + request_id = data.get("request_id") + if request_id and request_id in alive: + out.append(payload_str) + return out + async def broadcast_global(self, event: str, data: dict): """Send a message to all global (dashboard) connections. diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 76417ec7..4fb41639 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -384,6 +384,118 @@ async def read_workspace(workspace_id: str): return {"files": files, "meta": meta, "path": os.path.abspath(folder)} +def sync_output_from_meta_json(workspace_id: str) -> bool: + """Read meta.json from the workspace folder; if it has a non-empty + name or description that differs from the linked Output row, update + the row. Returns True if anything changed. + + Idempotent and best-effort: missing workspace, missing meta.json, + malformed JSON, or no linked Output all return False silently. + + Why this exists: the Apps editor's React component polls meta.json + every few seconds and propagates name/description into the Output + via autosave. The canvas-chat App Builder launch has no such + poller, so apps stayed named "Untitled App" forever even after + the agent wrote a real name into meta.json. Calling this from the + session-complete hook closes that gap on the one event we know + fires exactly once per session. + """ + try: + folder = os.path.join(WORKSPACE_DIR, workspace_id) + meta_path = os.path.join(folder, "meta.json") + if not os.path.exists(meta_path): + return False + with open(meta_path) as f: + meta = json.load(f) + if not isinstance(meta, dict): + return False + name = str(meta.get("name") or "").strip() + description = str(meta.get("description") or "").strip() + if not name and not description: + return False + matching = [o for o in _load_all() if o.workspace_id == workspace_id] + if not matching: + return False + output = matching[0] + changed = False + # Only overwrite the default placeholder ("Untitled App" / "") so a + # user who explicitly renamed the app in the UI isn't clobbered by + # a stale meta.json from a prior agent turn. + if name and output.name in ("", "Untitled App") and output.name != name: + output.name = name + changed = True + if description and not output.description and output.description != description: + output.description = description + changed = True + if changed: + output.updated_at = datetime.now().isoformat() + _save(output) + return changed + except (OSError, json.JSONDecodeError, ValueError): + return False + except Exception: + logger.exception("sync_output_from_meta_json failed for %s", workspace_id) + return False + + +def ensure_webapp_workspace_seeded_and_registered( + workspace_id: str, + folder: str, + session_id: Optional[str] = None, +) -> Optional[str]: + """Idempotently seed the webapp template into `folder` and register an + Output row pointing at `workspace_id`. Used by the canvas-chat launch + path so picking "App Builder" from the mode dropdown produces the same + sidebar visibility as the Apps editor's `/workspace/seed` flow. + + When `session_id` is supplied, it is persisted on the Output row so the + Apps editor can reattach to the same chat history later (without this + link, double-clicking the app card opens an empty editor instead of + the conversation the user already had with the agent). + + Idempotency: + - If `run.sh` already exists in the folder, skip the template copy + (matches the seed_workspace endpoint's idempotency guard). + - If any Output already points at this workspace_id, reuse it but + still attach session_id if it's missing. + Returns the output_id on success, None on failure (best-effort; the + caller's session still launches even if registration fails). + """ + try: + os.makedirs(folder, exist_ok=True) + already_seeded = os.path.exists(os.path.join(folder, "run.sh")) + if not already_seeded: + from backend.apps.outputs.runtime import _find_free_port + frontend_port = _find_free_port() + seed_webapp_template_workspace(folder, frontend_port) + with open(os.path.join(folder, "SKILL.md"), "w") as f: + f.write(load_app_builder_skill()) + existing = [o for o in _load_all() if o.workspace_id == workspace_id] + if existing: + output = existing[0] + if session_id and output.session_id != session_id: + output.session_id = session_id + output.updated_at = datetime.now().isoformat() + _save(output) + return output.id + now = datetime.now().isoformat() + output = Output( + name="Untitled App", + description="", + icon="view_quilt", + files={}, + workspace_id=workspace_id, + session_id=session_id, + created_at=now, + updated_at=now, + ) + _save(output) + return output.id + except Exception: + logger.exception("ensure_webapp_workspace_seeded_and_registered failed for %s", workspace_id) + return None + + @outputs.router.post("/workspace/seed") async def seed_workspace(body: WorkspaceSeedRequest): """Create a workspace folder and pre-seed it. diff --git a/frontend/src/app/components/TrustedFilePatterns.tsx b/frontend/src/app/components/TrustedFilePatterns.tsx index db7cce82..8c532844 100644 --- a/frontend/src/app/components/TrustedFilePatterns.tsx +++ b/frontend/src/app/components/TrustedFilePatterns.tsx @@ -75,7 +75,10 @@ export const TrustedFilePatterns: React.FC = () => { } }, [patterns, load]); - if (patterns === null) return null; + // Hide the whole section until the user actually has trusted patterns; + // an empty "no patterns yet" card was just visual bloat for the 99% case. + // The approval-time checkbox is what teaches the user this feature exists. + if (!patterns || patterns.length === 0) return null; return ( @@ -85,45 +88,39 @@ export const TrustedFilePatterns: React.FC = () => { Files like SSH keys and shell startup files normally ask before each write, even when you've set Write to "always allow". Patterns you've chosen to always allow appear below. Remove one to start asking again. - {patterns.length === 0 ? ( - - No trusted patterns yet. You'll see a checkbox the first time the agent tries to write a sensitive file. - - ) : ( - - {patterns.map((pat, idx) => ( - - - - {PATTERN_LABELS[pat] || pat} - - - {pat} - - - revoke(pat)} - aria-label={`Remove ${PATTERN_LABELS[pat] || pat}`} - sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }} - > - - + + {patterns.map((pat, idx) => ( + + + + {PATTERN_LABELS[pat] || pat} + + + {pat} + - ))} - - )} + revoke(pat)} + aria-label={`Remove ${PATTERN_LABELS[pat] || pat}`} + sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }} + > + + + + ))} + ); }; diff --git a/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx index fc49e746..e0e08b41 100644 --- a/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx @@ -3,11 +3,9 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; -import Chip from '@mui/material/Chip'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import TerminalIcon from '@mui/icons-material/Terminal'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { sanitizeSvgString } from '@/shared/sanitizeSvg'; @@ -153,40 +151,20 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = {deniedCount} denied )} - {/* Fixed-width fraction + count chip so the header row stops - reflowing as the count climbs from 9 → 10 → 11 → 12 during - parallel tool execution. Without min-widths, every digit- - boundary nudges the header text wider, which shifts the - chevron, which shifts the entire transcript below. The - tabular-nums + minWidth pair locks both the fraction and - the chip to a stable size for any 1-3 digit count. */} + {/* Single fraction renders progress AND total; the green color + alone signals completion, and the fraction's denominator + makes the separate ×N chip redundant. tabular-nums + + minWidth keep the position stable as digits change. */} {allDone && completedCount > 0 && ( - - - - {completedCount}/{group.callCount} - - + + {completedCount}/{group.callCount} + )} {!allDone && pendingCount > 0 && ( {completedCount}/{group.callCount} )} - {expanded ? : } diff --git a/frontend/src/shared/state/outputsSlice.ts b/frontend/src/shared/state/outputsSlice.ts index 8901416f..669e7a2b 100644 --- a/frontend/src/shared/state/outputsSlice.ts +++ b/frontend/src/shared/state/outputsSlice.ts @@ -136,7 +136,18 @@ export const executeOutput = createAsyncThunk( const outputsSlice = createSlice({ name: 'outputs', initialState, - reducers: {}, + reducers: { + /** Upsert an Output row from a server-pushed WS event (canvas-launched + * App Builder seeds the row on launch; meta-sync renames it at session + * end). Merges over existing fields so a row that already has agent- + * generated content doesn't lose anything from a partial server push. + */ + upsertOutput(state, action: { payload: Output; type: string }) { + const incoming = action.payload; + const existing = state.items[incoming.id]; + state.items[incoming.id] = existing ? { ...existing, ...incoming } : incoming; + }, + }, extraReducers: (builder) => { builder .addCase(fetchOutputs.pending, (state) => { state.loading = true; }) @@ -153,4 +164,5 @@ const outputsSlice = createSlice({ }, }); +export const { upsertOutput } = outputsSlice.actions; export default outputsSlice.reducer; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 24240aa6..d0c29796 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -24,6 +24,7 @@ import { } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice'; import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { upsertOutput } from '../state/outputsSlice'; import { getAuthToken } from '../config'; import { notifyAgentCompletion } from '../notifications'; @@ -488,6 +489,16 @@ class WebSocketManager { } break; + case 'agent:output_upserted': + // Emitted by the backend when an Output row is created (canvas-launched + // App Builder seed) or updated (post-session meta.json sync). The + // upsert reducer merges over an existing row so a UI that already + // loaded the row doesn't lose locally-applied fields. + if (data.output && data.output.id) { + store.dispatch(upsertOutput(data.output)); + } + break; + case 'agent:stream_start': case 'agent:stream_delta': case 'agent:stream_end':