[eric] dashboard: fullscreen chat docks the real browser (slot in full view, posEl-aware tile applier w/ live camera tracking, dock re-measure on slot moves, no dupe embed)

This commit is contained in:
ciregenz
2026-07-28 15:55:40 -07:00
parent 8d98c8f24c
commit aeb2b3fb14
5 changed files with 77 additions and 17 deletions
@@ -2402,7 +2402,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
{/* Dock slot: a browser this agent spawned lives HERE by default (the real card overlays
this rect geometrically, so the webview never remounts). Pinned between transcript
and composer, never inside the scroller, so it can't be clipped by chat scroll. */}
{hasDockedBrowser && !fullscreenChat && (
{hasDockedBrowser && (
<Box
data-browser-slot={id}
sx={{
@@ -108,8 +108,10 @@ const InlineSurfaceEmbeds: React.FC<{ c: ClaudeTokens; sessionId: string; fullsc
.filter((s) => s.parent_session_id === sessionId && s.browser_id)
.map((s) => s.browser_id as string),
);
// A docked browser renders its REAL card inside the chat (even in full size view), so the
// snapshot embed would be a stale duplicate right next to the live thing.
return Object.values(browserCards).filter(
(bc) => bc.spawned_by === sessionId || childBrowserIds.has(bc.browser_id),
(bc) => !bc.docked_to && (bc.spawned_by === sessionId || childBrowserIds.has(bc.browser_id)),
);
}, [browserCards, sessions, sessionId]);
const linkedApps = useMemo(
@@ -247,6 +247,7 @@ const BrowserCard: React.FC<Props> = ({
const dockedTo = useAppSelector((state) => state.dashboardLayout.browserCards[browserId]?.docked_to ?? null);
const dockParentCard = useAppSelector((state) => (dockedTo ? state.dashboardLayout.cards[dockedTo] ?? null : null));
const dockParentExpanded = useAppSelector((state) => (dockedTo ? state.agents.expandedSessionIds.includes(dockedTo) : false));
const dockParentTiled = useAppSelector((state) => (dockedTo ? state.dashboardLayout.tiledCards[dockedTo] : undefined));
const [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const rootElRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -267,9 +268,18 @@ const BrowserCard: React.FC<Props> = ({
if (slot) ro.observe(slot);
if (slot?.parentElement) ro.observe(slot.parentElement);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
// A RO only fires on slot RESIZE; the chat tiling/untiling MOVES the slot without resizing the
// window, so re-measure on camera writes + settle timers or the docked card lags behind.
window.addEventListener('openswarm:canvas-pan-changed', measure);
const timers = [60, 250, 700].map((ms) => window.setTimeout(measure, ms));
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
window.removeEventListener('openswarm:canvas-pan-changed', measure);
timers.forEach((tm) => window.clearTimeout(tm));
};
// dockParentCard x/y/w/h are re-measure triggers: the slot's client rect moves with the chat card.
}, [dockedTo, dockParentExpanded, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]);
}, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]);
const dockParentZ = dockParentCard?.zOrder ?? 0;
const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]);
@@ -1032,7 +1042,7 @@ const BrowserCard: React.FC<Props> = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? dockParentZ + 1 : cardZOrder,
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
@@ -173,6 +173,7 @@ const DashboardViewCard: React.FC<Props> = ({
const dockedTo = useAppSelector((state) => state.dashboardLayout.viewCards[cardKey]?.docked_to ?? null);
const dockParentCard = useAppSelector((state) => (dockedTo ? state.dashboardLayout.cards[dockedTo] ?? null : null));
const dockParentExpanded = useAppSelector((state) => (dockedTo ? state.agents.expandedSessionIds.includes(dockedTo) : false));
const dockParentTiled = useAppSelector((state) => (dockedTo ? state.dashboardLayout.tiledCards[dockedTo] : undefined));
const [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const dockRootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -192,8 +193,17 @@ const DashboardViewCard: React.FC<Props> = ({
if (slot) ro.observe(slot);
if (slot?.parentElement) ro.observe(slot.parentElement);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [dockedTo, dockParentExpanded, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]);
// A RO only fires on slot RESIZE; the chat tiling/untiling MOVES the slot without resizing the
// window, so re-measure on camera writes + settle timers or the docked card lags behind.
window.addEventListener('openswarm:canvas-pan-changed', measure);
const timers = [60, 250, 700].map((ms) => window.setTimeout(measure, ms));
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
window.removeEventListener('openswarm:canvas-pan-changed', measure);
timers.forEach((tm) => window.clearTimeout(tm));
};
}, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]);
const dockParentZ = dockParentCard?.zOrder ?? 0;
// Keep the live preview mounted only when the user can actually see/use this app card. Always live
@@ -628,7 +638,7 @@ const DashboardViewCard: React.FC<Props> = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
@@ -108,27 +108,65 @@ export function useTiledStyle(
if (!zone || !getLive || !selectId) return undefined;
const el = document.querySelector(`[data-select-id="${CSS.escape(selectId)}"]`) as HTMLElement | null;
if (!el) return undefined;
const props = ['left', 'top', 'width', 'height', 'transform', 'transform-origin'];
// left/top belong on whichever element OWNS position: AgentCard splits into a position:absolute
// motion wrapper around a position:relative inner Box (writing left/top on the inner Box ADDS to
// the wrapper's, rendering the tile at exactly double the offset); Browser/View cards are one
// element. Size + counter-scale always live on the select-id element, mirroring the sx path.
const posEl = getComputedStyle(el).position === 'absolute' || getComputedStyle(el).position === 'fixed'
? el
: (el.parentElement as HTMLElement | null) ?? el;
const posProps = ['left', 'top'];
const sizeProps = ['width', 'height', 'transform', 'transform-origin'];
const clearAll = (): void => {
posProps.forEach((pr) => posEl.style.removeProperty(pr));
sizeProps.forEach((pr) => el.style.removeProperty(pr));
};
const apply = (): void => {
// A parked card (kept alive off-screen / minimized) must keep its sx parking position.
if (el.getAttribute('data-keepalive-hidden') === '1') { props.forEach((pr) => el.style.removeProperty(pr)); return; }
if (el.getAttribute('data-keepalive-hidden') === '1') { clearAll(); return; }
const cam = getLive();
const s = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom);
if (!s) return;
el.style.left = `${s.left}px`;
el.style.top = `${s.top}px`;
el.style.width = `${s.width}px`;
el.style.height = `${s.height}px`;
el.style.transform = s.transform;
el.style.transformOrigin = s.transformOrigin;
// 'important' so framer-motion's own left/top writes (computed off the LAGGING React-committed
// camera) can't land after us and shift the tile by the stale-camera delta.
posEl.style.setProperty('left', `${s.left}px`, 'important');
posEl.style.setProperty('top', `${s.top}px`, 'important');
el.style.setProperty('width', `${s.width}px`, 'important');
el.style.setProperty('height', `${s.height}px`, 'important');
el.style.setProperty('transform', s.transform, 'important');
el.style.setProperty('transform-origin', s.transformOrigin, 'important');
};
apply();
window.addEventListener('openswarm:canvas-pan-changed', apply);
window.addEventListener('resize', apply);
// The workspace can change size WITHOUT a window resize (chrome collapsing on fullscreen-enter,
// a banner appearing). These inline writes OVERRIDE the class styles, so they must re-measure on
// the same signals the React path uses or the tile keeps the pre-collapse viewport (the
// fullscreen card that stopped short of the bottom edge).
const vp = document.querySelector('[data-canvas-viewport]');
const ro = new ResizeObserver(apply);
if (vp) ro.observe(vp);
const timers = [60, 250, 700].map((ms) => window.setTimeout(apply, ms));
// animateTo moves the camera WITHOUT emitting pan-changed, and the important-priority writes
// above block React's late self-heal, so a tiled card must track the live camera itself:
// a write-on-change rAF loop (frozen while hidden is fine; nothing moves visually then).
let raf = 0;
let lastKey = '';
const tick = (): void => {
const cam = getLive();
const size = workspaceSize();
const key = `${cam.panX}:${cam.panY}:${cam.zoom}:${size.w}:${size.h}`;
if (key !== lastKey) { lastKey = key; apply(); }
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => {
window.removeEventListener('openswarm:canvas-pan-changed', apply);
window.removeEventListener('resize', apply);
props.forEach((pr) => el.style.removeProperty(pr));
ro.disconnect();
timers.forEach((tm) => window.clearTimeout(tm));
window.cancelAnimationFrame(raf);
clearAll();
};
}, [zone, getLive, selectId]);
return zone ? computeTiledStyle(zone, panX, panY, zoom) : null;