feat: faster variant switching, live Grok thoughts, accurate trip endpoints

- Bump z-index on vehicle selector and modals (z-50 sat below Leaflet panes)
- Prefetch the other route variants in the background as soon as the first
  trip lands; switching now hits the cache and is near-instant
- Surface Grok's streaming thoughts to the UI: glassy overlay on the empty
  map + sidebar callout, with skeleton shimmer until the first thought
- Thread explicit origin/destination from the TopBar through to the prompt
  as a ground-truth block; harden rules so the first/last stops match the
  user's actual endpoints and cross-Channel trips include both sides
This commit is contained in:
2026-05-31 17:14:04 +01:00
parent ed64712525
commit 88fc86dc32
3 changed files with 226 additions and 51 deletions
+181 -37
View File
@@ -566,6 +566,16 @@ function LegRow({ leg }: { leg: Leg | undefined }) {
);
}
// ─── Skeleton shimmer ────────────────────────────────────────────────────────
function SkeletonRow({ widthPct = 100 }: { widthPct?: number }) {
return (
<div
className="h-2.5 rounded animate-pulse"
style={{ width: `${widthPct}%`, background: 'linear-gradient(90deg, var(--gd-border) 0%, var(--gd-border-2) 50%, var(--gd-border) 100%)' }}
/>
);
}
// ─── Variant strip ───────────────────────────────────────────────────────────
const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
primary: 'var(--gd-red)',
@@ -1341,6 +1351,7 @@ export default function TeslaTripPlanner() {
const [chatInput, setChatInput] = useState('');
const [chips, setChips] = useState<string[]>([]);
const [thinking, setThinking] = useState(false);
const [thinkingMessage, setThinkingMessage] = useState<string>('');
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState<Vehicle>(DEFAULT_VEHICLE);
const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false);
@@ -1404,6 +1415,55 @@ export default function TeslaTripPlanner() {
return () => { cancelled = true; };
}, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Prefetch other variants in the background ─────────────────────────────
const prefetchedRef = React.useRef<Set<string>>(new Set());
const prefetchKey = React.useMemo(() => {
if (allStops.length < 2) return null;
return `${allStops[0]?.name}__${allStops[allStops.length - 1]?.name}`;
}, [allStops]);
// Drop cached variants whose journey doesn't match the current origin/destination.
const lastPrefetchKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!prefetchKey) return;
if (lastPrefetchKey.current && lastPrefetchKey.current !== prefetchKey) {
setVariantCache(prev => {
const next: typeof prev = {};
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
return next;
});
}
lastPrefetchKey.current = prefetchKey;
}, [prefetchKey, selectedVariant]);
React.useEffect(() => {
if (!prefetchKey) return;
if (thinking || variantSwitching) return;
if (variants.length < 2) return;
const targets: RouteVariant['id'][] = ['fast', 'scenic', 'cheap'];
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (!lastUserMsg) return;
let cancelled = false;
(async () => {
for (const id of targets) {
if (cancelled) return;
if (id === selectedVariant) continue;
if (variantCache[id]) continue;
const key = `${prefetchKey}::${id}`;
if (prefetchedRef.current.has(key)) continue;
prefetchedRef.current.add(key);
try {
await sendMessage(`Replan the trip as the ${id} variant`, { variant: id, silent: true, prefetch: true });
} catch (err) {
console.warn('[TeslaTrip] prefetch failed for', id, err);
prefetchedRef.current.delete(key);
}
}
})();
return () => { cancelled = true; };
}, [prefetchKey, variants.length, thinking, variantSwitching, selectedVariant]); // eslint-disable-line react-hooks/exhaustive-deps
const computedTotals = React.useMemo(() => {
if (legs.length === 0) return null;
const km = legs.reduce((s, l) => s + (l.distanceKm ?? 0), 0);
@@ -1411,17 +1471,23 @@ export default function TeslaTripPlanner() {
return { totalKm: km, driveMinutes: min };
}, [legs]);
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => {
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean; prefetch?: boolean } = {}) => {
const trimmed = text.trim();
if (!trimmed) return;
const variantToUse = opts.variant ?? selectedVariant;
if (!opts.silent) {
if (!opts.silent && !opts.prefetch) {
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
setChatInput('');
setChips(prev => [...prev, trimmed].slice(-6));
}
if (opts.variant) setVariantSwitching(true);
else setThinking(true);
if (opts.prefetch) {
// Background prefetch: never touch the visible itinerary or any spinner state.
} else if (opts.variant) {
setVariantSwitching(true);
} else {
setThinking(true);
setThinkingMessage('');
}
let lastPartialItinerary: any = null;
let lastVariants: any[] | null = null;
@@ -1438,6 +1504,8 @@ export default function TeslaTripPlanner() {
itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })),
selectedVariant: variantToUse,
origin: origin.trim() || undefined,
destination: destination.trim() || undefined,
}),
});
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
@@ -1466,35 +1534,43 @@ export default function TeslaTripPlanner() {
try { payload = JSON.parse(evData); } catch { continue; }
if (evName === 'thinking') {
// Could surface payload.message somewhere; for now we just show the existing spinner
if (!opts.prefetch && typeof payload.message === 'string' && payload.message.trim()) {
setThinkingMessage(payload.message.trim());
}
} else if (evName === 'partial') {
lastPartialItinerary = payload.itinerary;
if (Array.isArray(payload.variants)) lastVariants = payload.variants;
// Use synchronous normalizer for partials — no geocoding, no blocking
if (payload.itinerary) {
setItinerary(normalizePartialItinerary(payload.itinerary));
}
if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants));
if (!opts.prefetch) {
// Use synchronous normalizer for partials — no geocoding, no blocking
if (payload.itinerary) {
setItinerary(normalizePartialItinerary(payload.itinerary));
}
if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants));
}
}
} else if (evName === 'done') {
finalReply = payload.reply || '';
if (payload.itinerary) {
const clean = await normalizeAndSanitizeItinerary(payload.itinerary);
setItinerary(clean);
const variantJustRendered = typeof payload.selectedVariant === 'string'
? payload.selectedVariant as RouteVariant['id']
: opts.variant ?? selectedVariant;
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
lastSelectedVariant = variantJustRendered;
if (!opts.prefetch) {
setItinerary(clean);
}
}
if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants));
}
if (typeof payload.selectedVariant === 'string') {
setSelectedVariant(payload.selectedVariant as RouteVariant['id']);
} else if (opts.variant) {
setSelectedVariant(opts.variant);
if (!opts.prefetch) {
if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants));
}
if (typeof payload.selectedVariant === 'string') {
setSelectedVariant(payload.selectedVariant as RouteVariant['id']);
} else if (opts.variant) {
setSelectedVariant(opts.variant);
}
}
} else if (evName === 'error') {
throw new Error(payload.error || 'Stream error');
@@ -1502,22 +1578,25 @@ export default function TeslaTripPlanner() {
}
}
if (!opts.silent && finalReply) {
if (!opts.silent && !opts.prefetch && finalReply) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: finalReply }]);
}
if (lastPartialItinerary && !opts.silent) {
if (lastPartialItinerary && !opts.silent && !opts.prefetch) {
toast.success('Grok finished your route');
} else if (opts.variant && lastSelectedVariant) {
} else if (opts.variant && !opts.prefetch && lastSelectedVariant) {
toast.success(`Switched to ${lastSelectedVariant} route`);
}
} catch (err: any) {
console.error('[TeslaTrip] Grok stream failed:', err);
if (!opts.silent) {
if (!opts.silent && !opts.prefetch) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
}
} finally {
setThinking(false);
setVariantSwitching(false);
if (!opts.prefetch) {
setThinking(false);
setVariantSwitching(false);
setThinkingMessage('');
}
}
// touch unused refs so eslint stays quiet (we keep them as breadcrumbs)
@@ -1847,6 +1926,50 @@ export default function TeslaTripPlanner() {
</div>
</div>
)}
{/* Thinking overlay on the map (when Grok is planning but nothing rendered yet) */}
{allStops.length === 0 && thinking && (
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div
className="text-center max-w-md px-6 py-5 rounded-2xl pointer-events-auto"
style={{
background: 'rgba(20,20,24,0.86)',
border: '1px solid var(--gd-border-2)',
backdropFilter: 'blur(14px)',
boxShadow: '0 12px 40px rgba(0,0,0,0.4)',
}}
>
<div className="flex items-center justify-center gap-2 mb-3">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
</div>
<div className="text-[11px] tracking-[0.15em] uppercase" style={{ color: 'var(--gd-red)' }}>
Grok is planning your route
</div>
</div>
{thinkingMessage ? (
<div
key={thinkingMessage}
className="text-[13px] leading-snug min-h-[40px]"
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
>
{thinkingMessage}
</div>
) : (
<div className="space-y-2 min-h-[40px]">
<SkeletonRow widthPct={92} />
<SkeletonRow widthPct={78} />
<SkeletonRow widthPct={85} />
</div>
)}
<div className="text-[10.5px] mt-3 tracking-wider uppercase" style={{ color: 'var(--gd-text-3)' }}>
Stops will appear as soon as they're chosen
</div>
</div>
</div>
)}
</div>
{/* Stops rail */}
@@ -1978,13 +2101,33 @@ export default function TeslaTripPlanner() {
)}
{thinking && (
<div className="mt-4 flex items-center gap-2 px-2 py-2 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
<div className="mt-4 px-3 py-2.5 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="flex items-center gap-2 mb-1">
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
</div>
<div className="text-[10px] tracking-wider uppercase" style={{ color: 'var(--gd-red)' }}>
{itinerary.days.length > 0 ? 'Refining your route' : 'Grok is planning your route'}
</div>
</div>
<div className="text-[11px] tracking-wider" style={{ color: 'var(--gd-red)' }}>GROK IS PLANNING YOUR ROUTE...</div>
{thinkingMessage && (
<div
className="text-[12px] leading-snug mt-1.5 line-clamp-3"
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
key={thinkingMessage}
>
{thinkingMessage}
</div>
)}
{!thinkingMessage && itinerary.days.length === 0 && (
<div className="mt-2 space-y-1.5">
<SkeletonRow widthPct={92} />
<SkeletonRow widthPct={78} />
<SkeletonRow widthPct={85} />
</div>
)}
</div>
)}
@@ -2053,8 +2196,8 @@ function ModalShell({
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 grid place-items-center p-6"
style={{ background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
className="fixed inset-0 grid place-items-center p-6"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
@@ -2519,8 +2662,8 @@ function AddDetourOverlay({
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex justify-center items-start px-6 pt-[90px]"
style={{ background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
className="fixed inset-0 flex justify-center items-start px-6 pt-[90px]"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
@@ -2819,10 +2962,11 @@ function VehicleSelectorPanel({
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
<div
className="fixed z-50 overflow-hidden"
className="fixed overflow-hidden"
style={{
zIndex: 9999,
left, top, width: panelWidth, maxHeight: '78vh',
background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)',