[eric] chat: live browser + app embeds inside the transcript, one click pops them onto the canvas

This commit is contained in:
ciregenz
2026-07-24 13:14:38 -07:00
parent c1edad623c
commit 79b9d3b4c2
3 changed files with 187 additions and 0 deletions
@@ -50,6 +50,7 @@ import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs, acquireSessionWs, releaseSessionWs, seedSessionSeq } from '@/shared/ws/WebSocketManager';
import StreamingBubble from './bubbles/StreamingBubble';
import WelcomeQuickReplies from './WelcomeQuickReplies';
import InlineSurfaceEmbeds from './shell/InlineSurfaceEmbeds';
import { useWelcomeGreeting } from './useWelcomeGreeting';
import { THINKING_LABELS } from './thinkingLabels';
import MessageBubble from './bubbles/MessageBubble';
@@ -1916,6 +1917,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
</Box>
)}
{/* The surfaces this agent is driving, live inside the chat: browser snapshots + built
apps, each one click from popping out onto the canvas. */}
{!isDraft && id && <InlineSurfaceEmbeds c={c} sessionId={id} fullscreen={fullscreenChat} />}
{/* First-run welcome chips: sit UNDER the streamed greeting, appear once it finishes,
vanish the moment the user answers. The greeting itself is a real assistant bubble. */}
{session.is_welcome_draft && isDraft && welcomeGreetingDone && !session.messages.some((m) => m.role === 'user') && (
@@ -0,0 +1,172 @@
import React, { useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { motion } from 'framer-motion';
import LanguageIcon from '@mui/icons-material/Language';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import OpenInFullRoundedIcon from '@mui/icons-material/OpenInFullRounded';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearTiledCard, focusBrowserCard, focusViewCard } from '@/shared/state/dashboardLayoutSlice';
import { getWebview } from '@/shared/browserRegistry';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
// The desktop-redesign frame: the surfaces an agent is driving live INSIDE its chat. Each linked
// browser card renders as a live snapshot embed (same visual grammar as the tool cards: rounded,
// bordered, quiet chrome header) that repaints while the agent works; each built app renders as an
// open row. Clicking pops the real card out on the canvas (exiting full size view first if needed),
// which IS the "drag it out" gesture without inventing a second windowing system inside the chat.
const SNAPSHOT_MS = 2500;
function useBrowserSnapshot(browserId: string, live: boolean): string | null {
const [shot, setShot] = useState<string | null>(null);
useEffect(() => {
let dead = false;
let timer = 0;
const grab = async (): Promise<void> => {
try {
const wv = getWebview(browserId) as unknown as { capturePage?: () => Promise<{ toDataURL(): string }> } | undefined;
if (wv?.capturePage) {
// capturePage can hang on off-screen guests (Electron 42); race a timeout so the poll never wedges.
const img = await Promise.race([
wv.capturePage(),
new Promise<null>((res) => window.setTimeout(() => res(null), 1200)),
]);
if (!dead && img) setShot(img.toDataURL());
}
} catch { /* snapshot is best-effort */ }
// Live sessions repaint forever; idle ones retry a few times so a webview that mounts late
// (fullscreen hiding the canvas, load races) still yields one real frame instead of a blank.
tries += 1;
if (!dead && (live || (tries < 6))) timer = window.setTimeout(() => { void grab(); }, SNAPSHOT_MS);
};
let tries = 0;
void grab();
return () => { dead = true; window.clearTimeout(timer); };
}, [browserId, live]);
return shot;
}
const BrowserEmbed: React.FC<{ c: ClaudeTokens; browserId: string; title: string; url: string; live: boolean; onOpen: () => void }> = ({ c, browserId, title, url, live, onOpen }) => {
const shot = useBrowserSnapshot(browserId, live);
return (
<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}` }}>
<LanguageIcon sx={{ fontSize: 14, color: c.text.muted, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.secondary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{title || 'Browser'}
</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: c.text.ghost, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
{url}
</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>
{shot ? (
<Box component="img" src={shot} 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 ? 'Loading page view...' : 'Page view unavailable'}
</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);
const outputs = useAppSelector((s) => s.outputs.items);
const sessions = useAppSelector((s) => s.agents.sessions);
const sessionStatus = sessions[sessionId]?.status;
const live = sessionStatus === 'running' || sessionStatus === 'waiting_approval';
const linkedBrowsers = useMemo(() => {
// A chat's browsers arrive two ways: cards this session spawned directly (spawned_by), and cards
// driven by its browser sub-agents (child session's browser_id). Union both, keyed by card id.
const childBrowserIds = new Set(
Object.values(sessions)
.filter((s) => s.parent_session_id === sessionId && s.browser_id)
.map((s) => s.browser_id as string),
);
return Object.values(browserCards).filter(
(bc) => bc.spawned_by === sessionId || childBrowserIds.has(bc.browser_id),
);
}, [browserCards, sessions, sessionId]);
const linkedApps = useMemo(
() => Object.values(outputs).filter((o) => o.session_id === sessionId),
[outputs, sessionId],
);
if (linkedBrowsers.length === 0 && linkedApps.length === 0) return null;
const popOut = (fire: () => void): void => {
// Full size view hides the canvas; step out first so "open on canvas" lands somewhere visible.
if (fullscreen) dispatch(clearTiledCard(sessionId));
fire();
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, my: 1 }}>
{linkedBrowsers.map((bc) => {
const tab = bc.tabs.find((t) => t.id === bc.activeTabId) ?? bc.tabs[0];
return (
<BrowserEmbed
key={bc.browser_id}
c={c}
browserId={bc.browser_id}
title={tab?.title ?? ''}
url={tab?.url ?? ''}
live={live}
onOpen={() => popOut(() => dispatch(focusBrowserCard(bc.browser_id)))}
/>
);
})}
{linkedApps.map((o) => (
<Box
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>
))}
</Box>
);
};
export default InlineSurfaceEmbeds;
@@ -918,6 +918,15 @@ const dashboardLayoutSlice = createSlice({
state.pendingFocusBrowserId = null;
},
// Camera-focus an EXISTING card (the inline chat embeds' "show on canvas"); the lifecycle
// pendingFocus effects own the fit + highlight + clear, same as a fresh add.
focusBrowserCard(state, action: PayloadAction<string>) {
if (state.browserCards[action.payload]) state.pendingFocusBrowserId = action.payload;
},
focusViewCard(state, action: PayloadAction<string>) {
if (state.viewCards[action.payload]) state.pendingFocusViewCardId = action.payload;
},
addBrowserCardFromBackend(state, action: PayloadAction<BrowserCardPosition>) {
const card = action.payload;
if (state.browserCards[card.browser_id]) return;
@@ -1833,6 +1842,8 @@ export const {
clearAllTiles,
clearCardWindowState,
clearPendingFocusBrowserId,
focusBrowserCard,
focusViewCard,
activateViewCardPreview,
clearPendingFocusViewCardId,
addWorkflowCard,