import './assistant.css'; import type { AssistantApplied } from '@trek/shared'; import { Sparkles, X } from 'lucide-react'; import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { type ResizeEdge, type WindowRect, type WindowSize, useDragResize } from '../../hooks/useDragResize'; import { useTranslation } from '../../i18n'; import { useAssistantStore } from '../../store/assistantStore'; import AssistantHeader from './AssistantHeader'; import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher'; import AssistantSurfaceLazy from './AssistantSurfaceLazy'; import AssistantTripPicker from './AssistantTripPicker'; import { useAssistantHotkey } from './useAssistantHotkey'; import { useAssistantThread } from './useAssistantThread'; /** * The assistant on the desktop: a glass window floating over the app, moved * by its head, resized from any edge or corner, kept inside the screen. It * remembers where it was. A double click on the head switches between the * compact window and a large one; let go near a corner and it settles there; * Alt+arrows move it and Alt+Shift+arrows resize it from the keyboard. * * The head's close button only hides it: the conversation goes on behind it, * and the planner shows the round button it opens from again, breathing while * an answer is still being written. Escape stops an answer first, then * closes, and the focus goes back to where it was. * * Mounted once for the whole app; it shows nothing until the assistant has a * connection this user can use. */ interface WindowTrip { id: number; title: string; } interface AssistantWindowProps { /** The trip on screen; null outside a trip, where the window asks which one first. */ trip: WindowTrip | null; /** Shows an applied change in the planner on screen. */ onShowOnMap?: (applied: AssistantApplied) => void; /** Selects a day in the planner on screen. */ onFocusDay?: (dayId: number) => void; } /** Narrower than this, the chat lays its cards out compact. */ const ROOMY_WIDTH = 480; /** Where the window's place and size are kept between visits. */ const WINDOW_STORAGE = 'trek.assistantWindow'; const MIN_SIZE: WindowSize = { width: 340, height: 420 }; const DEFAULT_SIZE: WindowSize = { width: 400, height: 600 }; /** Never closer to the screen's edge than this. */ const MARGIN = 16; /** The default distance from the edges, and how near a corner a released window snaps into it. */ const GAP = 24; const expandedSize = (viewport: WindowSize): WindowSize => ({ width: Math.min(720, viewport.width - 2 * MARGIN), height: Math.round(viewport.height * 0.8), }); const EDGES: { edge: ResizeEdge; className: string; cursor: 'ns' | 'ew' | 'corner-start' | 'corner-end' }[] = [ { edge: 'n', className: 'inset-x-3 -top-1 h-2', cursor: 'ns' }, { edge: 's', className: 'inset-x-3 -bottom-1 h-2', cursor: 'ns' }, { edge: 'start', className: 'inset-y-3 -start-1 w-2', cursor: 'ew' }, { edge: 'end', className: 'inset-y-3 -end-1 w-2', cursor: 'ew' }, { edge: 'n-start', className: '-start-1 -top-1 h-4 w-4', cursor: 'corner-start' }, { edge: 's-end', className: '-bottom-1 -end-1 h-4 w-4', cursor: 'corner-start' }, { edge: 'n-end', className: '-end-1 -top-1 h-4 w-4', cursor: 'corner-end' }, { edge: 's-start', className: '-bottom-1 -start-1 h-4 w-4', cursor: 'corner-end' }, ]; function edgeCursor(kind: (typeof EDGES)[number]['cursor'], rtl: boolean): string { if (kind === 'ns') return 'ns-resize'; if (kind === 'ew') return 'ew-resize'; // The top-start corner is the top left in a left-to-right page and the top right in Arabic. return (kind === 'corner-start') !== rtl ? 'nwse-resize' : 'nesw-resize'; } /** The fixed navbar's height (`--nav-h`, a calc() and 0 where there is none), measured. */ function navbarHeight(): number { if (typeof document === 'undefined') return 0; const probe = document.createElement('div'); probe.style.cssText = 'position:fixed;visibility:hidden;height:var(--nav-h, 0px)'; document.body.appendChild(probe); const height = probe.offsetHeight; probe.remove(); return height; } /** Where the floating button's middle sits inside the window, so the window grows out of it. */ function originFrom(rect: WindowRect, rtl: boolean): string { const fromStart = window.innerWidth - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.x; const x = rtl ? rect.width - fromStart : fromStart; const y = window.innerHeight - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.y; return `${Math.round(x)}px ${Math.round(y)}px`; } function ChatWindow({ trip, hidden, onShowOnMap, onFocusDay, }: { trip: WindowTrip; hidden: boolean; onShowOnMap?: (applied: AssistantApplied) => void; onFocusDay?: (dayId: number) => void; }) { const { t } = useTranslation(); const thread = useAssistantThread(trip.id); const close = useAssistantStore((s) => s.close); const setMinimized = useAssistantStore((s) => s.setMinimized); const setWindowRect = useAssistantStore((s) => s.setWindowRect); // The navbar sits on the same layer and later in the page: the head never goes under it. const [topInset] = useState(navbarHeight); const frame = useDragResize({ storageKey: WINDOW_STORAGE, minSize: MIN_SIZE, defaultSize: DEFAULT_SIZE, expandedSize, margin: MARGIN, gap: GAP, topInset, }); const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; const [origin] = useState(() => originFrom(frame.rect, rtl)); const returnFocus = useRef(typeof document !== 'undefined' ? document.activeElement : null); const sectionRef = useRef(null); const wasHidden = useRef(hidden); const { x, y, width, height } = frame.rect; // The map frames its view around the window: it learns where the window // settled after a move or a resize, not every step of the drag. useEffect(() => { if (hidden) setWindowRect(null); else if (!frame.active) setWindowRect({ x, y, width, height }); }, [hidden, frame.active, x, y, width, height, setWindowRect]); useEffect(() => () => setWindowRect(null), [setWindowRect]); // Back to whatever had the focus before the window opened. useEffect(() => { const target = returnFocus.current; return () => { if (target instanceof HTMLElement && target.isConnected) target.focus(); }; }, []); // Restored from the pill: back into the composer, where the user left off. useEffect(() => { if (wasHidden.current && !hidden) sectionRef.current?.querySelector('textarea')?.focus(); wasHidden.current = hidden; }, [hidden]); const onKeyDown = (e: KeyboardEvent) => { // Keys from a portal (the header's menu) bubble here through React; they are not the window's. if (!e.currentTarget.contains(e.target as Node)) return; frame.onKeyDown(e); if (e.key !== 'Escape' || e.defaultPrevented) return; e.preventDefault(); if (thread.isRunning) thread.stop(); else close(); }; const style: CSSProperties = { insetInlineStart: frame.rect.x, top: frame.rect.y, width: frame.rect.width, height: frame.rect.height, transformOrigin: origin, }; return ( ); } export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: AssistantWindowProps) { const navigate = useNavigate(); const available = useAssistantStore((s) => s.status?.available === true); const open = useAssistantStore((s) => s.window.open); const minimized = useAssistantStore((s) => s.window.minimized); const loadStatus = useAssistantStore((s) => s.loadStatus); const toggle = useAssistantStore((s) => s.toggle); const [picked, setPicked] = useState(null); useEffect(() => { void loadStatus(); }, [loadStatus]); useAssistantHotkey(available, toggle); // A trip picked outside the planner is only for this opening; the next one asks again. useEffect(() => { if (!open) setPicked(null); }, [open]); const current = trip ?? picked; const showPicked = useCallback(() => { if (picked) void navigate(`/trips/${picked.id}`); }, [navigate, picked]); if (!available) return null; if (!open) return trip ? : null; if (!current) return ; // A change in a trip the planner does not show is shown by opening that trip. const onScreen = !!trip && current.id === trip.id; const showOnMap = onScreen ? onShowOnMap : showPicked; return ( <>