[eric] shell: uncrop the spawn-pill shadow, shortcut tooltip on the composer, always-visible glass title bubble (incl. fullscreen), and collapsed cards show a plain-text answer

This commit is contained in:
ciregenz
2026-07-29 20:13:56 -07:00
parent 5b9d0dcce6
commit fac02bfccc
7 changed files with 104 additions and 34 deletions
@@ -419,7 +419,8 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
boxShadow: historyOpen || !isExpanded ? 'none' : '0 12px 32px rgba(0,0,0,0.4)',
padding: isExpanded ? '6px' : '0px',
userSelect: 'none' as const,
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
// Only the view picker needs clipping (it scrolls its own list); anything else clipped the collapsed spawn pill's drop shadow flat against its own border box.
overflow: viewPickerOpen ? 'hidden' : 'visible',
// historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size.
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
}}
@@ -96,9 +96,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
}) => {
return (
<>
{/* Floating bottom toolbar (all floating chrome steps aside while anything is fullscreen) */}
{/* Floating bottom toolbar (all floating chrome steps aside while anything is fullscreen). Sits at 30 not 16 so the spawn pill's 26px shadow reach clears the canvas root's clip. */}
{!anyFullscreen && (
<Box sx={{ position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 10 }}>
<Box sx={{ position: 'absolute', bottom: 30, left: '50%', transform: 'translateX(-50%)', zIndex: 10 }}>
<DashboardToolbar
ref={toolbarRef}
inputOpen={toolbarOpen}
@@ -49,6 +49,7 @@ import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta';
import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext';
import { GLASS_SURFACE, GLASS_SURFACE_BLUR, GLASS_SURFACE_TEXT } from '@/shared/styles/glassSurface';
import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
@@ -712,6 +713,20 @@ const AgentCard: React.FC<Props> = ({
[session.messages, session.status],
);
const pillMode = !expanded && !hasPending && !isDraft && !tileZone;
// Glass bubble + fullscreen scrim are both dark in either theme, so the title goes light on them.
const titleColor = expanded ? GLASS_SURFACE_TEXT : c.text.primary;
// The answer a finished turn actually spoke, for pills with no widget/plan to show. Only the last assistant say, never a tool line.
const pillFinalText = useMemo(() => {
if (session.status === 'running') return null;
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i];
if (m.role === 'user') break;
if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
return m.content.trim().slice(0, 400);
}
}
return session.last_message_preview?.trim() || null;
}, [session.messages, session.status, session.last_message_preview]);
const pillLabel = session.turn_label?.label || displayChatTitle(session);
const pillRunning = session.status === 'running';
@@ -1049,6 +1064,7 @@ const AgentCard: React.FC<Props> = ({
askPair={pillAskPair}
sessionId={session.id}
browserShot={browserShot}
finalText={pillFinalText}
selected={isSelected}
highlighted={isHighlighted}
/>
@@ -1068,7 +1084,7 @@ const AgentCard: React.FC<Props> = ({
...(expanded
? tiledStyle
? {
// Fullscreen/tiled: no room above the card, keep the inside hover scrim.
// Fullscreen/tiled: no room above the card, so the scrim rides on top. Never hidden: in fullscreen the title and the lights are the only way out.
position: 'absolute',
top: 0,
left: 0,
@@ -1077,9 +1093,7 @@ const AgentCard: React.FC<Props> = ({
px: 2,
pt: 1.5,
pb: 2,
opacity: 0,
transition: 'opacity 0.15s ease',
'&:hover': { opacity: 1 },
opacity: 1,
background: 'linear-gradient(to bottom, rgba(20,12,28,0.92) 0%, rgba(20,12,28,0.65) 60%, rgba(20,12,28,0) 100%)',
borderRadius: '12px 12px 0 0',
// Header text must read over the dark scrim regardless of app theme.
@@ -1087,7 +1101,7 @@ const AgentCard: React.FC<Props> = ({
'& input': { color: 'rgba(255,255,255,0.92)' },
}
: {
// On the canvas the title + lights pop up ABOVE the card, same as the minimized pill.
// On the canvas the title + lights pop up ABOVE the card, same as the minimized pill. Always visible: a hover-only name means you can't tell your agents apart at a glance.
position: 'absolute',
bottom: '100%',
top: 'auto',
@@ -1096,11 +1110,6 @@ const AgentCard: React.FC<Props> = ({
zIndex: 17,
px: 0.25,
pb: 0.75,
opacity: 0,
pointerEvents: 'none',
transition: 'opacity 0.15s ease, transform 0.15s ease',
transform: 'translateY(4px)',
'.osw-card:hover &': { opacity: 1, pointerEvents: 'auto', transform: 'none' },
}
: {
position: 'relative',
@@ -1140,20 +1149,35 @@ const AgentCard: React.FC<Props> = ({
display: 'flex',
alignItems: 'center',
gap: 1,
borderRadius: 1,
// Expanded titles wear the same glass bubble as the collapsed pill; a bare label floating over the canvas read as a stray caption.
...(expanded && !tiledStyle && {
alignSelf: 'flex-start',
flex: '0 1 auto',
// mr auto or the row's space-between flings the bubble to the far edge, away from the lights.
mr: 'auto',
maxWidth: '100%',
px: 1.25,
py: 0.375,
borderRadius: 999,
background: GLASS_SURFACE,
backdropFilter: GLASS_SURFACE_BLUR,
WebkitBackdropFilter: GLASS_SURFACE_BLUR,
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
}),
...(!(expanded && !tiledStyle) && { borderRadius: 1 }),
}}
>
<InlineEditableTitle
value={displayChatTitle(session)}
onCommit={(name) => dispatch(renameSession({ sessionId: session.id, name }))}
sx={{ flex: '0 1 auto', minWidth: 0, maxWidth: '100%', color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}
sx={{ flex: '0 1 auto', minWidth: 0, maxWidth: '100%', color: titleColor, fontWeight: 600, fontSize: '1rem' }}
>
<Typewriter
value={displayChatTitle(session)}
enabled={!!session.name && !isLegacyAutoName(session.name)}
>
{(t) => (
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography sx={{ color: titleColor, fontWeight: 600, fontSize: '1rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t}
</Typography>
)}
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CheckIcon from '@mui/icons-material/Check';
import DashboardGlyph from '../canvas/DashboardGlyph';
import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface';
import ShowUiWidgetView from '@/app/pages/AgentChat/tool-ui/ShowUiWidgetView';
import AskUiBubble from '@/app/pages/AgentChat/tool-ui/AskUiBubble';
import type { ToolPair } from '@/app/pages/AgentChat/tool-bubbles/ToolCallBubble';
@@ -17,22 +18,24 @@ interface AgentNarratorPillProps {
askPair?: ToolPair | null;
sessionId?: string;
browserShot: string | null;
/** The turn's plain-text answer, shown when the turn produced no richer artifact. */
finalText?: string | null;
selected: boolean;
highlighted: boolean;
}
const GLASS = 'rgba(24,14,32,0.8)';
const GLASS_BLUR = 'blur(18px) saturate(150%)';
const GLASS = GLASS_SURFACE;
const GLASS_BLUR = GLASS_SURFACE_BLUR;
const MAX_VISIBLE_TODOS = 4;
/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: live question > widget > browser shot > plan > Thinking. */
function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId, browserShot, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId, browserShot, finalText, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS);
const hiddenCount = (todos?.length || 0) - visibleTodos.length;
const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined;
const liveAsk = askPair && sessionId ? askPair : null;
// One key per ladder state so a state CHANGE remounts the artifact and replays the one-shot entrance; nothing loops.
const artifactKey = liveAsk ? `ask-${liveAsk.id}` : artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : 'none';
const artifactKey = liveAsk ? `ask-${liveAsk.id}` : artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : finalText ? 'final' : 'none';
return (
<Box
@@ -175,6 +178,36 @@ function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId
Thinking...
</Typography>
</Box>
) : finalText ? (
// Last resort, and the common one: a turn that just talked back. Without this a collapsed card answered "hi" with silence.
<Box
key={artifactKey}
className="osw-artifact"
sx={{
maxWidth: 320,
px: 1.5,
py: 0.875,
borderRadius: '14px',
background: GLASS,
backdropFilter: GLASS_BLUR,
WebkitBackdropFilter: GLASS_BLUR,
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
}}
>
<Typography
sx={{
fontSize: '0.75rem',
color: 'rgba(255,255,255,0.82)',
lineHeight: 1.45,
display: '-webkit-box',
WebkitLineClamp: 4,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{finalText}
</Typography>
</Box>
) : null}
</Box>
);
@@ -8,6 +8,7 @@ import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
import MicIcon from '@mui/icons-material/Mic';
import CircularProgress from '@mui/material/CircularProgress';
import { useVoice } from '@/shared/voice/voiceContext';
import { useAppSelector } from '@/shared/hooks';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
@@ -41,6 +42,7 @@ function DesktopSpawnPill({
onHistory,
}: DesktopSpawnPillProps): React.ReactElement {
const [menuOpen, setMenuOpen] = useState(false);
const newAgentShortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut);
const rootRef = useRef<HTMLDivElement | null>(null);
const { state: voiceState, pct: voicePct, pressStart: voicePressStart, pressEnd: voicePressEnd } = useVoice();
const recording = voiceState === 'recording';
@@ -124,22 +126,26 @@ function DesktopSpawnPill({
background: 'rgba(22,12,34,0.66)',
backdropFilter: 'blur(20px) saturate(160%)',
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
// Reach (offset + blur) stays under the 30px the canvas leaves below us, or the bottom edge shears the tail off.
boxShadow: '0 6px 20px rgba(0,0,0,0.35)',
cursor: 'text',
}}
onClick={onOpenComposer}
>
<Typography
sx={{
fontSize: '0.8125rem',
color: 'rgba(255,255,255,0.55)',
fontWeight: 400,
userSelect: 'none',
mr: 1.5,
}}
>
Ask me anything...
</Typography>
{/* Tooltip sits on the placeholder, not the whole pill, so it can't double up with the + and mic tooltips. */}
<Tooltip title={`Ask me anything (${comboDisplay(newAgentShortcut || 'Meta+l')})`} placement="top" arrow>
<Typography
sx={{
fontSize: '0.8125rem',
color: 'rgba(255,255,255,0.55)',
fontWeight: 400,
userSelect: 'none',
mr: 1.5,
}}
>
Ask me anything...
</Typography>
</Tooltip>
<Box
role="button"
aria-label="Add to canvas"
@@ -15,6 +15,7 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice';
import { addBrowserCard } from '@/shared/state/dashboardLayoutSlice';
import { getLastDashboardId } from '@/shared/lastDashboardId';
import { API_BASE } from '@/shared/config';
@@ -198,7 +199,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
</Box>
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { openExternal(DISCORD_URL); onClose(); }} sx={rowSx}>
<Box component="button" onClick={() => { dispatch(addBrowserCard({ url: DISCORD_URL })); onClose(); }} sx={rowSx}>
<ForumOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Talk to the team</Typography>
@@ -206,7 +207,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
</Box>
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { openExternal(DOCS_URL); onClose(); }} sx={rowSx}>
<Box component="button" onClick={() => { dispatch(addBrowserCard({ url: DOCS_URL })); onClose(); }} sx={rowSx}>
<MenuBookOutlinedIcon sx={iconSx} />
<Typography sx={{ flex: 1, fontSize: '0.8125rem', fontWeight: 500 }}>Docs &amp; shortcuts</Typography>
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.4)' }} />
@@ -0,0 +1,5 @@
// The shell's floating-chrome glass (narrator pill, card title bubbles). Deliberately dark in BOTH
// themes, so it is a fixed shell surface rather than a theme token: text on it is always light.
export const GLASS_SURFACE = 'rgba(24,14,32,0.8)';
export const GLASS_SURFACE_BLUR = 'blur(18px) saturate(150%)';
export const GLASS_SURFACE_TEXT = 'rgba(255,255,255,0.92)';