mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] canvas: an app gets the same surface contract as a browser, in the slot, the embed and the pill (ENG-410)
Three symptoms, one asymmetry: the app surface was copied from the browser surface and the copy was incomplete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018foyDoK19jjbYdudfzQVkZ
This commit is contained in:
co-authored by
Claude Opus 5
parent
66ca97d773
commit
e59ba8c561
@@ -275,6 +275,17 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<string, { id?: string; docked_to?: string | null; parent_session_id?: string | null; freed?: boolean; width: number; height: number; zOrder: number }> }).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);
|
||||
|
||||
@@ -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 }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
border: `1px solid ${c.border.subtle}`, borderRadius: 2, overflow: 'hidden', cursor: 'pointer',
|
||||
bgcolor: c.bg.elevated, transition: 'border-color 150ms, box-shadow 150ms',
|
||||
'&:hover': { borderColor: c.border.strong, boxShadow: c.shadow.md },
|
||||
'&:hover .osw-embed-open': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.25, py: 0.75, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<GridViewRoundedIcon sx={{ fontSize: 14, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.secondary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: c.text.ghost, flex: 1 }}>Built in this chat</Typography>
|
||||
{live && <Box sx={{ width: 6, height: 6, borderRadius: '50%', background: c.status.success, flexShrink: 0 }} />}
|
||||
<Box className="osw-embed-open" sx={{ display: 'flex', alignItems: 'center', gap: 0.4, opacity: 0, transition: 'opacity 120ms', color: c.text.muted, flexShrink: 0 }}>
|
||||
<OpenInFullRoundedIcon sx={{ fontSize: 12 }} />
|
||||
<Typography sx={{ fontSize: '0.625rem', fontWeight: 600 }}>Open on canvas</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{thumbnail ? (
|
||||
<Box component="img" src={thumbnail} alt="" sx={{ display: 'block', width: '100%', maxHeight: 260, objectFit: 'cover', objectPosition: 'top' }} />
|
||||
) : (
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.ghost, fontSize: '0.75rem' }}>
|
||||
{live ? 'Building...' : 'Preview not captured yet'}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<Box
|
||||
<AppEmbed
|
||||
key={o.id}
|
||||
onClick={() => 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 },
|
||||
}}
|
||||
>
|
||||
<GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600, color: c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{o.name || 'App'}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: c.text.muted }}>Built in this chat</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: c.text.muted }}>
|
||||
<OpenInFullRoundedIcon sx={{ fontSize: 12 }} />
|
||||
<Typography sx={{ fontSize: '0.625rem', fontWeight: 600 }}>Open</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
c={c}
|
||||
name={o.name || 'App'}
|
||||
thumbnail={o.thumbnail ?? null}
|
||||
live={live}
|
||||
onOpen={() => popOut(() => dispatch(focusViewCard(o.id)))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<Props> = ({
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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<Props> = ({
|
||||
// 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
|
||||
|
||||
@@ -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<Props> = ({
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user