mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] ux: app error agent loop
This commit is contained in:
@@ -79,6 +79,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).
|
||||
|
||||
@@ -912,26 +917,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:
|
||||
@@ -1478,6 +1495,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
|
||||
@@ -1493,6 +1563,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: {
|
||||
|
||||
@@ -80,6 +80,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);
|
||||
@@ -408,7 +434,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
inputData={inputData}
|
||||
backendResult={backendResult}
|
||||
/>
|
||||
<BuildingOverlay sessionId={output.session_id ?? null} />
|
||||
<BuildingOverlay show={showBuildingOverlay} />
|
||||
</Box>
|
||||
|
||||
{/* Resize handles */}
|
||||
@@ -484,15 +510,13 @@ 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. Hides whatever transient half-broken state the agent
|
||||
// might be writing through (a missing import, a syntax error mid-keystroke)
|
||||
// so the user sees "Building..." instead of an error iframe. Fades in/out.
|
||||
const BuildingOverlay: React.FC<{ sessionId: string | null }> = ({ sessionId }) => {
|
||||
// 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();
|
||||
const status = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId]?.status : undefined));
|
||||
const isBuilding = status === 'running' || status === 'waiting_approval';
|
||||
return (
|
||||
<Fade in={isBuilding} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Fade in={show} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -549,6 +573,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);
|
||||
@@ -634,6 +685,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
frontendCode={output.files?.['index.html'] ?? ''}
|
||||
inputData={inputData}
|
||||
backendResult={backendResult}
|
||||
onConsoleMessage={handleConsoleMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface ViewCardPosition {
|
||||
width: number;
|
||||
height: number;
|
||||
zOrder: number;
|
||||
/** Chat session that spawned this view card; drives column-snap on initial placement. */
|
||||
parent_session_id?: string | null;
|
||||
}
|
||||
|
||||
@@ -534,11 +533,6 @@ const dashboardLayoutSlice = createSlice({
|
||||
} else {
|
||||
const parentCard = parentSessionId ? state.cards[parentSessionId] : null;
|
||||
if (parentCard) {
|
||||
// Mirror the agent-spawned browser flow (WebSocketManager.ts): drop the
|
||||
// new card into a column to the right of its parent chat, stacking
|
||||
// under any siblings (browser OR view) already in that column so a
|
||||
// single chat's outputs read as one cluster instead of scattering
|
||||
// across the canvas via the global grid scan.
|
||||
const targetX = parentCard.x + parentCard.width + GRID_GAP * 12;
|
||||
let targetY = parentCard.y;
|
||||
const siblings = [
|
||||
|
||||
Reference in New Issue
Block a user