Files
TREK/client/src/components/Assistant/AssistantWindow.tsx
T
Maurice 123e27643d feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat
The three AI addons share one tile in the integration column; a dialog holds
the connection, the assistant's limits and AI parsing, which now reads with
the assistant's connection unless an install keeps its own. Existing parsing
configs stay where they are and keep winning.

The chat sees the map on screen, offers next questions under an answer,
shows its live step in the window head, previews waiting changes inside the
day list, checks whether a day still works, folds a multi-day plan into a
week grid, keeps per-trip notes and can estimate costs. An empty or cut-off
answer gets one push instead of ending silently, and the assistant's own
step and size limits are raised.
2026-10-22 21:30:26 +00:00

296 lines
12 KiB
TypeScript

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<Element | null>(typeof document !== 'undefined' ? document.activeElement : null);
const sectionRef = useRef<HTMLElement>(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<HTMLElement>) => {
// 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 (
<section
ref={sectionRef}
role="dialog"
aria-label={t('assistant.window.title')}
// Hidden, not unmounted, while minimized: the answer being written keeps coming in.
hidden={hidden}
onKeyDown={onKeyDown}
style={style}
className={`trek-assistant-pop fixed z-[var(--z-nav)] ${hidden ? 'hidden' : 'flex'} flex-col overflow-visible rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150 ${
frame.active ? 'select-none' : 'trek-assistant-settle'
}`}
>
{EDGES.map(({ edge, className, cursor }) => (
<div
key={edge}
aria-hidden="true"
{...frame.edgeProps(edge)}
className={`absolute z-10 touch-none ${className}`}
style={{ cursor: edgeCursor(cursor, rtl) }}
/>
))}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
<AssistantHeader
tripId={trip.id}
thread={thread}
tripTitle={trip.title}
dragProps={frame.handleProps}
// Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again.
onClose={() => setMinimized(true)}
/>
<AssistantSurfaceLazy
tripId={trip.id}
tripTitle={trip.title}
variant="window"
density={width < ROOMY_WIDTH ? 'compact' : 'roomy'}
onShowOnMap={onShowOnMap}
onFocusDay={onFocusDay}
/>
</div>
</section>
);
}
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<WindowTrip | null>(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 ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
// 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 (
<>
<ChatWindow
trip={current}
hidden={minimized}
onShowOnMap={showOnMap}
// Only the planner on screen has days to select.
onFocusDay={onScreen ? onFocusDay : undefined}
/>
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
</>
);
}
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
const { t } = useTranslation();
const close = useAssistantStore((s) => s.close);
return (
<section
role="dialog"
aria-labelledby="assistant-picker-title"
onKeyDown={(e) => {
if (e.key === 'Escape' && !e.defaultPrevented) close();
}}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
>
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
{t('assistant.picker.title')}
</h2>
<button
type="button"
onClick={close}
aria-label={t('common.close')}
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={16} aria-hidden="true" />
</button>
</header>
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
</section>
);
}