mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
3c3a228737
- 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
298 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|