diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 9c8abf15..3c4f85a8 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -275,6 +275,17 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (b.docked_to !== (sessionIdProp || routeId)) continue; if (!best || zOf(b) > bestZ) { best = b; bestZ = zOf(b); } } + // Apps dock into the SAME slot but live in viewCards, so this saw none of them: a docked app + // left width/height at 0, the slot fell back to its generic box with no aspectRatio, and the + // app contain-fitted inside a frame never shaped for it -- the grey margins either side in + // Eric's screenshot (ENG-410). One slot per chat, so the same z-order pick decides both. + const p_views = (st.dashboardLayout as { viewCards?: Record }).viewCards ?? {}; + for (const [p_key, v] of Object.entries(p_views)) { + const p_dock = v.docked_to ?? ((v.parent_session_id && !v.freed) ? v.parent_session_id : null); + if (p_dock !== (sessionIdProp || routeId)) continue; + const p_z = st.dashboardLayout.zOrders[p_key] ?? v.zOrder ?? 0; + if (!best || p_z > bestZ) { best = { browser_id: p_key, width: v.width, height: v.height, zOrder: v.zOrder }; bestZ = p_z; } + } return best; }; const dockedSurfaceW = useAppSelector((st) => pickTopDocked(st)?.width ?? 0); diff --git a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx index 25ac10ba..17f38e12 100644 --- a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx +++ b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx @@ -83,6 +83,52 @@ const BrowserEmbed: React.FC<{ c: ClaudeTokens; browserId: string; title: string ); }; + +// An app built in this chat gets the same treatment as a browser: a titled frame with a real view +// of the thing, not a text row. It was a one-line link while browsers showed a live picture, which +// is the asymmetry Eric reported ("it should show that it's inside the agent like browser agents"). +// Uses the stored `thumbnail` rather than a live capture: app cards never register a webview in +// browserRegistry, so captureBrowserShot cannot see them, and inventing that path blind is how a +// preview becomes a renderer crash. +const AppEmbed: React.FC<{ c: ClaudeTokens; name: string; thumbnail: string | null; live: boolean; onOpen: () => void }> = ({ c, name, thumbnail, live, onOpen }) => ( + + + + + + {name} + + Built in this chat + {live && } + + + Open on canvas + + + {thumbnail ? ( + + ) : ( + + {live ? 'Building...' : 'Preview not captured yet'} + + )} + + +); + const InlineSurfaceEmbeds: React.FC<{ c: ClaudeTokens; sessionId: string; fullscreen?: boolean }> = ({ c, sessionId, fullscreen }) => { const dispatch = useAppDispatch(); const browserCards = useAppSelector((s) => s.dashboardLayout.browserCards); @@ -141,28 +187,14 @@ const InlineSurfaceEmbeds: React.FC<{ c: ClaudeTokens; sessionId: string; fullsc ); })} {linkedApps.map((o) => ( - popOut(() => dispatch(focusViewCard(o.id)))} - sx={{ - display: 'flex', alignItems: 'center', gap: 1.25, px: 1.25, py: 1, - border: `1px solid ${c.border.subtle}`, borderRadius: 2, cursor: 'pointer', - bgcolor: c.bg.elevated, transition: 'border-color 150ms', - '&:hover': { borderColor: c.border.strong }, - }} - > - - - - {o.name || 'App'} - - Built in this chat - - - - Open - - + c={c} + name={o.name || 'App'} + thumbnail={o.thumbnail ?? null} + live={live} + onOpen={() => popOut(() => dispatch(focusViewCard(o.id)))} + /> ))} ); diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 81b01eb1..8dbd46eb 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -47,7 +47,7 @@ import { extractLiveSteps } from '../desktop/agentLiveSteps'; import { extractLatestShowUi, extractPendingAskUi, freezeIfDone, artifactName, hasWorkAfterLatestShowUi } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; import { useBrowserPillShot } from '../desktop/useBrowserPillShot'; -import { subscribeFollowingBrowsers, isBrowserFollowing } from '../desktop/followingBrowsers'; +import { subscribeFollowingBrowsers, isSurfaceFollowing } from '../desktop/followingBrowsers'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import AskQuestionCard from '@/app/pages/AgentChat/tool-ui/AskQuestionCard'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; @@ -694,8 +694,10 @@ const AgentCard: React.FC = ({ // f7's collapsed state: a session's browser (spawned by it or docked into it) shows under the pill. const browserShot = useBrowserPillShot(session.id, pillMode && !pillArtifact); - // A live miniature under the pill owns that space; the pill's own artifacts go quiet. - const browserDocked = React.useSyncExternalStore(subscribeFollowingBrowsers, () => isBrowserFollowing(session.id)); + // A live miniature under the pill owns that space; the pill's own artifacts go quiet. Surfaces + // are browsers AND apps: this asked only about browsers, so a tucked app got the steps popover + // drawn straight over it (ENG-410). + const browserDocked = React.useSyncExternalStore(subscribeFollowingBrowsers, () => isSurfaceFollowing(session.id)); // justDraggedRef: the motion.div parks at the START position for the whole imperative drag, so the end-of-drag commit must snap (not spring) to the final spot or the card visibly re-glides from where the drag began. const noTransition = isDragging || isResizing || (isSelected && multiDragActive) || justDraggedRef.current; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 8d242f17..b1a14e42 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -51,7 +51,7 @@ import WindowControls from './WindowControls'; import { useTiledCard } from './useTiledCard'; import { useCardTiling } from './useCardTiling'; import { getMinimizedShot, saveMinimizedShot } from '../desktop/minimizedShots'; -import { setBrowserFollowing } from '../desktop/followingBrowsers'; +import { setSurfaceFollowing } from '../desktop/followingBrowsers'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -1133,8 +1133,8 @@ const BrowserCard: React.FC = ({ // Tell the pill a live miniature is underneath it, so it suppresses its own artifacts instead of // stacking a widget/frozen shot on top of the browser (Eric's overlap screenshots). useEffect(() => { - if (tuckTo) setBrowserFollowing(tuckTo, browserId, followsParent); - return () => { if (tuckTo) setBrowserFollowing(tuckTo, browserId, false); }; + if (tuckTo) setSurfaceFollowing(tuckTo, browserId, followsParent); + return () => { if (tuckTo) setSurfaceFollowing(tuckTo, browserId, false); }; }, [followsParent, tuckTo, browserId]); // Under the pill, not beside it: beside-at-pill-height read as a detached window fighting the // pill's ring and shadow (Eric, 2026-08-17); tucked below the collapsed pill it reads as the diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 384c7ab6..926ebd82 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -12,6 +12,7 @@ import { Output, SERVE_BASE, updateOutput } from '@/shared/state/outputsSlice'; import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { saveMinimizedShot } from '../desktop/minimizedShots'; +import { setSurfaceFollowing } from '../desktop/followingBrowsers'; import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget'; import { expandSession } from '@/shared/state/agentsSlice'; import WindowControls from './WindowControls'; @@ -599,6 +600,12 @@ const DashboardViewCard: React.FC = ({ // Collapsed parent = the app tucks under the pill as a 320px miniature, EVERY stage, no shadow; // the same contract BrowserCard carries (Eric's screenshots: a full-size app half over the pill). const followsParent = !!tuckTo && !!dockParentCard && !dockParentExpanded && !isTiled && !isMinimized; + // Announce the tucked app so the pill suppresses its own artifacts. BrowserCard has always done + // this; the app card never did, which is the whole of the overlap bug (ENG-410). + useEffect(() => { + if (tuckTo) setSurfaceFollowing(tuckTo, output.id, followsParent); + return () => { if (tuckTo) setSurfaceFollowing(tuckTo, output.id, false); }; + }, [followsParent, tuckTo, output.id]); const followScale = Math.min(1, 320 / displayW); const followX = followsParent && dockParentCard ? dockParentCard.x : null; const followY = followsParent && dockParentCard ? dockParentCard.y + 52 : null; diff --git a/frontend/src/app/pages/Dashboard/desktop/followingBrowsers.ts b/frontend/src/app/pages/Dashboard/desktop/followingBrowsers.ts index 8be5380a..f90943d0 100644 --- a/frontend/src/app/pages/Dashboard/desktop/followingBrowsers.ts +++ b/frontend/src/app/pages/Dashboard/desktop/followingBrowsers.ts @@ -1,4 +1,9 @@ -// Which collapsed chats currently have their LIVE browser miniature tucked under the pill. +// Which collapsed chats currently have a LIVE surface miniature tucked under the pill. +// +// Surfaces are browsers AND apps. It was browser-only, and an app tucking under a pill therefore +// never announced itself, so the pill happily drew its steps popover straight over the app +// (ENG-410). The map is keyed by parent session and holds the surface id, so two surfaces under one +// pill cannot silently un-register each other. // The pill reads this to go quiet (no widget/shot/thinking below it) so nothing overlaps the // miniature; render-level only, so the park machinery's state is never touched (the earlier // drop-the-frozen-shot approach made park/unpark oscillate and the browser blinked). @@ -10,18 +15,22 @@ export function subscribeFollowingBrowsers(fn: () => void): () => void { return () => p_subs.delete(fn); } -export function setBrowserFollowing(parentSessionId: string, browserId: string, on: boolean): void { +export function setSurfaceFollowing(parentSessionId: string, surfaceId: string, on: boolean): void { const cur = p_following.get(parentSessionId); if (on) { - if (cur === browserId) return; - p_following.set(parentSessionId, browserId); + if (cur === surfaceId) return; + p_following.set(parentSessionId, surfaceId); } else { - if (cur !== browserId) return; + if (cur !== surfaceId) return; p_following.delete(parentSessionId); } p_subs.forEach((fn) => fn()); } -export function isBrowserFollowing(parentSessionId: string): boolean { +export function isSurfaceFollowing(parentSessionId: string): boolean { return p_following.has(parentSessionId); } + +/** @deprecated browser-only name kept so no call site silently stops registering. */ +export const setBrowserFollowing = setSurfaceFollowing; +export const isBrowserFollowing = isSurfaceFollowing;