From d2d0ce74aafaa9610f6c3d6907d7c7550d2a141e Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 16 Jun 2026 03:44:40 -0700 Subject: [PATCH] [aidan] ux/app-builder-dashboard: improve app builder dashboard (#89) * [aidan] ux: when agent creates app always opens in dashboard * [aidan] enhancement: app editing ui mimicing browseragents * [aidan] ui/ux: when building app window appears immediately * [aidan]: app view spacing dashboard mimicing browser * [aidan] ux: app error agent loop * [aidan] fix: remove duplicate logic --- backend/apps/agents/agent_manager.py | 99 ++++++++++++++++--- backend/apps/outputs/outputs.py | 27 +++++ backend/apps/outputs/runtime.py | 37 ++++++- .../src/app/components/ErrorBoundary.tsx | 26 +++++ .../webapp_template/frontend/src/index.tsx | 58 +++++++++-- .../frontend/src/vite-env.d.ts | 7 ++ .../webapp_template/frontend/vite.config.ts | 8 +- .../Dashboard/cards/DashboardViewCard.tsx | 99 +++++++++++++++++++ .../Dashboard/geometry/dashboardTethers.ts | 76 +++++++++++--- .../hooks/lifecycle/useDashboardLifecycle.ts | 43 ++++++++ .../hooks/state/useDashboardController.ts | 2 + .../src/shared/state/dashboardLayoutSlice.ts | 66 +++++++++++-- frontend/src/shared/ws/WebSocketManager.ts | 25 +++-- 13 files changed, 516 insertions(+), 57 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index ad665e7c..c5fe744e 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -81,6 +81,11 @@ logger = logging.getLogger(__name__) os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") +p_VIEW_BUILDER_RENDER_MAX_RETRIES = 2 +p_view_builder_render_retry_counts: dict[str, int] = {} +p_view_builder_dirty_sessions: set[str] = set() + + def _apply_context_window(session, settings=None) -> None: """Set session.context_window from the registry for its (provider, model). @@ -966,26 +971,38 @@ class AgentManager: except Exception: content = str(raw_response) - # When the agent writes/edits a file inside a live App - # Builder workspace, surface any build-server errors - # (vite/babel/tsc/uvicorn) that landed in the runtime's - # stderr in the moments after the write. Without this the - # agent walks away from broken JSX, the iframe shows a red - # overlay, and the user has to copy-paste the error back. - # ~400ms gives vite's file watcher + babel parse enough - # time to react; the post_tool_hook runs once per tool so - # the added latency is acceptable for the win. hook_tool_name_for_errors = input_data.get("tool_name", "") - if hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit"): - tool_in = input_data.get("tool_input") or {} - file_path = tool_in.get("file_path") or tool_in.get("path") or "" + wrote_files = hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit") + tool_in = input_data.get("tool_input") or {} + file_path = tool_in.get("file_path") or tool_in.get("path") or "" + wrote_frontend_file = wrote_files and "/frontend/" in file_path + installed_pkg = False + if hook_tool_name_for_errors == "Bash": + bash_in = input_data.get("tool_input") or {} + cmd = (bash_in.get("command") or "").lower() + installed_pkg = any(s in cmd for s in ( + "npm install", "npm i ", "npm uninstall", "npm ci", + "pnpm add", "pnpm install", "pnpm remove", + "yarn add", "yarn install", "yarn remove", + )) + + if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg): + p_view_builder_dirty_sessions.add(session.id) + try: + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + outputs_runtime_manager.reset_render_state_for_workspace(session.id) + except Exception: + pass + elif wrote_files: if file_path: try: await asyncio.sleep(0.4) from backend.apps.outputs.runtime import ( - manager as _outputs_runtime_manager, + manager as outputs_runtime_manager, ) - errs = _outputs_runtime_manager.drain_errors_for_path(file_path) + errs = outputs_runtime_manager.drain_errors_for_path(file_path) except Exception: errs = [] if errs: @@ -1532,6 +1549,59 @@ class AgentManager: if len(_stderr_buffer) > 500: del _stderr_buffer[:250] + async def stop_hook(input_data, tool_use_id, context): + """End-of-turn render gate for App Builder sessions. Reads the + browser-reported render-state of the preview; if the app fails + to render, blocks with the error so the agent fixes it, up to + MAX_RETRIES then lets the stop through.""" + if session.mode != "view-builder": + return {} + if session.id not in p_view_builder_dirty_sessions: + return {} + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + if outputs_runtime_manager.get(session.id) is None: + return {} + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + waited = 0.0 + while state is None and waited < 5.0: + await asyncio.sleep(0.25) + waited += 0.25 + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + + if state != "error": + p_view_builder_render_retry_counts.pop(session.id, None) + p_view_builder_dirty_sessions.discard(session.id) + return {} + + attempts = p_view_builder_render_retry_counts.get(session.id, 0) + if attempts >= p_VIEW_BUILDER_RENDER_MAX_RETRIES: + logger.warning( + "view-builder preview still failing after %s attempts for session %s; allowing stop", + attempts, session.id, + ) + p_view_builder_render_retry_counts.pop(session.id, None) + p_view_builder_dirty_sessions.discard(session.id) + return {} + + p_view_builder_render_retry_counts[session.id] = attempts + 1 + logger.info( + "view-builder render block (attempt %s/%s) for session %s", + attempts + 1, p_VIEW_BUILDER_RENDER_MAX_RETRIES, session.id, + ) + trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text + return { + "decision": "block", + "reason": ( + f"The preview failed to render (attempt {attempts + 1}/" + f"{p_VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n" + f"{trimmed}\n\n" + "Fix this so the app renders before finishing; the user " + "currently sees an error instead of the app." + ), + } + options_kwargs = { "model": resolved_model, # 64 MB ceiling on the SDK <-> CLI JSON-RPC channel. The @@ -1547,6 +1617,7 @@ class AgentManager: "hooks": { "PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])], "PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])], + "Stop": [HookMatcher(matcher=None, hooks=[stop_hook])], }, "allowed_tools": effective_allowed, "disallowed_tools": effective_disallowed, diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 27923c7e..3b2d241e 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -454,6 +454,33 @@ async def runtime_get_status(workspace_id: str): return _runtime_status_payload(workspace_id) +@outputs.router.post("/workspace/{workspace_id}/runtime/report-error") +async def runtime_report_error(workspace_id: str, body: dict): + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False, "recorded": 0} + message = (body.get("message") or "").strip() + component_stack = (body.get("componentStack") or "").strip() + if not message: + return {"ok": False, "recorded": 0} + composed = message + if component_stack: + composed = f"{composed}\n{component_stack}" + rt.set_render_error(composed) + return {"ok": True, "recorded": 1} + + +@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready") +async def runtime_report_ready(workspace_id: str): + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False} + rt.set_render_ok() + return {"ok": True} + + @outputs.router.post("/shutdown-all") async def runtime_shutdown_all(): """Reap every workspace subprocess. Electron POSTs this during diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 77f5fe64..b83b9abd 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -99,6 +99,8 @@ class AppRuntime: # vite/babel/uvicorn errors in its next turn and can self-fix # instead of leaving the user with a red iframe overlay. self.recent_errors: deque[str] = deque(maxlen=_RECENT_ERRORS_MAX) + self.render_state: Optional[str] = None + self.render_error_text: str = "" self._stdout_task: Optional[asyncio.Task] = None self._stderr_task: Optional[asyncio.Task] = None self._wait_task: Optional[asyncio.Task] = None @@ -113,6 +115,18 @@ class AppRuntime: self.recent_errors.clear() return out + def set_render_ok(self) -> None: + self.render_state = "ok" + self.render_error_text = "" + + def set_render_error(self, text: str) -> None: + self.render_state = "error" + self.render_error_text = (text or "").strip() + + def reset_render_state(self) -> None: + self.render_state = None + self.render_error_text = "" + @property def running(self) -> bool: return self.process is not None and self.process.returncode is None @@ -460,13 +474,16 @@ class AppRuntime: pass def _maybe_capture_error(self, text: str) -> None: - """If a stderr/stdout line matches a known build-error pattern, - record it for the next agent-tool drain. Tests every line , - cheap (single regex search) and only the matching ones land in - the buffer.""" if _ERROR_PATTERNS.search(text): self.recent_errors.append(text.rstrip()) + def p_maybe_capture_render_beacon(self, text: str) -> None: + if "[openswarm:app-ready]" in text: + self.set_render_ok() + elif "[openswarm:app-error]" in text: + idx = text.index("[openswarm:app-error]") + len("[openswarm:app-error]") + self.set_render_error(text[idx:].strip()) + async def _pipe_stream(self, stream: Optional[asyncio.StreamReader], name: str) -> None: if stream is None: return @@ -480,6 +497,7 @@ class AppRuntime: self._broadcast(LogLine(name, text)) if name == "stderr" or name == "stdout": self._maybe_capture_error(text) + self.p_maybe_capture_render_beacon(text) except Exception: logger.exception("log pipe error (%s) for %s", name, self.workspace_id) @@ -638,6 +656,17 @@ class AppRuntimeManager: return rt.drain_errors() return [] + def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]: + rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) + if rt is None: + return None, "" + return rt.render_state, rt.render_error_text + + def reset_render_state_for_workspace(self, workspace_id: str) -> None: + rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) + if rt is not None: + rt.reset_render_state() + async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]: rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) if rt is None: diff --git a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx index 6fdacd98..ced4433d 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx @@ -35,8 +35,34 @@ class ErrorBoundary extends React.Component { + if (!window.__openswarm_rendered) reportRender(false, e.message || String(e.error ?? e)); +}); +window.addEventListener('unhandledrejection', (e) => { + if (!window.__openswarm_rendered) reportRender(false, String(e.reason ?? e)); +}); + +if (import.meta.hot) { + const hot = import.meta.hot; + hot.on('vite:error', (payload) => { + const err = payload?.err; + reportRender(false, err?.message || err?.plugin || 'vite error'); + }); + // Re-assert the real state after every HMR update: if the ErrorBoundary is + // still showing its fallback, report the error again (an unrelated edit that + // didn't fix it must not flip the gate to "ready"); otherwise report ready. + hot.on('vite:afterUpdate', () => { + if (window.__openswarm_render_failed) { + reportRender(false, window.__openswarm_last_error || 'app still failing to render'); + } else { + reportRender(true); + } + }); +} + const rootEl = document.getElementById('root'); if (!rootEl) { - console.error('[App] FATAL: #root element not found in DOM'); + console.error('[openswarm:app-error]', '#root element not found in DOM'); } else { // Wrap Main in an ErrorBoundary so any runtime crash from agent // edits (missing imports, hook-rules violations, etc.) shows a // readable error card in the preview pane instead of unmounting - // to a blank screen. The boundary also forwards the error via - // console.error + postMessage so the agent sees it on its next - // turn. + // to a blank screen. The boundary forwards the error via + // console.error + postMessage so the agent sees it on its next turn. createRoot(rootEl).render(
, ); - console.log('[App] React root mounted'); + // Defer a frame so a synchronous render crash sets __openswarm_render_failed + // (via the boundary) before we'd wrongly report ready. + requestAnimationFrame(() => { + if (window.__openswarm_render_failed) return; + reportRender(true); + }); } diff --git a/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts b/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts index f07ec82e..97d2dc49 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts +++ b/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts @@ -1,2 +1,9 @@ /// /// + +// Render-health beacon flags the OpenSwarm App Builder host reads off the preview. +interface Window { + __openswarm_rendered?: boolean; + __openswarm_render_failed?: boolean; + __openswarm_last_error?: string; +} diff --git a/backend/apps/outputs/webapp_template/frontend/vite.config.ts b/backend/apps/outputs/webapp_template/frontend/vite.config.ts index ae0498af..086c468f 100644 --- a/backend/apps/outputs/webapp_template/frontend/vite.config.ts +++ b/backend/apps/outputs/webapp_template/frontend/vite.config.ts @@ -52,7 +52,13 @@ export default defineConfig(({ mode }) => { plugins: [ react(), Pages({ dirs: 'src/pages', extensions: ['tsx'] }), - terminal({ console: 'terminal', output: ['terminal', 'console'] }), + // vite-plugin-terminal provides a `virtual:terminal/console` module + // that only exists in dev; loading it during `vite build` errors + // out, so the End-of-turn build-verify gate would fail on every + // brand-new workspace. + ...(mode === 'development' + ? [terminal({ console: 'terminal', output: ['terminal', 'console'] })] + : []), ], resolve: { alias: { diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 6176dfe5..7abc7504 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; import Box from '@mui/material/Box'; +import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; @@ -96,6 +97,32 @@ const DashboardViewCard: React.FC = ({ const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); + // Reload the preview when the session finishes a turn: React holds the + // ErrorBoundary's snag page until a reload, so without this the user keeps + // seeing the old error even after the agent fixed it. The overlay lingers + // through the reload (finishing) so the stale page never flashes. + const linkedStatus = useAppSelector( + (s) => (output.session_id ? s.agents.sessions[output.session_id]?.status : undefined), + ); + const [finishing, setFinishing] = useState(false); + const wasBuildingRef = useRef(false); + const finishTimerRef = useRef(null); + useEffect(() => { + const building = linkedStatus === 'running' || linkedStatus === 'waiting_approval'; + if (wasBuildingRef.current && !building) { + previewRef.current?.reload(); + setFinishing(true); + if (finishTimerRef.current) clearTimeout(finishTimerRef.current); + finishTimerRef.current = window.setTimeout(() => setFinishing(false), 1200); + } + wasBuildingRef.current = building; + }, [linkedStatus]); + useEffect(() => () => { + if (finishTimerRef.current) clearTimeout(finishTimerRef.current); + }, []); + const showBuildingOverlay = linkedStatus === 'running' + || linkedStatus === 'waiting_approval' || finishing; + const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); const [isDragging, setIsDragging] = useState(false); @@ -428,6 +455,7 @@ const DashboardViewCard: React.FC = ({ interactive={interactive} onAppClicked={() => dispatch(setActiveViewCardId(output.id))} /> + {/* Resize handles */} @@ -502,6 +530,49 @@ const DashboardViewCard: React.FC = ({ export default React.memo(DashboardViewCard); +// Calm overlay shown while the App Builder chat that owns this output is +// actively editing it (and through the post-turn reload). Hides whatever +// transient half-broken state the agent might be writing through so the +// user sees "Building..." instead of an error iframe. Fades in/out. +const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => { + const c = useClaudeTokens(); + return ( + + + + + Building… + + + + ); +}; + // Old-mode outputs render the legacy serve URL; new-mode webapp_template outputs attach to a runtime and point the webview at Vite once frontend_url arrives. const DashboardOutputPreview: React.FC<{ previewRef: React.Ref; @@ -525,6 +596,33 @@ const DashboardOutputPreview: React.FC<{ isNewMode, }); + // Declared above every early-return below so React's hook order stays + // stable; moving it below would trigger "Rendered more hooks than during + // the previous render." + const handleConsoleMessage = useCallback((level: string, text: string) => { + if (!text || !workspaceId) return; + const tok = getAuthToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (tok) headers.Authorization = `Bearer ${tok}`; + if (text.includes('[openswarm:app-ready]')) { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready`, { + method: 'POST', headers, + }).catch(() => {}); + return; + } + if (level !== 'error' || !text.includes('[openswarm:app-error]')) return; + const idx = text.indexOf('[openswarm:app-error]'); + const tail = text.slice(idx + '[openswarm:app-error]'.length).trim(); + const firstNewline = tail.indexOf('\n'); + const message = firstNewline >= 0 ? tail.slice(0, firstNewline).trim() : tail; + const componentStack = firstNewline >= 0 ? tail.slice(firstNewline + 1).trim() : ''; + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error`, { + method: 'POST', + headers, + body: JSON.stringify({ message, componentStack }), + }).catch(() => {}); + }, [workspaceId]); + // An orphaned record (files deleted on disk) used to render the raw 404 JSON // inside the card, or spin on "Starting preview" forever; probe once instead. const [filesMissing, setFilesMissing] = useState(false); @@ -610,6 +708,7 @@ const DashboardOutputPreview: React.FC<{ frontendCode={output.files?.['index.html'] ?? ''} inputData={inputData} backendResult={backendResult} + onConsoleMessage={handleConsoleMessage} interactive={interactive} onAppClicked={onAppClicked} /> diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 80440641..f28850c1 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -1,7 +1,8 @@ import { useMemo, type RefObject } from 'react'; -import type { CardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice'; import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; import type { AgentSession } from '@/shared/state/agentsSlice'; +import type { Output } from '@/shared/state/outputsSlice'; const ELBOW_RADIUS = 16; @@ -60,6 +61,8 @@ interface UseTethersArgs { glowingBrowserCards: Record; cards: Record; browserCards: Record; + viewCards: Record; + outputs: Record; expandedSessionIds: string[]; liveDragInfo: LiveDragInfo | null; measuredHeightsRef: RefObject>; @@ -72,6 +75,8 @@ export function useTethers({ glowingBrowserCards, cards, browserCards, + viewCards, + outputs, expandedSessionIds, liveDragInfo, measuredHeightsRef, @@ -118,21 +123,25 @@ export function useTethers({ }; }).filter(Boolean) as Tether[]; - function browserTether( - browserId: string, + // One tether builder for both browser and view cards: the anchor-pairing + // and elbow/vertical path are identical; only the destination card map and + // the key prefix differ, so the resolved dst card is passed in. + function cardTether( + dst: { x: number; y: number; width: number; height: number } | undefined, + dstId: string, sourceId: string, - fading: boolean, + key: string, label: string, + fading: boolean, ): Tether | null { const src = cards[sourceId]; - const dst = browserCards[browserId]; if (!src || !dst) return null; 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 === browserId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } const srcMeasured = measuredHeightsRef.current![sourceId]; @@ -200,7 +209,7 @@ export function useTethers({ const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; return { - key: `browser-${browserId}`, + key, path: pathD, labelX, labelY, @@ -209,9 +218,16 @@ export function useTethers({ }; } - const glowTethers = new Map>(); + const glowTethers = new Map>(); for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) { - const t = browserTether(browserId, sourceId, fading, label || ''); + const t = cardTether( + browserCards[browserId], + browserId, + sourceId, + `browser-${browserId}`, + label || '', + fading, + ); if (t) glowTethers.set(browserId, t); } @@ -220,15 +236,51 @@ 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; - const t = browserTether(s.browser_id, s.parent_session_id, false, ''); + const t = cardTether( + browserCards[s.browser_id], + s.browser_id, + s.parent_session_id, + `browser-${s.browser_id}`, + '', + false, + ); if (t) glowTethers.set(s.browser_id, t); } const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[]; - return [...agentTethers, ...browserTethers]; + // Index outputs by their owning session so the per-session lookup below + // doesn't scan the whole outputs map for every view-builder chat. + const outputsBySession = new Map(); + for (const o of Object.values(outputs)) { + if (!o.session_id) continue; + const arr = outputsBySession.get(o.session_id); + if (arr) arr.push(o.id); else outputsBySession.set(o.session_id, [o.id]); + } + + const viewTethers: Tether[] = []; + for (const s of sessionList) { + if (s.mode !== 'view-builder') continue; + if (s.status !== 'running' && s.status !== 'waiting_approval') continue; + const outIds = outputsBySession.get(s.id); + if (!outIds) continue; + for (const outputId of outIds) { + if (!viewCards[outputId]) continue; + const t = cardTether( + viewCards[outputId], + outputId, + s.id, + `view-${outputId}`, + 'Editing', + false, + ); + if (t) viewTethers.push(t); + } + } + + return [...agentTethers, ...browserTethers, ...viewTethers]; // 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, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index b0b51f65..df6d4237 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -12,6 +12,7 @@ import { fetchLayout, reconcileSessions, addBrowserCard, + addViewCard, resetLayout, removeViewCard, clearPendingFocusBrowserId, @@ -253,6 +254,48 @@ export function useDashboardLifecycle({ } }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); + // On first load after outputs settle, snapshot every existing Output id as + // "already accounted for." Any output that ARRIVES later (typically the + // agent:output_upserted WS broadcast the backend fires the instant a + // view-builder session is seeded, at session start) whose session_id points + // at a view-builder chat on this dashboard gets a view card dropped on the + // canvas right away. Per-mount tracked so a manual close after auto-open + // stays closed. Prior approach keyed off a pending-set populated inside + // launchAndSendFirstMessage.then(): the WS upsert won the race and the + // effect saw an empty set, so the card didn't pop until the session-end + // meta-sync re-broadcast. + const autoOpenedOutputsRef = useRef>(new Set()); + const outputsSnapshottedRef = useRef(false); + useEffect(() => { + if (!layoutInitialized || !outputsLoaded) return; + if (!outputsSnapshottedRef.current) { + for (const oid of Object.keys(outputs)) autoOpenedOutputsRef.current.add(oid); + outputsSnapshottedRef.current = true; + return; + } + for (const output of Object.values(outputs)) { + if (autoOpenedOutputsRef.current.has(output.id)) continue; + const sid = output.session_id; + if (!sid) continue; + const sess = sessions[sid]; + if (!sess || sess.mode !== 'view-builder') continue; + if (sess.dashboard_id !== dashboardId) continue; + autoOpenedOutputsRef.current.add(output.id); + if (viewCards[output.id]) continue; + dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid })); + const outputId = output.id; + setTimeout(() => { + const vc = store.getState().dashboardLayout.viewCards[outputId]; + if (!vc) return; + const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }]; + const ac = store.getState().dashboardLayout.cards[sid]; + if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height }); + canvasActions.fitToCards(rects, 1.15, true); + handleHighlightCard(outputId); + }, 200); + } + }, [layoutInitialized, outputsLoaded, outputs, sessions, viewCards, dashboardId, expandedSessionIds, dispatch, canvasActions, handleHighlightCard]); + const namedOnFirstMessageRef = useRef(null); useEffect(() => { if (!dashboardId || !layoutInitialized) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 4184dede..fc425a19 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -271,6 +271,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { glowingBrowserCards, cards, browserCards, + viewCards, + outputs, expandedSessionIds, liveDragInfo, measuredHeightsRef, diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 3e36cd1b..6cce568c 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -38,6 +38,7 @@ export interface ViewCardPosition { width: number; height: number; zOrder: number; + parent_session_id?: string | null; } export interface BrowserTab { @@ -199,6 +200,11 @@ interface Rect { h: number; } +interface CardPlacementExclusion { + type: CardType; + id: string; +} + function rectsOverlap(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } @@ -206,20 +212,25 @@ function rectsOverlap(a: Rect, b: Rect): boolean { function collectOccupiedRects( state: DashboardLayoutState, expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, ): Rect[] { const expanded = new Set(expandedSessionIds); const rects: Rect[] = []; for (const c of Object.values(state.cards)) { + if (exclude?.type === 'agent' && exclude.id === c.session_id) continue; const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height; rects.push({ x: c.x, y: c.y, w: c.width, h }); } for (const c of Object.values(state.viewCards)) { + if (exclude?.type === 'view' && exclude.id === c.output_id) continue; rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } for (const c of Object.values(state.browserCards)) { + if (exclude?.type === 'browser' && exclude.id === c.browser_id) continue; rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } for (const n of Object.values(state.notes)) { + if (exclude?.type === 'note' && exclude.id === n.note_id) continue; rects.push({ x: n.x, y: n.y, w: n.width, h: n.height }); } return rects; @@ -312,6 +323,36 @@ export function findOpenSpotNear( return findOpenGridCell(occupiedRects, newW, newH); } +export function placeInParentColumn( + state: DashboardLayoutState, + parentSessionId: string | null | undefined, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { 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 columnCards = [ + ...Object.values(state.browserCards).filter( + (c) => !(exclude?.type === 'browser' && exclude.id === c.browser_id), + ), + ...Object.values(state.viewCards).filter( + (c) => !(exclude?.type === 'view' && exclude.id === c.output_id), + ), + ].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; + + return findOpenSpotNear(targetX, targetY, rects, newW, newH); +} + // 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 @@ -522,27 +563,38 @@ const dashboardLayoutSlice = createSlice({ addViewCard(state, action: PayloadAction<{ outputId: string; expandedSessionIds?: string[]; + parentSessionId?: string | null; x?: number; y?: number; width?: number; height?: number; }>) { - const { outputId, expandedSessionIds, x, y, width, height } = action.payload; + const { outputId, expandedSessionIds, parentSessionId, x, y, width, height } = action.payload; if (state.viewCards[outputId]) return; + const w = width || DEFAULT_VIEW_CARD_W; + const h = height || DEFAULT_VIEW_CARD_H; let posX: number, posY: number; if (x != null && y != null) { posX = x; posY = y; } else { - const rects = collectOccupiedRects(state, expandedSessionIds); - const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H); - posX = pos.x; - posY = pos.y; + const parentCard = parentSessionId ? state.cards[parentSessionId] : null; + if (parentCard) { + const pos = placeInParentColumn(state, parentSessionId, w, h, expandedSessionIds); + posX = pos.x; + posY = pos.y; + } else { + const rects = collectOccupiedRects(state, expandedSessionIds); + const pos = findOpenGridCell(rects, w, h); + posX = pos.x; + posY = pos.y; + } } state.viewCards[outputId] = { output_id: outputId, x: posX, y: posY, - width: width || DEFAULT_VIEW_CARD_W, - height: height || DEFAULT_VIEW_CARD_H, + width: w, + height: h, zOrder: state.nextZOrder++, + parent_session_id: parentSessionId || null, }; }, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 258f62e4..dd90844f 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; @@ -762,21 +762,20 @@ class WebSocketManager { const parentId = data.parent_session_id; if (parentId) { const layoutState = store.getState().dashboardLayout; - const parentCard = layoutState.cards[parentId]; - if (parentCard) { - const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; - let targetY = parentCard.y; - const columnCards = Object.values(layoutState.browserCards).filter( - (c) => Math.abs(c.x - targetX) < 50 && c.browser_id !== data.browser_card.browser_id, + 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 }, ); - if (columnCards.length > 0) { - const lowestBottom = Math.max(...columnCards.map((c) => c.y + c.height)); - targetY = lowestBottom + GRID_GAP; - } store.dispatch(setBrowserCardPosition({ browserId: data.browser_card.browser_id, - x: targetX, - y: targetY, + x: pos.x, + y: pos.y, })); store.dispatch(setGlowingBrowserCards({ browserIds: [data.browser_card.browser_id],