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:
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user