[eric] browser: agent browsers live INSIDE the chat by default (geometric dock slot, webview never remounts); drag out to free it, drop on a chat to dock

This commit is contained in:
ciregenz
2026-07-28 12:06:15 -07:00
parent 43d385ff63
commit d97dd98911
5 changed files with 87 additions and 11 deletions
+2
View File
@@ -44,6 +44,8 @@ class BrowserCardPosition(BaseModel):
keep_open: bool = False
# The dashboard this card calls home. Persisted so the home survives a save; without it the card reloads untagged and renders on EVERY dashboard (the cross-dashboard bleed).
dashboard_id: Optional[str] = None
# Chat session this browser lives inside (renders over the chat's dock slot); None = free card.
docked_to: Optional[str] = None
class NotePosition(BaseModel):
@@ -257,6 +257,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
};
const { id: routeId } = useParams<{ id: string }>();
const id = sessionIdProp || routeId;
// True while a spawned browser calls this chat home; gates the dock slot the real card overlays.
const hasDockedBrowser = useAppSelector((st) => Object.values(st.dashboardLayout.browserCards).some((bc) => bc.docked_to === (sessionIdProp || routeId)));
// A card linked as a workflow sidecar (Test Agent, or a watched run) swaps its composer for a Force Stop button: continuing the chat is meaningless, but killing the run is the common need. Once a Test Agent finishes, the button flips to a green "close" (see workflow_test_state + ForceStopAgentBar).
const linkedSidecar = useAppSelector((s) => {
const found = Object.values(s.workflows.openCards).find(
@@ -2395,6 +2397,23 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Fade>
);
})()}
{/* 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 && (
<Box
data-browser-slot={id}
sx={{
flexShrink: 0,
height: 320,
mx: 1.5,
mb: 1,
borderRadius: '10px',
border: `1px dashed ${c.border.medium}`,
background: c.bg.secondary,
}}
/>
)}
{readOnly ? null : isStoppableSidecar ? (
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
) : (
@@ -40,6 +40,7 @@ import {
toggleMinimizeCard,
setTiledCard,
clearTiledCard,
setBrowserDocked,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import WindowControls from './WindowControls';
@@ -239,6 +240,36 @@ const BrowserCard: React.FC<Props> = ({
else dispatch(setTiledCard({ cardId: browserId, zone }));
}, [dispatch, browserId]);
// ---- In-chat dock: while docked to an expanded chat, the card overlays the chat's slot rect.
// Pure geometry in the shared canvas layer (same DOM node), so the webview never remounts.
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 [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const rootElRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!dockedTo || !dockParentCard || !dockParentExpanded) { setDockRect(null); return undefined; }
const measure = (): void => {
const slot = document.querySelector(`[data-browser-slot="${dockedTo}"]`);
const layer = rootElRef.current?.parentElement;
if (!slot || !layer) { setDockRect(null); return; }
const z = getCanvasState().zoom || 1;
const lr = layer.getBoundingClientRect();
const sr = slot.getBoundingClientRect();
// Slot and card share the transformed layer, so layer-relative coords are camera-invariant.
setDockRect({ x: (sr.left - lr.left) / z, y: (sr.top - lr.top) / z, w: sr.width / z, h: sr.height / z });
};
measure();
const slot = document.querySelector(`[data-browser-slot="${dockedTo}"]`);
const ro = new ResizeObserver(measure);
if (slot) ro.observe(slot);
if (slot?.parentElement) ro.observe(slot.parentElement);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
// 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]);
const dockParentZ = dockParentCard?.zOrder ?? 0;
const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]);
const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]);
@@ -717,13 +748,13 @@ const BrowserCard: React.FC<Props> = ({
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
dragState.current = { startX: e.clientX, startY: e.clientY, origX: dockRect?.x ?? cardX, origY: dockRect?.y ?? cardY, startPanX: cs.panX, startPanY: cs.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ }
onDragStart?.(browserId, 'browser');
}, [cardX, cardY, onDragStart, browserId, getCanvasState, tileZone]);
}, [cardX, cardY, onDragStart, browserId, getCanvasState, tileZone, dockRect]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -777,6 +808,17 @@ const BrowserCard: React.FC<Props> = ({
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
// Dropping over a chat docks the browser INTO it; anywhere else undocks to a free card.
const { clientX: hx, clientY: hy } = lastPointerRef.current;
const under = document.elementsFromPoint(hx, hy);
const slotHit = under.map((el) => (el as HTMLElement).closest?.('[data-browser-slot]') as HTMLElement | null).find(Boolean);
const chatHit = under.map((el) => (el as HTMLElement).closest?.('[data-select-type="agent-card"]') as HTMLElement | null).find(Boolean);
const dockTarget = slotHit?.getAttribute('data-browser-slot') || chatHit?.getAttribute('data-select-id') || null;
if (dockTarget) {
dispatch(setBrowserDocked({ browserId, dockedTo: dockTarget }));
} else if (dockedTo) {
dispatch(setBrowserDocked({ browserId, dockedTo: null }));
}
dispatch(setBrowserCardPosition({
browserId,
x: finalX,
@@ -790,7 +832,7 @@ const BrowserCard: React.FC<Props> = ({
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
}, [dispatch, browserId, onDragEnd, getCanvasState]);
}, [dispatch, browserId, onDragEnd, getCanvasState, dockedTo]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -928,8 +970,11 @@ const BrowserCard: React.FC<Props> = ({
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !keepAliveHidden && !isMinimized;
return (
<Box
ref={rootElRef}
className="osw-card"
data-select-type="browser-card"
data-select-id={browserId}
@@ -959,20 +1004,20 @@ const BrowserCard: React.FC<Props> = ({
contain: 'layout style',
// Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale.
willChange: 'transform',
left: keepAliveHidden || isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : (dragging ? cardX : displayX)),
top: tiledStyle && !(keepAliveHidden || isMinimized) ? tiledStyle.top : (dragging ? cardY : displayY),
left: keepAliveHidden || isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)),
top: tiledStyle && !(keepAliveHidden || isMinimized) ? tiledStyle.top : dockActive ? dockRect!.y : (dragging ? cardY : displayY),
transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
width: tiledStyle ? tiledStyle.width : displayW,
height: tiledStyle ? tiledStyle.height : displayH,
borderRadius: tileZone === 'fullscreen' ? '12px' : `${c.radius.lg}px`,
width: tiledStyle ? tiledStyle.width : dockActive ? dockRect!.w : displayW,
height: tiledStyle ? tiledStyle.height : dockActive ? dockRect!.h : displayH,
borderRadius: tileZone === 'fullscreen' ? '12px' : dockActive ? '10px' : `${c.radius.lg}px`,
border: agentBorder,
bgcolor: c.bg.surface,
boxShadow: agentShadow,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? dockParentZ + 1 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
@@ -84,6 +84,8 @@ export interface BrowserCardPosition {
keep_open?: boolean;
/** Dashboard this card belongs to; cards render and persist only on their owning dashboard. */
dashboard_id?: string;
/** Chat session this browser lives inside (renders over the chat's dock slot); null/absent = free card. */
docked_to?: string | null;
}
export interface WorkflowCardPosition {
@@ -930,6 +932,10 @@ const dashboardLayoutSlice = createSlice({
if (state.viewCards[action.payload]) state.pendingFocusViewCardId = action.payload;
},
setBrowserDocked(state, action: PayloadAction<{ browserId: string; dockedTo: string | null }>) {
const bc = state.browserCards[action.payload.browserId];
if (bc) bc.docked_to = action.payload.dockedTo;
},
addBrowserCardFromBackend(state, action: PayloadAction<BrowserCardPosition>) {
const card = action.payload;
if (state.browserCards[card.browser_id]) return;
@@ -1812,6 +1818,7 @@ export const {
setActiveViewCardId,
addBrowserCard,
addBrowserCardFromBackend,
setBrowserDocked,
setBrowserCardPosition,
setBrowserCardSize,
removeBrowserCard,
+4 -1
View File
@@ -28,7 +28,7 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, setBrowserDocked, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { fetchSettings } from '../state/settingsSlice';
import { displaySessionName } from '../state/sessionDisplay';
@@ -847,6 +847,9 @@ class WebSocketManager {
let glowLabel = 'Use Browser';
if (parentCard) {
pos = placeBrowserBesideChat(layoutState, parentCard, parentId, browserCard.width, browserCard.height, browserCard.browser_id);
// Default home is INSIDE the chat: the card overlays the chat's dock slot while the
// chat is expanded; the beside-chat spot stays the undock/collapse fallback.
store.dispatch(setBrowserDocked({ browserId: browserCard.browser_id, dockedTo: parentId }));
} else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) {
pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP, true);
} else if (sess?.workflow_edit_id && layoutState.workflowsHub) {