[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:
Aidan
2026-06-16 03:44:40 -07:00
committed by GitHub
parent 863fd4f9c5
commit d2d0ce74aa
13 changed files with 516 additions and 57 deletions
+85 -14
View File
@@ -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,
+27
View File
@@ -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
+33 -4
View File
@@ -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,
};
},
+12 -13
View File
@@ -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],