mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 18:14:53 +02:00
[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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user