mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
8433251c86
The welcome chose the journal's words whenever the Studio had not registered as the chat's host, which also hit the Studio's own panel before (or without) that registration. A journey's chat now speaks of the journal only where the window or the phone's chat screen say they show it for its journal; everywhere else, the Studio included, it stays the book's. AssistantWindow had gone past the complexity limit choosing between the planner's trip, the journal on screen, a picked chat and the home chat; that choice now lives in windowChat.
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import './assistant.css';
|
|
|
|
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
|
|
import type { WindowRect } from '../../hooks/useDragResize';
|
|
import { useTranslation } from '../../i18n';
|
|
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
|
|
import { useAssistantStore } from '../../store/assistantStore';
|
|
import AssistantFrameEdges from './AssistantFrameEdges';
|
|
import AssistantHeader from './AssistantHeader';
|
|
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
|
|
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
|
|
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
|
|
import { plannerSearchOf } from './assistantShowOnMap';
|
|
import type { AssistantChatTrip } from './assistantViewContext';
|
|
import type { EntityTarget } from './cards/cardTypes';
|
|
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
|
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.
|
|
*
|
|
* Outside a trip it opens on the home chat, which knows all of the user's
|
|
* trips; its head lists them to go on in one trip's own chat, and a trip's
|
|
* chat opened that way has the way back home in its head. On a journey's
|
|
* page it opens on that journal's chat, the journey's conversation it shares
|
|
* with TREK Studio.
|
|
*
|
|
* Mounted once for the whole app; it shows nothing until the assistant has a
|
|
* connection this user can use.
|
|
*/
|
|
|
|
type WindowTrip = AssistantChatTrip;
|
|
|
|
interface AssistantWindowProps {
|
|
/** The trip on screen; null outside a trip, where the window opens on the home chat. */
|
|
trip: WindowTrip | null;
|
|
/** Opens what a card or an applied change points at, from the trip on screen. */
|
|
onOpen?: (target: EntityTarget) => void;
|
|
/** Selects a day in the planner on screen. */
|
|
onFocusDay?: (dayId: number) => void;
|
|
/** Brings the planner's map on screen, from whichever of its tabs is open. */
|
|
onRevealMap?: () => 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';
|
|
|
|
/** 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,
|
|
onOpen,
|
|
onFocusDay,
|
|
onRevealMap,
|
|
onOpenTripChat,
|
|
tripSwitch,
|
|
}: {
|
|
trip: WindowTrip;
|
|
hidden: boolean;
|
|
onOpen?: (target: EntityTarget) => void;
|
|
onFocusDay?: (dayId: number) => void;
|
|
onRevealMap?: () => void;
|
|
onOpenTripChat?: (trip: WindowTrip, question: string | null) => void;
|
|
tripSwitch?: Parameters<typeof AssistantHeader>[0]['tripSwitch'];
|
|
}) {
|
|
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);
|
|
const frame = useAssistantFrame(WINDOW_STORAGE);
|
|
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 ${FRAME_CLASS} ${hidden ? 'hidden' : 'flex'} ${
|
|
frame.active ? 'select-none' : 'trek-assistant-settle'
|
|
}`}
|
|
>
|
|
<AssistantFrameEdges frame={frame} />
|
|
<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}
|
|
tripSwitch={tripSwitch}
|
|
// 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'}
|
|
onOpen={onOpen}
|
|
onFocusDay={onFocusDay}
|
|
onRevealMap={onRevealMap}
|
|
onOpenTripChat={onOpenTripChat}
|
|
// The window never shows the Studio: a journey's chat in it is the journal's.
|
|
journal={assistantScopeOf(trip.id).kind === 'journey'}
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Which chat the window shows: the page's own (the trip in the planner, else
|
|
* the journal on screen), else the one picked, else the home chat. `onScreen`
|
|
* when it is the page's own, `inPlanner` when that page is the planner.
|
|
*/
|
|
function windowChat(
|
|
trip: WindowTrip | null,
|
|
journal: WindowTrip | null,
|
|
picked: WindowTrip | null,
|
|
homeChat: WindowTrip
|
|
) {
|
|
const routeChat = trip ?? journal;
|
|
const current = routeChat ?? picked ?? homeChat;
|
|
const onScreen = !!routeChat && current.id === routeChat.id;
|
|
return { routeChat, current, onScreen, inPlanner: onScreen && trip !== null };
|
|
}
|
|
|
|
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: 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);
|
|
const home = useAssistantHome(setPicked);
|
|
|
|
useEffect(() => {
|
|
void loadStatus();
|
|
}, [loadStatus]);
|
|
useAssistantHotkey(available, toggle);
|
|
|
|
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
|
if (open !== wasOpen) {
|
|
setWasOpen(open);
|
|
if (!open) setPicked(null);
|
|
}
|
|
|
|
const journal = useRouteJournalChat();
|
|
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
|
|
const atHome = current.id === HOME_CHAT_KEY;
|
|
// A change in a trip the planner does not show is shown by opening that trip (a journal, its page); a tab opens on it.
|
|
const openPicked = useCallback(
|
|
(target: EntityTarget) => {
|
|
if (target.kind === 'page') void navigate(target.path);
|
|
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
|
},
|
|
[navigate, picked]
|
|
);
|
|
// The home chat has no trip to show anything in; another page it simply opens.
|
|
const openFromHome = useCallback(
|
|
(target: EntityTarget) => {
|
|
if (target.kind === 'page') void navigate(target.path);
|
|
},
|
|
[navigate]
|
|
);
|
|
const goHome = useCallback(() => setPicked(null), []);
|
|
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
|
|
const openOnScreen = useCallback(
|
|
(target: EntityTarget) => {
|
|
if (target.kind === 'page' && routeChat) setPicked(routeChat);
|
|
onOpen?.(target);
|
|
},
|
|
[onOpen, routeChat]
|
|
);
|
|
|
|
// The round button's hint dot reads a trip's plan; a journal has none.
|
|
const launcherTrip = trip?.id ?? null;
|
|
if (!available) return null;
|
|
if (!open) return routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
|
|
|
|
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
|
|
if (atHome) openTarget = openFromHome;
|
|
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
|
|
if (atHome) tripSwitch = { onPick: setPicked };
|
|
else if (!onScreen) tripSwitch = { onHome: goHome };
|
|
return (
|
|
<>
|
|
<ChatWindow
|
|
trip={current}
|
|
hidden={minimized}
|
|
onOpen={openTarget}
|
|
// Only the planner on screen has days to select and a map to bring back.
|
|
onFocusDay={inPlanner ? onFocusDay : undefined}
|
|
onRevealMap={inPlanner ? onRevealMap : undefined}
|
|
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
|
tripSwitch={tripSwitch}
|
|
/>
|
|
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
|
{minimized && <AssistantLauncher variant="floating" tripId={launcherTrip} focusOnMount />}
|
|
</>
|
|
);
|
|
}
|