[eric] chat+dashboard: animated WebGL weather widget (vendored runtime, adapter keeps simple agent shape); AskUI Confirm wired to their native actions + orphan feedback; wallpaper v2 (aerial-perspective ridges, lit crests, 120s ambient drift, reduced-motion off)

This commit is contained in:
ciregenz
2026-07-19 23:44:37 -07:00
parent 8fb931136c
commit 0779692a54
3 changed files with 199 additions and 151 deletions
@@ -29,6 +29,7 @@ function parseResultResponse(pair: ToolPair): Record<string, unknown> | null {
function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubbleProps): React.ReactElement {
const payload = parseShowUiPayload(pair);
const [submitted, setSubmitted] = useState(false);
const [orphaned, setOrphaned] = useState(false);
const answered = parseResultResponse(pair);
const componentId = payload && payload.component === 'vendored' ? String(payload.props.id || '') : '';
@@ -41,13 +42,22 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken()}` },
body: JSON.stringify({ session_id: sessionId, component_id: componentId, response }),
}).catch(() => setSubmitted(false));
})
.then((r) => {
if (!r.ok) {
// Nothing parked server-side (agent gone or this is a replayed transcript): say so instead of silently swallowing the click.
setSubmitted(false);
setOrphaned(true);
}
})
.catch(() => setSubmitted(false));
},
[submitted, sessionId, componentId],
);
// Their embedded-actions contract: onAction(actionId, state) delivers the component's full state;
// approval-card uses onConfirm/onCancel instead. Inject a default Send action when none given.
// Their embedded-actions contract: onAction(actionId, state) delivers the component's full state,
// and the components ship their own footer actions (Clear/Confirm), so we only wire the callback.
// 'cancel' is a local clear, never an answer; approval-card uses onConfirm/onCancel instead.
const extraProps = useMemo(() => {
if (!payload || payload.component !== 'vendored') return {};
const waiting = pair.result === null && !submitted;
@@ -60,10 +70,11 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
: { choice: (answered?.choice as string) || undefined };
}
if (waiting) {
const hasActions = Array.isArray((payload.props as { actions?: unknown[] }).actions) && (payload.props as { actions?: unknown[] }).actions!.length > 0;
return {
...(hasActions ? {} : { actions: [{ id: 'submit', label: 'Send' }] }),
onAction: (actionId: string, state: unknown) => respond({ action: actionId, value: state ?? null }),
onAction: (actionId: string, state: unknown) => {
if (actionId === 'cancel') return;
respond({ action: actionId, value: state ?? null });
},
};
}
return answered && 'value' in answered ? { choice: answered.value } : {};
@@ -81,6 +92,11 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
{submitted && pair.result === null && (
<Box sx={{ fontSize: '0.72rem', opacity: 0.55, pt: 0.5 }}>Sent to the agent...</Box>
)}
{orphaned && (
<Box sx={{ fontSize: '0.72rem', opacity: 0.55, pt: 0.5 }}>
No agent is waiting for this answer (the request expired or this is an old transcript).
</Box>
)}
</Box>
);
}
@@ -1,91 +1,54 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import WbSunnyOutlinedIcon from '@mui/icons-material/WbSunnyOutlined';
import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined';
import GrainIcon from '@mui/icons-material/Grain';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import ThunderstormOutlinedIcon from '@mui/icons-material/ThunderstormOutlined';
import { WeatherWidget as AnimatedWeatherWidget } from '@toolui/components/weather-widget/weather-widget-container';
import type { WeatherConditionCode, ForecastDay } from '@toolui/components/weather-widget/schema-runtime';
import { useThemeMode } from '@/shared/styles/ThemeContext';
import type { WeatherProps } from './showUiPayload';
function conditionIcon(condition: string | undefined, size: number): React.ReactElement {
function toConditionCode(condition: string | undefined): WeatherConditionCode {
const cond = (condition || '').toLowerCase();
const sx = { fontSize: size, color: 'rgba(255,255,255,0.92)' };
if (/thunder|storm/.test(cond)) return <ThunderstormOutlinedIcon sx={sx} />;
if (/rain|drizzle|shower/.test(cond)) return <GrainIcon sx={sx} />;
if (/snow|sleet|ice/.test(cond)) return <AcUnitIcon sx={sx} />;
if (/cloud|overcast|fog|mist/.test(cond)) return <CloudOutlinedIcon sx={sx} />;
return <WbSunnyOutlinedIcon sx={sx} />;
if (/thunder|storm/.test(cond)) return 'thunderstorm';
if (/heavy rain|downpour/.test(cond)) return 'heavy-rain';
if (/drizzle/.test(cond)) return 'drizzle';
if (/rain|shower/.test(cond)) return 'rain';
if (/sleet/.test(cond)) return 'sleet';
if (/hail/.test(cond)) return 'hail';
if (/snow/.test(cond)) return 'snow';
if (/fog|mist|haze/.test(cond)) return 'fog';
if (/overcast/.test(cond)) return 'overcast';
if (/partly|part sun|some cloud/.test(cond)) return 'partly-cloudy';
if (/cloud/.test(cond)) return 'cloudy';
if (/wind/.test(cond)) return 'windy';
return 'clear';
}
/** iOS-style weather card: dusk-sky art, big thin temperature, five-day strip. */
/** Agent-facing 'weather' shape adapted onto the vendored animated (WebGL) weather widget. */
function WeatherWidget({ props }: { props: WeatherProps }): React.ReactElement {
const unit = props.unit || 'F';
const { mode } = useThemeMode();
const forecast: ForecastDay[] = (props.forecast || []).slice(0, 7).map((d) => ({
label: d.day,
conditionCode: toConditionCode(d.condition),
tempMin: Math.round(d.low ?? d.high - 8),
tempMax: Math.round(d.high),
}));
return (
<Box
sx={{
width: 300,
borderRadius: '16px',
overflow: 'hidden',
position: 'relative',
p: 2,
color: '#fff',
background:
'radial-gradient(120% 90% at 78% 62%, rgba(255,196,110,0.9) 0%, rgba(214,142,90,0.75) 30%, rgba(120,85,80,0.4) 55%, rgba(0,0,0,0) 75%), linear-gradient(180deg, #4d4048 0%, #6b5a58 45%, #8a6a55 100%)',
boxShadow: '0 10px 30px rgba(0,0,0,0.3)',
}}
>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 600, textShadow: '0 1px 8px rgba(0,0,0,0.35)' }}>
{props.location}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', mt: 0.5 }}>
<Typography sx={{ fontSize: '4rem', fontWeight: 200, lineHeight: 1, letterSpacing: '-2px', textShadow: '0 2px 12px rgba(0,0,0,0.3)' }}>
{Math.round(props.temp)}
</Typography>
<Typography sx={{ fontSize: '1.6rem', fontWeight: 300, mt: 0.5, opacity: 0.85 }}>°{unit}</Typography>
</Box>
{(props.high != null || props.low != null) && (
<Box sx={{ display: 'flex', gap: 1.5, mt: 1 }}>
{props.high != null && (
<Typography sx={{ fontSize: '0.9rem', fontWeight: 600 }}>
<Box component="span" sx={{ opacity: 0.6, fontWeight: 400 }}>H </Box>{Math.round(props.high)}°
</Typography>
)}
{props.low != null && (
<Typography sx={{ fontSize: '0.9rem', fontWeight: 600 }}>
<Box component="span" sx={{ opacity: 0.6, fontWeight: 400 }}>L </Box>{Math.round(props.low)}°
</Typography>
)}
</Box>
)}
{props.forecast && props.forecast.length > 0 && (
<Box
sx={{
display: 'flex',
mt: 2.5,
borderRadius: '12px',
background: 'rgba(255,255,255,0.14)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
px: 1,
py: 1.25,
}}
>
{props.forecast.slice(0, 5).map((d, i) => (
<Box key={`${d.day}-${i}`} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', opacity: 0.85 }}>
{d.day}
</Typography>
{conditionIcon(d.condition, 16)}
<Typography sx={{ fontSize: '0.82rem', fontWeight: 700 }}>{Math.round(d.high)}°</Typography>
{d.low != null && (
<Typography sx={{ fontSize: '0.72rem', opacity: 0.6 }}>{Math.round(d.low)}°</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`} style={{ width: 320 }}>
<AnimatedWeatherWidget
version="3.1"
id={`weather-${props.location}`}
location={{ name: props.location }}
units={{ temperature: props.unit === 'C' ? 'celsius' : 'fahrenheit' }}
current={{
conditionCode: toConditionCode(props.condition),
temperature: Math.round(props.temp),
tempMin: Math.round(props.low ?? props.temp - 4),
tempMax: Math.round(props.high ?? props.temp + 4),
}}
forecast={forecast}
time={{ localTimeOfDay: new Date().getHours() + new Date().getMinutes() / 60 }}
effects={{ enabled: true, quality: 'auto' }}
/>
</div>
);
}
@@ -3,100 +3,169 @@ import Box from '@mui/material/Box';
import { useThemeMode } from '@/shared/styles/ThemeContext';
/**
* Monterey-style layered wave wallpaper for the desktop-shell canvas. Pure SVG so it ships with
* the bundle, scales to any viewport, and stays crisp; sits under the wash/grain/dot-grid layers.
* Monterey-style layered-ridge wallpaper. Design rules: scenery not signal (long smooth ridges,
* luminance ramps pale sky -> deep indigo so glass chrome always has contrast), depth via aerial
* perspective (far ridges blurred + desaturated, near ridges sharp with a lit crest), and life via
* ONE imperceptible compositor-only drift (120s, transform-only, off under reduced-motion).
*/
function DesktopWallpaper(): React.ReactElement {
const { mode } = useThemeMode();
return (
<Box
aria-hidden
sx={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden' }}
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
overflow: 'hidden',
'& .osw-wp-drift': {
animation: 'osw-wp-drift 120s ease-in-out infinite alternate',
willChange: 'transform',
},
'& .osw-wp-drift-far': {
animation: 'osw-wp-drift-far 160s ease-in-out infinite alternate',
willChange: 'transform',
},
'@keyframes osw-wp-drift': {
from: { transform: 'translateX(0px)' },
to: { transform: 'translateX(-28px)' },
},
'@keyframes osw-wp-drift-far': {
from: { transform: 'translateX(0px)' },
to: { transform: 'translateX(18px)' },
},
'@media (prefers-reduced-motion: reduce)': {
'& .osw-wp-drift, & .osw-wp-drift-far': { animation: 'none' },
},
}}
>
<svg
width="100%"
height="100%"
viewBox="0 0 1600 1000"
preserveAspectRatio="xMidYMid slice"
style={{ position: 'absolute', inset: 0, display: 'block' }}
style={{ position: 'absolute', inset: '-2%', width: '104%', height: '104%', display: 'block' }}
>
<defs>
<linearGradient id="osw-wp-sky" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" stopColor="#d6d3e4" />
<stop offset="0.55" stopColor="#c39bc6" />
<stop offset="1" stopColor="#b06ab8" />
<stop offset="0" stopColor="#dcd9e8" />
<stop offset="0.45" stopColor="#c8b2d4" />
<stop offset="1" stopColor="#b490c4" />
</linearGradient>
<linearGradient id="osw-wp-pinkridge" x1="0" y1="0" x2="0.6" y2="1">
<stop offset="0" stopColor="#e4a9c6" />
<stop offset="0.5" stopColor="#cb9bbf" />
<stop offset="1" stopColor="#bd5f92" />
</linearGradient>
<linearGradient id="osw-wp-redridge" x1="0.2" y1="0" x2="0.7" y2="1">
<stop offset="0" stopColor="#e2547e" />
<stop offset="0.55" stopColor="#c33f92" />
<linearGradient id="osw-wp-redridge" x1="0.15" y1="0" x2="0.6" y2="1">
<stop offset="0" stopColor="#e0517c" />
<stop offset="0.5" stopColor="#c33f92" />
<stop offset="1" stopColor="#a5349b" />
</linearGradient>
<linearGradient id="osw-wp-purple" x1="0.1" y1="0.2" x2="0.8" y2="1">
<stop offset="0" stopColor="#9a3bbc" />
<stop offset="0.6" stopColor="#7726b8" />
<stop offset="1" stopColor="#641dab" />
<linearGradient id="osw-wp-pink" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0" stopColor="#e7b3cd" />
<stop offset="0.55" stopColor="#cb8fb8" />
<stop offset="1" stopColor="#bd5f92" />
</linearGradient>
<linearGradient id="osw-wp-indigo" x1="0.15" y1="0" x2="0.75" y2="1">
<stop offset="0" stopColor="#6716d5" />
<stop offset="0.65" stopColor="#3b1a9e" />
<stop offset="1" stopColor="#271581" />
<linearGradient id="osw-wp-magenta" x1="0.2" y1="0" x2="0.7" y2="1">
<stop offset="0" stopColor="#c14fb4" />
<stop offset="1" stopColor="#96319b" />
</linearGradient>
<linearGradient id="osw-wp-violetcrest" x1="0" y1="0" x2="1" y2="0.8">
<stop offset="0" stopColor="#8f35e0" />
<linearGradient id="osw-wp-purple" x1="0.1" y1="0.1" x2="0.7" y2="1">
<stop offset="0" stopColor="#8f35c9" />
<stop offset="0.6" stopColor="#7326b8" />
<stop offset="1" stopColor="#5c1da6" />
</linearGradient>
<linearGradient id="osw-wp-violet" x1="0" y1="0" x2="0.9" y2="0.9">
<stop offset="0" stopColor="#8d33dd" />
<stop offset="1" stopColor="#6716d5" />
</linearGradient>
<filter id="osw-wp-soft" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="7" />
<linearGradient id="osw-wp-indigo" x1="0.2" y1="0" x2="0.8" y2="1">
<stop offset="0" stopColor="#5b21c9" />
<stop offset="0.6" stopColor="#3b1a9e" />
<stop offset="1" stopColor="#271581" />
</linearGradient>
<linearGradient id="osw-wp-crest" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" stopColor="#ffffff" stopOpacity="0.55" />
<stop offset="1" stopColor="#ffffff" stopOpacity="0" />
</linearGradient>
<filter id="osw-wp-far" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="9" />
</filter>
<filter id="osw-wp-mid" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="3" />
</filter>
</defs>
<rect width="1600" height="1000" fill="url(#osw-wp-sky)" />
<rect x="-40" y="-20" width="1680" height="1040" fill="url(#osw-wp-sky)" />
{/* Far pink ridge sweeping the right half */}
<path
d="M820,0 C980,260 1120,380 1320,330 C1450,296 1540,220 1600,150 L1600,1000 L820,1000 Z"
fill="url(#osw-wp-pinkridge)"
filter="url(#osw-wp-soft)"
opacity="0.9"
/>
{/* Far layer: soft pink billow owning the right half + red-magenta ridge from the top-left. Blurred + gentle = aerial distance. */}
<g className="osw-wp-drift-far">
<path
d="M700,40 C900,180 1010,330 1230,360 C1400,382 1520,300 1620,190 L1620,1020 L700,1020 Z"
fill="url(#osw-wp-pink)"
filter="url(#osw-wp-far)"
opacity="0.9"
/>
<path
d="M-20,-20 L520,-20 C470,140 500,300 640,440 C480,610 220,600 -20,680 Z"
fill="url(#osw-wp-redridge)"
filter="url(#osw-wp-mid)"
opacity="0.95"
/>
</g>
{/* Red-magenta ridge descending from the top-left */}
<path
d="M0,0 L560,0 C480,150 520,300 660,470 C540,650 300,640 0,720 Z"
fill="url(#osw-wp-redridge)"
/>
{/* Mid layer: the magenta band bridging the composition. */}
<g className="osw-wp-drift">
<path
d="M-20,600 C280,470 560,540 840,640 C1090,728 1360,700 1620,580 L1620,1020 L-20,1020 Z"
fill="url(#osw-wp-magenta)"
filter="url(#osw-wp-mid)"
opacity="0.9"
/>
<path
d="M-20,596 C280,466 560,536 840,636"
fill="none"
stroke="url(#osw-wp-crest)"
strokeWidth="5"
opacity="0.35"
/>
{/* Mid magenta band bridging center */}
<path
d="M0,560 C340,430 620,520 900,660 C1120,770 1380,720 1600,600 L1600,1000 L0,1000 Z"
fill="#ab37ad"
opacity="0.85"
filter="url(#osw-wp-soft)"
/>
{/* Deep purple shoulder anchoring the bottom-left. */}
<path
d="M-20,560 C220,520 420,600 580,760 C420,900 180,880 -20,910 Z"
fill="url(#osw-wp-purple)"
/>
<path
d="M-20,558 C220,518 420,598 578,756"
fill="none"
stroke="url(#osw-wp-crest)"
strokeWidth="4"
opacity="0.3"
/>
</g>
{/* Deep purple shoulder, left */}
<path
d="M0,520 C260,470 470,560 640,740 C460,900 200,850 0,880 Z"
fill="url(#osw-wp-purple)"
/>
{/* Violet crest above the foreground wave */}
<path
d="M0,830 C360,680 760,850 1060,780 C1280,730 1460,800 1600,740 L1600,1000 L0,1000 Z"
fill="url(#osw-wp-violetcrest)"
opacity="0.92"
/>
{/* Foreground indigo wave */}
<path
d="M0,880 C320,760 720,910 1020,850 C1260,802 1470,880 1600,830 L1600,1000 L0,1000 Z"
fill="url(#osw-wp-indigo)"
/>
{/* Near layer: violet crest + indigo foreground wave, sharpest, with lit crests. */}
<g>
<path
d="M-20,820 C320,690 700,830 1010,772 C1260,726 1450,790 1620,720 L1620,1020 L-20,1020 Z"
fill="url(#osw-wp-violet)"
opacity="0.95"
/>
<path
d="M-20,816 C320,686 700,826 1010,768 C1260,722 1450,786 1620,716"
fill="none"
stroke="url(#osw-wp-crest)"
strokeWidth="4"
opacity="0.4"
/>
<path
d="M-20,882 C300,780 680,912 1000,856 C1260,810 1470,890 1620,834 L1620,1020 L-20,1020 Z"
fill="url(#osw-wp-indigo)"
/>
<path
d="M-20,878 C300,776 680,908 1000,852 C1260,806 1470,886 1620,830"
fill="none"
stroke="url(#osw-wp-crest)"
strokeWidth="3"
opacity="0.35"
/>
</g>
</svg>
{mode === 'dark' && (
<Box sx={{ position: 'absolute', inset: 0, background: 'rgba(12,6,24,0.38)' }} />