import { Ellipsis, History, House, Lightbulb, LightbulbOff, Luggage, NotebookPen, Plus, Settings, Trash2, X, } from 'lucide-react'; import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from '../../i18n'; import { assistantScopeOf } from '../../store/assistantChatKey'; import { PopoverItem } from '../Packing/PackingPopover'; import ConfirmDialog from '../shared/ConfirmDialog'; import { ContextMenu, useContextMenu } from '../shared/ContextMenu'; import { NEUTRAL_TINT } from '../shared/DialogShell'; import Tooltip from '../shared/Tooltip'; import { PopoverListPanel } from '../shared/dialogParts'; import { usePopoverList } from '../shared/usePopoverList'; import AssistantMemoryDialog from './AssistantMemoryDialog'; import AssistantTripPicker from './AssistantTripPicker'; import { AI_SETTINGS_PATH } from './assistantParts'; import type { AssistantChatTrip } from './assistantViewContext'; import { useAssistantHintsSetting } from './useAssistantHints'; import { useAssistantLiveStep } from './useAssistantLiveStep'; import type { useAssistantThread } from './useAssistantThread'; import { useTripMemory } from './useTripMemory'; // The round, raised buttons of TREK's dialog heads. const ICON_BUTTON = 'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content'; interface AssistantHeaderProps { tripId: number; thread: ReturnType; tripTitle: string; onClose: () => void; /** Moves the window: spread on the title, which is the part of the header to grab. */ dragProps: HTMLAttributes; /** * The way between the home chat and one trip's: the home chat picks a trip * (`onPick`), a trip's chat opened from outside it goes back (`onHome`). */ tripSwitch?: TripSwitch; } interface TripSwitch { onPick?: (trip: AssistantChatTrip) => void; onHome?: () => void; } /** * The window's head, in the look of TREK's dialog heads: a tinted band with * a small line naming the assistant over the conversation's name; the band is where the window is * grabbed to move it. On the end side the recent chats (a new chat starts at * the top of that list), more and close, as round raised buttons. Closing * keeps the conversation, and an answer being written goes on. The model is not picked here; * that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button. */ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps, tripSwitch, }: AssistantHeaderProps) { const { t, locale } = useTranslation(); const live = useAssistantLiveStep(tripId); const memory = useTripMemory(tripId); // Trip notes belong to a trip: a journey's turns never read them (assistant-scope.ts turnOf). const tripChat = assistantScopeOf(tripId).kind === 'trip'; const [memoryOpen, setMemoryOpen] = useState(false); const navigate = useNavigate(); const menu = useContextMenu(); const moreRef = useRef(null); const [confirming, setConfirming] = useState(false); const threads = thread.threads ?? []; const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1); const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history; const hints = useAssistantHintsSetting(); const openHistory = () => { if (!history.open) thread.refreshThreads(); history.toggle(); }; const pick = (run: () => void) => { history.close(); run(); }; // Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys // go on reaching the window; a click or a scroll elsewhere leaves the focus where it went. const closeMenu = () => { const fromMenu = document.activeElement?.closest('[role="menu"]') != null; menu.close(); if (fromMenu) moreRef.current?.focus(); }; const openMenu = (e: MouseEvent) => menu.open( e, [ { label: t('assistant.history.new'), icon: Plus, disabled: thread.threadId === null && !thread.isRunning, onClick: thread.newThread, }, { label: t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn'), icon: hints.on ? LightbulbOff : Lightbulb, onClick: hints.toggle, }, { label: t('assistant.window.settings'), icon: Settings, onClick: () => void navigate(AI_SETTINGS_PATH) }, { label: t('assistant.window.deleteThread'), icon: Trash2, danger: true, disabled: thread.threadId === null || thread.isRunning, onClick: () => setConfirming(true), }, ], true ); return (
{thread.isRunning ? ( // While it works, the line over the title says what it does right now.

{live?.text ?? t('assistant.step.working')}

) : (

{t('assistant.launcher.tooltip')}

)}

{thread.title ?? tripTitle}

{/* Buttons are no place to start a drag from. */}
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > {tripSwitch && } {tripChat && ( )}
} label={t('assistant.history.new')} onClick={() => pick(thread.newThread)} /> {threads.length === 0 ? (

{t('assistant.history.empty')}

) : ( threads.map((th) => ( pick(() => thread.openThread(th.id))} trailing={ {new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })} } /> )) )} {thread.retentionNote && (

{thread.retentionNote}

)}
{memoryOpen && setMemoryOpen(false)} />} setConfirming(false)} onConfirm={() => { setConfirming(false); void thread.removeThread(); }} title={t('assistant.window.deleteThread')} message={t('assistant.window.deleteConfirm')} confirmLabel={t('common.delete')} />
); } /** * Between the home chat and a trip's: from the home chat a list of the user's * trips (the running and the next one first) opens that trip's chat in the * window; from a trip's chat opened outside it, one press goes back home. */ function TripSwitchButton({ onPick, onHome }: TripSwitch) { const { t } = useTranslation(); const list = usePopoverList(0, { estimatedHeight: 360 }); const { open, triggerRef, listId, toggle, close } = list; if (onHome) { return ( ); } if (!onPick) return null; return ( <> { close(); onPick({ id, title }); }} /> ); }