Files
TREK/client/src/components/Assistant/AssistantHeader.tsx
T
Maurice d1b8cb20a3 feat(assistant): found places open the place form, notes kept on its own, city walk stops checked off
- Found places: the strip shows only when the answer names one of them, the
  named ones first. A click on a card, or "Add to trip", opens the planner's
  place form filled in with the place on the day in focus, its Google details
  (photos, hours, description) loading beside it; "+ Day" and the day grid
  are gone.
- Trip notes: remember_for_trip runs at once without a card. The assistant
  keeps lasting facts the user mentions (who travels, needs, likes, budget,
  pace) on its own and offers the unsure ones as a follow-up; the chat shows
  "Noted for this trip: ..." with the notes a tap away, and reads them again
  so the dialog never saves over the new note.
- City walks: set_city_walk_visit checks a station off on its day (visited,
  skipped, or the mark taken back), with day_edit as in the day plan, a
  change card naming the station and its walk, and an undo that restores the
  status before while the station is as the change left it.
- Card heads are a step smaller (32px tile, smaller title and count), and the
  dot beside "Working on it" in the header is gone.
2026-10-23 18:21:54 +02:00

221 lines
8.6 KiB
TypeScript

import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, 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 { AI_SETTINGS_PATH } from './assistantParts';
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<typeof useAssistantThread>;
tripTitle: string;
onClose: () => void;
/** Moves the window: spread on the title, which is the part of the header to grab. */
dragProps: HTMLAttributes<HTMLElement>;
}
/**
* 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 }: 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 [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();
};
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 (
<header
className="flex flex-shrink-0 items-center gap-2 border-b border-edge-faint py-2.5 pe-2.5 ps-4"
style={{ background: NEUTRAL_TINT }}
>
<div
{...dragProps}
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
>
<div className="min-w-0 flex-1">
{thread.isRunning ? (
// While it works, the line over the title says what it does right now.
<p role="status" className="m-0 flex min-w-0 items-center text-caption font-medium">
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
</p>
) : (
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
{t('assistant.launcher.tooltip')}
</p>
)}
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
<bdi>{thread.title ?? tripTitle}</bdi>
</h2>
</div>
</div>
{/* Buttons are no place to start a drag from. */}
<div
className="flex flex-shrink-0 items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{tripChat && (
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button
type="button"
onClick={() => setMemoryOpen(true)}
aria-label={t('assistant.memory.title')}
className={`relative ${ICON_BUTTON}`}
>
<NotebookPen size={13} aria-hidden="true" />
{memory.has && (
<span
aria-hidden="true"
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
/>
)}
</button>
</Tooltip>
)}
<Tooltip label={t('assistant.history.open')}>
<button
ref={historyTrigger}
type="button"
onClick={openHistory}
aria-label={t('assistant.history.open')}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyListId : undefined}
className={ICON_BUTTON}
>
<History size={13} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label={t('assistant.window.menu')}>
<button
type="button"
onClick={openMenu}
aria-label={t('assistant.window.menu')}
aria-haspopup="menu"
className={ICON_BUTTON}
>
<Ellipsis size={14} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label={t('common.close')}>
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ICON_BUTTON}>
<X size={14} aria-hidden="true" />
</button>
</Tooltip>
</div>
<PopoverListPanel list={history} label={t('assistant.history.open')} className="w-72">
<PopoverItem
icon={<Plus size={14} />}
label={t('assistant.history.new')}
onClick={() => pick(thread.newThread)}
/>
{threads.length === 0 ? (
<p className="m-0 px-2.5 py-2 text-caption text-content-muted">{t('assistant.history.empty')}</p>
) : (
threads.map((th) => (
<PopoverItem
key={th.id}
icon={null}
label={th.title}
active={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-shrink-0 text-caption text-content-faint">
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
</span>
}
/>
))
)}
{thread.retentionNote && (
<p className="m-0 border-t border-edge-faint px-2.5 pb-1 pt-2 text-caption text-content-faint">
{thread.retentionNote}
</p>
)}
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
void thread.removeThread();
}}
title={t('assistant.window.deleteThread')}
message={t('assistant.window.deleteConfirm')}
confirmLabel={t('common.delete')}
/>
</header>
);
}