mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-03 08:18:43 +02:00
[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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -35,8 +35,34 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
// Clean first mount: nothing caught, so the app is in a good render
|
||||
// state and the ready beacon is allowed.
|
||||
if (!this.state.error) {
|
||||
window.__openswarm_render_failed = false;
|
||||
window.__openswarm_last_error = '';
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState): void {
|
||||
// Fast Refresh retried the previously-broken subtree and it rendered:
|
||||
// re-allow the ready beacon so index.tsx's vite:afterUpdate can report ok.
|
||||
if (prevState.error && !this.state.error) {
|
||||
window.__openswarm_render_failed = false;
|
||||
window.__openswarm_last_error = '';
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({ errorInfo });
|
||||
// Suppress the post-mount "ready" beacon in index.tsx and re-arm the
|
||||
// window-error gate: the app is not in a rendered state right now.
|
||||
// Stash the message so the HMR handler can re-assert this error after
|
||||
// an unrelated edit (which resets the host's render-state to None).
|
||||
window.__openswarm_render_failed = true;
|
||||
window.__openswarm_rendered = false;
|
||||
window.__openswarm_last_error =
|
||||
`${error?.message ?? String(error)}\n${errorInfo?.componentStack ?? ''}`.trim();
|
||||
// Two channels so the OpenSwarm host's webview-preload bridge can
|
||||
// pick this up regardless of which one it taps:
|
||||
// 1. console.error — forwarded as a `[FRONTEND]` line into the
|
||||
|
||||
@@ -3,21 +3,67 @@ import { createRoot } from 'react-dom/client';
|
||||
import Main from './app/Main';
|
||||
import ErrorBoundary from './app/components/ErrorBoundary';
|
||||
|
||||
console.log('[App] Bootstrapping React app');
|
||||
// Render-health beacons the OpenSwarm host reads (via the forwarded preview
|
||||
// console) to decide, at the end of an agent turn, whether the app renders.
|
||||
// The ErrorBoundary covers React render crashes; the listeners here cover
|
||||
// what never reaches a boundary: module-load / pre-mount throws and vite
|
||||
// transform errors.
|
||||
function reportRender(ok: boolean, detail?: string) {
|
||||
if (ok) {
|
||||
window.__openswarm_rendered = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[openswarm:app-ready]');
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[openswarm:app-error]', detail ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Gate on __openswarm_rendered so a throw inside a click handler after a good
|
||||
// render (a bug, but not "the app won't render") doesn't block the turn.
|
||||
window.addEventListener('error', (e) => {
|
||||
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(
|
||||
<ErrorBoundary>
|
||||
<Main />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pages/client-react" />
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<Props> = ({
|
||||
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
|
||||
const [backendResult] = useState<Record<string, any> | 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<number | null>(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<Props> = ({
|
||||
interactive={interactive}
|
||||
onAppClicked={() => dispatch(setActiveViewCardId(output.id))}
|
||||
/>
|
||||
<BuildingOverlay show={showBuildingOverlay} />
|
||||
</Box>
|
||||
|
||||
{/* Resize handles */}
|
||||
@@ -502,6 +530,49 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
|
||||
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 (
|
||||
<Fade in={show} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 11,
|
||||
bgcolor: c.bg.surface,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.25,
|
||||
// Block pointer events to the iframe behind so user can't click into the half-built app.
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.accent.primary,
|
||||
animation: 'view-card-building-pulse 1.2s ease-in-out infinite',
|
||||
'@keyframes view-card-building-pulse': {
|
||||
'0%, 100%': { opacity: 0.35, transform: 'scale(0.85)' },
|
||||
'50%': { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', fontWeight: 500 }}>
|
||||
Building…
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
// 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<ViewPreviewHandle>;
|
||||
@@ -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<string, string> = { '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}
|
||||
/>
|
||||
|
||||
@@ -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<string, GlowingBrowserCard>;
|
||||
cards: Record<string, CardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
expandedSessionIds: string[];
|
||||
liveDragInfo: LiveDragInfo | null;
|
||||
measuredHeightsRef: RefObject<Record<string, number>>;
|
||||
@@ -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<string, ReturnType<typeof browserTether>>();
|
||||
const glowTethers = new Map<string, ReturnType<typeof cardTether>>();
|
||||
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<string, string[]>();
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const outputsSnapshottedRef = useRef<boolean>(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<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dashboardId || !layoutInitialized) return;
|
||||
|
||||
@@ -271,6 +271,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
glowingBrowserCards,
|
||||
cards,
|
||||
browserCards,
|
||||
viewCards,
|
||||
outputs,
|
||||
expandedSessionIds,
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user