Files
TREK/client/src/components/Assistant/AssistantWindow.tsx
T
Maurice 3c3a228737 fix(assistant): lint, types and tests for the CI round
- agent loop and decisions without await in loops: steps as recursion,
  calls and applied changes in order through one promise chain, the
  independent reads and claims in parallel; part mapping in its own helpers
- place card, header and weather strip without refs or components created
  in render; long hooks and components split; state reset during render
- trip notes only in a turn's tool session, never an undo's; map view
  reported only while the assistant can run
- place strip scrolls through the strip's own smooth scroll, no physical side
- tests follow the raised limits and the new card fields; toasts read through
  a mocked useToast instead of a window global; baselines lowered where the
  counts fell
2026-10-21 22:29:58 +02:00

298 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);
const [wasOpen, setWasOpen] = useState(open);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one asks again.
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setPicked(null);
}
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>
);
}