mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(assistant): the assistant opens outside a trip, as a home chat about all trips
A third scope, home, next to trip and journey: threads that name neither a trip nor a journey (the CHECK now only forbids both), reached under /api/assistant by their owner only. A home turn reads the user's trips through list_trips into a short card (under way, next, later, behind, undated), offers every read (a trip's on the id the model names, checked by the tool with the user's rights) and only the writes that take no trip or journey (atlas, vacation planner, collections, tour library, settings), all held as change cards. open_trip_chat answers with a card that goes on in that trip's own chat with the question waiting in its composer. On the desktop the window opens on the home chat outside a trip; its head lists the trips to switch into and a switched trip chat has the way back. On the phone the home chat opens full screen from the dashboard head and the dock's More list, and the card opens the trip with its chat. Both run on useAssistantHome.
This commit is contained in:
+4
-3
@@ -67,6 +67,7 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag
|
||||
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
|
||||
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
|
||||
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
|
||||
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
|
||||
|
||||
// The ten phone screens are chunks of their own, alongside the desktop pages
|
||||
// rather than inside them. Each page used to import its M screen statically and
|
||||
@@ -410,10 +411,10 @@ export default function App() {
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
|
||||
{/* The phone has its own assistant screen inside the trip. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
||||
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && (
|
||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||
<Suspense fallback={null}><AssistantHost /></Suspense>
|
||||
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
|
||||
@@ -165,4 +165,46 @@ describe('assistantApi JSON calls', () => {
|
||||
await assistantApi.stop(journey);
|
||||
expect(hits).toEqual(['threads', 'undo', 'stop']);
|
||||
});
|
||||
|
||||
it('FE-AI-API-009: the home scope reaches the routes outside any trip', async () => {
|
||||
const home = { kind: 'home' } as const;
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
|
||||
const signal = new AbortController().signal;
|
||||
await assistantApi.chat(home, BODY, signal);
|
||||
await assistantApi.decide(home, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
|
||||
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/assistant/chat',
|
||||
'/api/assistant/threads/7/decisions',
|
||||
]);
|
||||
|
||||
const hits: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/assistant/threads', () => {
|
||||
hits.push('threads');
|
||||
return HttpResponse.json({ threads: [] });
|
||||
}),
|
||||
http.get('/api/assistant/threads/3', () => {
|
||||
hits.push('thread');
|
||||
return HttpResponse.json({ thread: { id: 3, title: 't', lastMessageAt: 'x' }, messages: [] });
|
||||
}),
|
||||
http.delete('/api/assistant/threads/3', () => {
|
||||
hits.push('delete');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.post('/api/assistant/tool-calls/:id/undo', () => {
|
||||
hits.push('undo');
|
||||
return HttpResponse.json({ undone: true });
|
||||
}),
|
||||
http.post('/api/assistant/stop', () => {
|
||||
hits.push('stop');
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
await assistantApi.threads(home);
|
||||
await assistantApi.thread(home, 3);
|
||||
await assistantApi.deleteThread(home, 3);
|
||||
await assistantApi.undo(home, 'c1');
|
||||
await assistantApi.stop(home);
|
||||
expect(hits).toEqual(['threads', 'thread', 'delete', 'undo', 'stop']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,14 +103,16 @@ const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
|
||||
|
||||
/**
|
||||
* What a conversation call is about: a trip's id (the planner's chat), or a
|
||||
* scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
|
||||
* decisions, undo and stop exist under both; files, found places, proposals
|
||||
* and sharing belong to a trip and take its id.
|
||||
* scope, which also reaches a journey's chat in TREK Studio and the home chat
|
||||
* outside any one trip. Threads, turns, decisions, undo and stop exist under
|
||||
* all three; files, found places, proposals and sharing belong to a trip and
|
||||
* take its id.
|
||||
*/
|
||||
export type AssistantTarget = number | AssistantScope;
|
||||
|
||||
function scopeBase(target: AssistantTarget): string {
|
||||
if (typeof target === 'number') return tripBase(target);
|
||||
if (target.kind === 'home') return '/assistant';
|
||||
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-reac
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import PlaceAvatar from '../shared/PlaceAvatar';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
@@ -45,7 +46,11 @@ export default function AssistantComposer({ tripId, tripTitle }: { tripId: numbe
|
||||
const empty = useAuiState((s) => s.composer.isEmpty);
|
||||
const { take, busy, accept, canAttach } = useAssistantAttach(tripId);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
// The home chat is about no one trip, so its placeholder names none.
|
||||
const fallback =
|
||||
tripId === HOME_CHAT_KEY
|
||||
? t('assistant.home.placeholder')
|
||||
: t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
const placeholder = useTypewriterPlaceholder(
|
||||
suggestions.map((s) => s.prompt),
|
||||
fallback,
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
Ellipsis,
|
||||
History,
|
||||
House,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Luggage,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -12,7 +24,9 @@ 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';
|
||||
@@ -29,6 +43,16 @@ interface AssistantHeaderProps {
|
||||
onClose: () => void;
|
||||
/** Moves the window: spread on the title, which is the part of the header to grab. */
|
||||
dragProps: HTMLAttributes<HTMLElement>;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +63,14 @@ interface AssistantHeaderProps {
|
||||
* 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) {
|
||||
export default function AssistantHeader({
|
||||
tripId,
|
||||
thread,
|
||||
tripTitle,
|
||||
onClose,
|
||||
dragProps,
|
||||
tripSwitch,
|
||||
}: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
@@ -122,6 +153,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
|
||||
{tripChat && (
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
@@ -218,3 +250,49 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip label={t('assistant.home.back')}>
|
||||
<button type="button" onClick={onHome} aria-label={t('assistant.home.back')} className={ICON_BUTTON}>
|
||||
<House size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (!onPick) return null;
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={t('assistant.home.switch')}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={t('assistant.home.switch')}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Luggage size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<PopoverListPanel list={list} label={t('assistant.picker.title')} className="flex w-72 flex-col">
|
||||
<AssistantTripPicker
|
||||
onPick={({ id, title }) => {
|
||||
close();
|
||||
onPick({ id, title });
|
||||
}}
|
||||
/>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ function useRouteTripId(): number | null {
|
||||
|
||||
/**
|
||||
* The desktop assistant, mounted once above the routes so a conversation
|
||||
* keeps going from page to page. Inside a trip it is about that trip; what a
|
||||
* keeps going from page to page. Inside a trip it is about that trip, outside
|
||||
* one it is the home chat about all of them (AssistantWindow); what a
|
||||
* card or an applied change opens goes through the planner's links: "Show on
|
||||
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
|
||||
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
|
||||
|
||||
@@ -101,6 +101,14 @@ describe('AssistantLauncher', () => {
|
||||
act(() => useAssistantStore.getState().open());
|
||||
expect(button).not.toHaveAttribute('aria-busy');
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-008: the floating button breathes for an answer of the home chat being written too', () => {
|
||||
const home = assistantChatKey({ kind: 'home' });
|
||||
const running = { ...chatOf(useAssistantStore.getState(), home), isRunning: true };
|
||||
useAssistantStore.setState({ status: STATUS, byTrip: { [home]: running } });
|
||||
render(<AssistantLauncher variant="floating" />);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shortcut', () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const LAUNCHER_SIZE = 48;
|
||||
/**
|
||||
* The way into the assistant: a button in the navbar, and in the planner a
|
||||
* round one floating at the bottom end of the screen. Both open and close the
|
||||
* window; outside a trip it opens on the trip picker. Nothing shows until the
|
||||
* window; outside a trip it opens on the home chat. Nothing shows until the
|
||||
* assistant has a connection this user can use: no setup prompt in its place.
|
||||
* The floating one carries a dot while the trip has a warning the user has
|
||||
* not been shown; opening the chat counts them as seen.
|
||||
@@ -35,11 +35,11 @@ export default function AssistantLauncher({
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened. Only a
|
||||
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened. A
|
||||
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on.
|
||||
const busy = useAssistantStore((s) =>
|
||||
!s.window.open || s.window.minimized
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey')
|
||||
: false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function AssistantSurface({
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
@@ -41,8 +42,9 @@ export default function AssistantSurface({
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Spinner } from '../shared/Spinner';
|
||||
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||
|
||||
/**
|
||||
* Outside a trip the assistant first asks which one: the chat, its history
|
||||
* and its changes all belong to one trip. The running and the next trip lead.
|
||||
* The user's trips to go on with in one trip's own chat, from the home chat's
|
||||
* head: that chat, its history and its changes belong to the trip. The
|
||||
* running and the next trip lead.
|
||||
*/
|
||||
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -28,20 +28,15 @@ export default function AssistantWelcome({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan.
|
||||
const book = useAssistantScope().kind === 'journey';
|
||||
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
||||
const scope = useAssistantScope().kind;
|
||||
const book = scope === 'journey';
|
||||
const hints = useAssistantHints(tripId, { withStays: true });
|
||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||
<EmptyState
|
||||
scene="chat"
|
||||
size={72}
|
||||
compact
|
||||
surface="var(--bg-elevated)"
|
||||
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
|
||||
/>
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
|
||||
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
|
||||
@@ -53,6 +48,12 @@ export default function AssistantWelcome({
|
||||
);
|
||||
}
|
||||
|
||||
/** The question over the empty chat, by what the chat is about. */
|
||||
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
|
||||
if (scope === 'journey') return t('journey.studio.assistant.welcome');
|
||||
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
|
||||
}
|
||||
|
||||
export function AssistantSuggestions({
|
||||
suggestions,
|
||||
layout,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-012
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-014
|
||||
*
|
||||
* The desktop window around the chat: when it shows at all, the button it
|
||||
* opens from, the trip picker outside a trip, closing that keeps the chat, and
|
||||
* opens from, the home chat outside a trip with its way into one trip's chat
|
||||
* and back, closing that keeps the chat, and
|
||||
* Escape, which stops an answer before it closes anything (and leaves the
|
||||
* header's menu to itself), and where the focus goes on the way; a trip's head
|
||||
* offers the notes its turns read. The geometry is
|
||||
@@ -36,12 +37,14 @@ vi.mock('./AssistantSurfaceLazy', () => ({
|
||||
density,
|
||||
onFocusDay,
|
||||
onOpen,
|
||||
onOpenTripChat,
|
||||
}: {
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
density?: string;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<p>
|
||||
@@ -66,6 +69,11 @@ vi.mock('./AssistantSurfaceLazy', () => ({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onOpenTripChat && (
|
||||
<button type="button" onClick={() => onOpenTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
|
||||
go on in Lisbon
|
||||
</button>
|
||||
)}
|
||||
<textarea aria-label="composer" />
|
||||
</div>
|
||||
),
|
||||
@@ -136,12 +144,36 @@ describe('AssistantWindow', () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
||||
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
||||
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||
expect(resume).toHaveBeenCalledWith(0);
|
||||
// The home chat has no trip notes.
|
||||
expect(screen.queryByRole('button', { name: 'Trip notes' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
|
||||
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to all trips' }));
|
||||
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-013: the home chat's card goes on in the trip's chat with the question waiting there", () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'go on in Lisbon' }));
|
||||
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
|
||||
// In a trip's chat there is no card to go on from.
|
||||
expect(screen.queryByRole('button', { name: 'go on in Lisbon' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-014: minimized outside a trip, the round button brings the home chat back', () => {
|
||||
seed({ open: true, minimized: true });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByText('chat 0 Your travels')).not.toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window.minimized).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
|
||||
@@ -238,6 +270,10 @@ describe('AssistantWindow', () => {
|
||||
<Location />
|
||||
</>
|
||||
);
|
||||
// From the home chat only another page opens.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import './assistant.css';
|
||||
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
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 } 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 AssistantTripPicker from './AssistantTripPicker';
|
||||
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { useAssistantHome } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
@@ -30,17 +31,18 @@ import { useAssistantThread } from './useAssistantThread';
|
||||
* 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.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
type WindowTrip = AssistantChatTrip;
|
||||
|
||||
interface AssistantWindowProps {
|
||||
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
||||
/** 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;
|
||||
@@ -70,12 +72,16 @@ function ChatWindow({
|
||||
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);
|
||||
@@ -150,6 +156,7 @@ function ChatWindow({
|
||||
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)}
|
||||
@@ -162,6 +169,7 @@ function ChatWindow({
|
||||
onOpen={onOpen}
|
||||
onFocusDay={onFocusDay}
|
||||
onRevealMap={onRevealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -177,19 +185,21 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
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 asks again.
|
||||
// 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 current = trip ?? picked;
|
||||
const current = trip ?? 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 tab opens on it.
|
||||
const openPicked = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
@@ -198,6 +208,14 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
},
|
||||
[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: the chat stays on it, as if it had been picked.
|
||||
const openOnScreen = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
@@ -209,52 +227,27 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
|
||||
if (!available) return null;
|
||||
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
||||
if (!current) return <PickerWindow onPick={setPicked} />;
|
||||
|
||||
const onScreen = !!trip && current.id === trip.id;
|
||||
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={onScreen ? onOpen && openOnScreen : openPicked}
|
||||
onOpen={openTarget}
|
||||
// Only the planner on screen has days to select and a map to bring back.
|
||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||
onRevealMap={onScreen ? onRevealMap : undefined}
|
||||
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
||||
tripSwitch={tripSwitch}
|
||||
/>
|
||||
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
||||
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
||||
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,6 +225,17 @@ const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
|
||||
|
||||
const MAX_SUGGESTIONS = 4;
|
||||
|
||||
/**
|
||||
* What the empty home chat offers, outside any one trip: questions across the
|
||||
* user's trips and about the parts of TREK that belong to none of them.
|
||||
*/
|
||||
export const HOME_SUGGESTIONS: readonly AssistantSuggestion[] = [
|
||||
{ id: 'homeNext', labelKey: 'assistant.suggest.homeNext' },
|
||||
{ id: 'homeCompare', labelKey: 'assistant.suggest.homeCompare' },
|
||||
{ id: 'homeAtlas', labelKey: 'assistant.suggest.homeAtlas' },
|
||||
{ id: 'homeVacay', labelKey: 'assistant.suggest.homeVacay' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The questions the empty chat offers: about the place or day in focus first,
|
||||
* then what fits the trip's phase. The label is also the prompt, so a tapped
|
||||
|
||||
@@ -34,6 +34,17 @@ export interface AssistantView {
|
||||
* version the trial ran on.
|
||||
*/
|
||||
bookVersion?: number | null;
|
||||
/**
|
||||
* Goes on in a trip's own chat with a question waiting there, from the home
|
||||
* chat's card that offers it; left out where no chat can be switched to.
|
||||
*/
|
||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
||||
}
|
||||
|
||||
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
|
||||
export interface AssistantChatTrip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const AssistantViewContext = createContext<AssistantView | null>(null);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { BUDGET_LOOK } from './budget/budgetLook';
|
||||
import type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes';
|
||||
import { COLLAB_LOOK } from './collab/collabLook';
|
||||
import { COLLECTION_LOOK } from './collection/collectionLook';
|
||||
import { HOME_LOOK } from './home/homeLook';
|
||||
import { JOURNEY_LOOK } from './journey/journeyLook';
|
||||
import { PACKING_LOOK } from './packing/packingLook';
|
||||
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
|
||||
@@ -170,6 +171,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
...PACKING_LOOK.steps,
|
||||
...BOOK_LOOK.steps,
|
||||
...BOOK_DESIGN_LOOK.steps,
|
||||
...HOME_LOOK.steps,
|
||||
};
|
||||
|
||||
/** Shown on the map by its day: a stop or a note lives on one. */
|
||||
@@ -234,6 +236,7 @@ export const CARD_LOOKS: { readonly [K in TrekCardKind]: CardLook<K> } = {
|
||||
...PACKING_LOOK.cards,
|
||||
...BOOK_LOOK.cards,
|
||||
...BOOK_DESIGN_LOOK.cards,
|
||||
...HOME_LOOK.cards,
|
||||
};
|
||||
|
||||
/** The look of the card in hand, typed for any kind. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BUDGET_VIEWS } from './budget/budgetViews';
|
||||
import type { CardView } from './cardTypes';
|
||||
import { COLLAB_VIEWS } from './collab/collabViews';
|
||||
import { COLLECTION_VIEWS } from './collection/collectionViews';
|
||||
import { HOME_VIEWS } from './home/homeViews';
|
||||
import { JOURNEY_VIEWS } from './journey/journeyViews';
|
||||
import { PACKING_VIEWS } from './packing/packingViews';
|
||||
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
|
||||
@@ -33,6 +34,7 @@ export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
|
||||
...PACKING_VIEWS,
|
||||
...BOOK_VIEWS,
|
||||
...BOOK_DESIGN_VIEWS,
|
||||
...HOME_VIEWS,
|
||||
};
|
||||
|
||||
/** The view of the card in hand, typed for any kind. */
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* FE-AI-TRIPCHAT-001 to FE-AI-TRIPCHAT-004
|
||||
*
|
||||
* The home chat's card that offers a trip's own chat: the trip under its head
|
||||
* with its dates, where it stands and its places, the question waiting there,
|
||||
* and the button that goes on in that chat, shown only where a chat can be
|
||||
* switched to.
|
||||
*/
|
||||
import type { TripChatCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { TripChatBlock } from './TripChatBlock';
|
||||
|
||||
const CARD: TripChatCard = {
|
||||
kind: 'tripChat',
|
||||
trip: { id: 5, title: 'Kyoto', startDate: '2026-11-02', endDate: '2026-11-09', phase: 'next', placeCount: 12 },
|
||||
question: 'What is still missing?',
|
||||
};
|
||||
|
||||
function entry(card: TripChatCard, toolCallId = 'c1'): CardEntry {
|
||||
return { toolCallId, card };
|
||||
}
|
||||
|
||||
function show(cards: CardEntry[], view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 0, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<TripChatBlock cards={cards} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TripChatBlock', () => {
|
||||
it('FE-AI-TRIPCHAT-001: shows the trip, where it stands, its places and the question', () => {
|
||||
show([entry(CARD)]);
|
||||
expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip chat')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ to /)).toBeInTheDocument();
|
||||
expect(screen.getByText('Next')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('What is still missing?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-TRIPCHAT-002: the button goes on in the trip's chat with the question", () => {
|
||||
const onOpenTripChat = vi.fn();
|
||||
show([entry(CARD)], { onOpenTripChat });
|
||||
fireEvent.click(screen.getByRole('button', { name: "Continue in this trip's chat" }));
|
||||
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 5, title: 'Kyoto' }, 'What is still missing?');
|
||||
});
|
||||
|
||||
it('FE-AI-TRIPCHAT-003: no button where no chat can be switched to, and a trip without dates says so', () => {
|
||||
const bare: TripChatCard = {
|
||||
kind: 'tripChat',
|
||||
trip: { id: 6, title: 'Someday', startDate: null, endDate: null, phase: 'undated', placeCount: null },
|
||||
question: null,
|
||||
};
|
||||
show([entry(bare)]);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No dates yet')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Next')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TRIPCHAT-004: two trips sit in one card, each with its own button; nothing to show draws nothing', () => {
|
||||
const onOpenTripChat = vi.fn();
|
||||
const other: TripChatCard = {
|
||||
...CARD,
|
||||
trip: { ...CARD.trip, id: 7, title: 'Oslo', phase: 'now', startDate: '2026-10-20', endDate: '2026-10-20' },
|
||||
question: null,
|
||||
};
|
||||
show([entry(CARD), entry(other, 'c2')], { onOpenTripChat, variant: 'fullscreen' });
|
||||
const buttons = screen.getAllByRole('button', { name: "Continue in this trip's chat" });
|
||||
expect(buttons).toHaveLength(2);
|
||||
fireEvent.click(buttons[1]);
|
||||
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 7, title: 'Oslo' }, null);
|
||||
expect(screen.getByText('Under way')).toBeInTheDocument();
|
||||
|
||||
const { container } = render(<TripChatBlock cards={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { TripChatCard, TripChatPhase } from '@trek/shared';
|
||||
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
|
||||
/**
|
||||
* The home chat's way into one trip's own chat: the trip under its head (its
|
||||
* dates, where it stands, how many places it holds), the question that waits
|
||||
* there ready to send, and the button that goes on in that chat. The button
|
||||
* shows only where a chat can be switched to; the window switches in place,
|
||||
* the phone opens the trip with its chat.
|
||||
*/
|
||||
|
||||
/** Where the trip stands, as a pill; a trip without dates says so on its line instead. */
|
||||
const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: string; tone: PillTone }>> = {
|
||||
now: { labelKey: 'assistant.card.tripChat.phase.now', tone: 'success' },
|
||||
next: { labelKey: 'assistant.card.tripChat.phase.next', tone: 'accent' },
|
||||
upcoming: { labelKey: 'assistant.card.tripChat.phase.upcoming', tone: 'info' },
|
||||
past: { labelKey: 'assistant.card.tripChat.phase.past', tone: 'neutral' },
|
||||
};
|
||||
|
||||
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { onOpenTripChat, variant } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const { trip, question } = card;
|
||||
const start = trip.startDate ?? trip.endDate;
|
||||
const end = trip.endDate ?? trip.startDate;
|
||||
let dates = t('assistant.card.tripChat.noDates');
|
||||
if (start && end) {
|
||||
dates =
|
||||
start === end
|
||||
? format.date(start)
|
||||
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
|
||||
}
|
||||
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
|
||||
return (
|
||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||
{first ? (
|
||||
<CardHeader
|
||||
icon={Luggage}
|
||||
tone="info"
|
||||
eyebrow={t('assistant.card.tripChat.eyebrow')}
|
||||
title={trip.title}
|
||||
meta={dates}
|
||||
>
|
||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||
</CardHeader>
|
||||
) : (
|
||||
<div className="mb-2.5">
|
||||
<bdi className="block truncate text-body font-bold text-content">{trip.title}</bdi>
|
||||
<div className="mt-0.5 text-caption text-content-muted">{dates}</div>
|
||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||
</div>
|
||||
)}
|
||||
{question && (
|
||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||
</p>
|
||||
)}
|
||||
{onOpenTripChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
|
||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||
}`}
|
||||
>
|
||||
{t('assistant.card.tripChat.open')}
|
||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pills under the trip's name: where it stands and how many places it holds. */
|
||||
function TripFacts({
|
||||
phase,
|
||||
placeCount,
|
||||
}: {
|
||||
phase: { labelKey: string; tone: PillTone } | null;
|
||||
placeCount: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!phase && !placeCount) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{phase && <SoftPill tone={phase.tone}>{t(phase.labelKey)}</SoftPill>}
|
||||
{!!placeCount && <WhiteBadge>{t('collections.placeCount', { count: placeCount })}</WhiteBadge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TripChatBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const trips = cards.flatMap((entry) => (entry.card.kind === 'tripChat' ? [entry.card] : []));
|
||||
if (trips.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.tripChat.label')}>
|
||||
{trips.map((card, index) => (
|
||||
<TripChatSection key={`${card.trip.id}-${index}`} card={card} first={index === 0} />
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PackLook } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* What the home chat (the assistant outside any one trip) brings to the
|
||||
* chat's pure look: the words its reads run under and the look of the card
|
||||
* that offers a trip's own chat. It has no verbs or entities of its own; the
|
||||
* changes it proposes are the atlas', the vacation planner's and the
|
||||
* collections'. A look stays pure, literals only.
|
||||
*/
|
||||
export const HOME_LOOK = {
|
||||
verbs: {},
|
||||
tools: {},
|
||||
steps: {
|
||||
list_trips: { stepKey: 'assistant.step.readingTrips' },
|
||||
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
|
||||
},
|
||||
entities: {},
|
||||
cards: {
|
||||
tripChat: {},
|
||||
},
|
||||
} satisfies PackLook<'tripChat'>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { TripChatBlock } from './TripChatBlock';
|
||||
|
||||
/**
|
||||
* How the home chat's kind draws: the trip it offers to go on with, beside
|
||||
* the answer. This file only maps the kind to its view; the component lives
|
||||
* in a sibling file, since a file that exports anything besides components
|
||||
* breaks fast refresh.
|
||||
*/
|
||||
export const HOME_VIEWS = {
|
||||
tripChat: { Block: TripChatBlock },
|
||||
} satisfies { [K in 'tripChat']: CardView<K> };
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* FE-AI-HOME-001 to FE-AI-HOME-004
|
||||
*
|
||||
* The home chat's shared hooks: its key and name, the way into one trip's own
|
||||
* chat with the question waiting there (a question already typed there
|
||||
* stays), the way in from the pages outside a trip, and the questions the
|
||||
* empty home chat offers.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { HOME_SUGGESTIONS } from './assistantModel';
|
||||
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
});
|
||||
|
||||
function Home({ switchTo }: { switchTo: (trip: { id: number; title: string }) => void }) {
|
||||
const home = useAssistantHome(switchTo);
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
chat {home.chat.id} {home.chat.title}
|
||||
</p>
|
||||
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
|
||||
ask
|
||||
</button>
|
||||
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, null)}>
|
||||
go
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Launcher() {
|
||||
const { available, open } = useAssistantHomeLauncher();
|
||||
return available ? (
|
||||
<button type="button" onClick={open}>
|
||||
open
|
||||
</button>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function Suggestions() {
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(0);
|
||||
return (
|
||||
<ul data-can-edit={String(canEdit)}>
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.id}>{s.prompt}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useAssistantHome', () => {
|
||||
it('FE-AI-HOME-001: names the home chat and keeps it under its own key', () => {
|
||||
render(<Home switchTo={vi.fn()} />);
|
||||
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-HOME-002: goes on in a trip's chat with the question waiting, and never over one typed there", () => {
|
||||
const switchTo = vi.fn();
|
||||
render(<Home switchTo={switchTo} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
|
||||
expect(switchTo).toHaveBeenCalledWith({ id: 9, title: 'Lisbon' });
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
|
||||
|
||||
useAssistantStore.getState().setDraft(9, 'My own words');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('My own words');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'go' }));
|
||||
expect(switchTo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAssistantHomeLauncher', () => {
|
||||
it('FE-AI-HOME-003: offers the way in only while the assistant can run, and opens the window', () => {
|
||||
const { rerender } = render(<Launcher />);
|
||||
expect(screen.queryByRole('button', { name: 'open' })).not.toBeInTheDocument();
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||
rerender(<Launcher />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open' }));
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the empty home chat', () => {
|
||||
it('FE-AI-HOME-004: offers questions across the trips, all of them, and holds nothing back', () => {
|
||||
render(<Suggestions />);
|
||||
const items = screen.getAllByRole('listitem').map((item) => item.textContent);
|
||||
expect(items).toHaveLength(HOME_SUGGESTIONS.length);
|
||||
expect(items).toContain('Which trip is next, and what is still open?');
|
||||
expect(items).toContain('How many countries have I visited?');
|
||||
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
* The home chat, the assistant outside any one trip, as the desktop window
|
||||
* and the phone screen both show it: the key its conversations are kept
|
||||
* under, the name its head carries, and the way into one trip's own chat.
|
||||
* That way leaves the question the home chat handed over waiting in the
|
||||
* trip's composer, ready to send, and then lets the view switch (the window
|
||||
* shows that trip's chat in place, the phone opens the trip with its chat).
|
||||
*/
|
||||
export function useAssistantHome(switchTo: (trip: AssistantChatTrip) => void) {
|
||||
const { t } = useTranslation();
|
||||
const setDraft = useAssistantStore((s) => s.setDraft);
|
||||
|
||||
const openTripChat = useCallback(
|
||||
(trip: AssistantChatTrip, question: string | null) => {
|
||||
// A question typed there already stays; the handed-over one only fills an empty composer.
|
||||
const typed = useAssistantStore.getState().byTrip[trip.id]?.draft ?? '';
|
||||
if (question && !typed.trim()) setDraft(trip.id, question);
|
||||
switchTo(trip);
|
||||
},
|
||||
[setDraft, switchTo]
|
||||
);
|
||||
|
||||
const title = t('assistant.home.title');
|
||||
const chat = useMemo((): AssistantChatTrip => ({ id: HOME_CHAT_KEY, title }), [title]);
|
||||
return { chat, openTripChat };
|
||||
}
|
||||
|
||||
/**
|
||||
* The way into the home chat on the pages outside a trip (the phone's
|
||||
* dashboard head and the dock's More list): offered only while the assistant
|
||||
* can run for this user, like every other way in.
|
||||
*/
|
||||
export function useAssistantHomeLauncher() {
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.open);
|
||||
return { available, open };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useJourneyStore } from '../../store/journeyStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { localIsoDate } from '../../utils/localDate';
|
||||
import { suggestionsFor, tripPhase } from './assistantModel';
|
||||
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel';
|
||||
import { useAssistantFocus } from './useAssistantFocus';
|
||||
|
||||
export interface ReadySuggestion {
|
||||
@@ -21,7 +21,9 @@ export interface ReadySuggestion {
|
||||
* (one picked from the dashboard) neither its dates nor its rights are at
|
||||
* hand, so it gets the general questions and none that would change it. A
|
||||
* journey's chat in TREK Studio gets none, since they all ask about a trip,
|
||||
* and its rights are the journey's role, as the Studio itself reads it.
|
||||
* and its rights are the journey's role, as the Studio itself reads it. The
|
||||
* home chat outside any one trip gets its own questions across the user's
|
||||
* trips; what it may change the server decides, so nothing is held back here.
|
||||
*/
|
||||
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,9 +37,11 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
|
||||
const journeyRole = useJourneyStore((s) =>
|
||||
scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined
|
||||
);
|
||||
const canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer';
|
||||
const home = scope.kind === 'home';
|
||||
const canEdit = tripChat ? !!trip && can('place_edit', trip) : home || (journeyRole ?? 'owner') !== 'viewer';
|
||||
|
||||
const suggestions = useMemo((): ReadySuggestion[] => {
|
||||
if (home) return HOME_SUGGESTIONS.map((s) => ({ id: s.id, prompt: t(s.labelKey) }));
|
||||
if (!tripChat) return [];
|
||||
const phase = trip
|
||||
? tripPhase({
|
||||
@@ -53,7 +57,7 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
|
||||
place: kind === 'place' ? label : null,
|
||||
day: kind === 'day' ? label : null,
|
||||
}).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) }));
|
||||
}, [tripChat, trip, hasPlaces, canEdit, kind, label, t]);
|
||||
}, [home, tripChat, trip, hasPlaces, canEdit, kind, label, t]);
|
||||
|
||||
return { suggestions, canEdit };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-007
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -22,6 +22,8 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import MBottomNav from './MBottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
@@ -97,4 +99,15 @@ describe('MBottomNav', () => {
|
||||
render(<MBottomNav />, { initialEntries: ['/vacay'] });
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => {
|
||||
const user = userEvent.setup();
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
|
||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { normalizeAppearance } from '@trek/shared'
|
||||
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
|
||||
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
|
||||
import MFab from './MFab'
|
||||
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
|
||||
|
||||
interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function MBottomNav() {
|
||||
// the dock yields the slot instead (see screenFabSlot below, #1811).
|
||||
const create = useCreateAction(true)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
// The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's.
|
||||
const assistant = useAssistantHomeLauncher()
|
||||
|
||||
// Close the popover when the route changes underneath it (browser back etc.).
|
||||
useEffect(() => setMoreOpen(false), [location.pathname])
|
||||
@@ -42,6 +45,11 @@ export default function MBottomNav() {
|
||||
|
||||
const isActive = (to: string) => isNavItemActive(location.pathname, to)
|
||||
const moreActive = moreItems.some(item => isActive(item.to))
|
||||
const moreRows = [
|
||||
...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []),
|
||||
...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })),
|
||||
]
|
||||
const hasMore = moreRows.length > 0
|
||||
|
||||
// The FAB gives way to a decorative logo slot on screens without an add
|
||||
// action (settings/admin, demo Z. 1372/1429).
|
||||
@@ -55,7 +63,7 @@ export default function MBottomNav() {
|
||||
|
||||
// Split so the raised centre slot sits dead centre; the More slot always
|
||||
// closes the right group.
|
||||
const slotCount = dockItems.length + (moreItems.length > 0 ? 1 : 0)
|
||||
const slotCount = dockItems.length + (hasMore ? 1 : 0)
|
||||
const splitAt = Math.ceil(slotCount / 2)
|
||||
const left = dockItems.slice(0, splitAt)
|
||||
const right = dockItems.slice(splitAt)
|
||||
@@ -94,11 +102,11 @@ export default function MBottomNav() {
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{moreItems.map(({ to, label, icon: Icon }) => (
|
||||
{moreRows.map(({ key, label, icon: Icon, go }) => (
|
||||
<button
|
||||
key={to}
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => { setMoreOpen(false); navigate(to) }}
|
||||
onClick={() => { setMoreOpen(false); go() }}
|
||||
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
|
||||
>
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
|
||||
@@ -136,7 +144,7 @@ export default function MBottomNav() {
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-around">
|
||||
{right.map(renderItem)}
|
||||
{moreItems.length > 0 && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen(v => !v)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { useAssistantHome } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
/**
|
||||
* The assistant on the phone outside a trip: the home chat, full screen over
|
||||
* the dashboard, Vacay, Atlas, the collections or the settings, opened from
|
||||
* the dashboard's head or the dock's More list. It knows all of the user's
|
||||
* trips; going on in one trip's own chat opens that trip with its chat on
|
||||
* screen and the question waiting in its composer. Another page a card points
|
||||
* at closes the chat and opens there.
|
||||
*
|
||||
* Inside a trip the trip's own screen (MAssistantScreen) shows its chat, so
|
||||
* this one stays away there. Nothing shows until the assistant has a
|
||||
* connection this user can use.
|
||||
*/
|
||||
export default function MAssistantHomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const inTrip = useMatch('/trips/:id/*') !== null;
|
||||
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 close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen takes over with the window still open, so its chat comes up there.
|
||||
const switchTo = useCallback((trip: AssistantChatTrip) => void navigate(`/trips/${trip.id}`), [navigate]);
|
||||
const home = useAssistantHome(switchTo);
|
||||
const hops = useMemo(
|
||||
(): MapHops => ({
|
||||
open: (target: EntityTarget) => {
|
||||
if (target.kind !== 'page') return;
|
||||
close();
|
||||
void navigate(target.path);
|
||||
},
|
||||
}),
|
||||
[close, navigate]
|
||||
);
|
||||
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
return (
|
||||
<AssistantScreen
|
||||
tripId={home.chat.id}
|
||||
tripTitle={home.chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={home.openTripChat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Trash2, Users,
|
||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MDancingTrek from '../../components/MDancingTrek'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import { useInAppNotificationStore } from '../../../store/inAppNotificationStore'
|
||||
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
|
||||
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
|
||||
import type { TripCardBadge } from '../../../api/client'
|
||||
import DemoBanner from '../../../components/Layout/DemoBanner'
|
||||
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
|
||||
@@ -55,6 +56,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
const unread = useInAppNotificationStore(s => s.unreadCount)
|
||||
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
||||
const [subOpen, setSubOpen] = useState(false)
|
||||
|
||||
// Plugin dashboard widgets + trip-card badges, mirroring the desktop page:
|
||||
@@ -219,6 +221,11 @@ export default function MDashboard(): React.ReactElement {
|
||||
</span>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1" />
|
||||
{assistantAvailable && (
|
||||
<MIconBtn ariaLabel={t('assistant.launcher.tooltip')} onClick={openAssistant}>
|
||||
<Sparkles size={18} strokeWidth={2} />
|
||||
</MIconBtn>
|
||||
)}
|
||||
<MIconBtn ariaLabel={t('notifications.title')} onClick={() => navigate('/notifications')}>
|
||||
<Bell size={18} strokeWidth={2} />
|
||||
{unread > 0 && (
|
||||
|
||||
@@ -17,6 +17,7 @@ import AssistantCompareSwitch from '../../../../components/Assistant/AssistantCo
|
||||
import AssistantMemoryDialog from '../../../../components/Assistant/AssistantMemoryDialog';
|
||||
import AssistantSurfaceLazy from '../../../../components/Assistant/AssistantSurfaceLazy';
|
||||
import { AI_SETTINGS_PATH } from '../../../../components/Assistant/assistantParts';
|
||||
import type { AssistantChatTrip } from '../../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../../components/Assistant/cards/cardTypes';
|
||||
import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
|
||||
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
|
||||
@@ -26,6 +27,7 @@ import { useAssistantThread } from '../../../../components/Assistant/useAssistan
|
||||
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { assistantScopeOf } from '../../../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import MDropdownPanel from '../../../components/MDropdownPanel';
|
||||
import MIconBtn from '../../../components/MIconBtn';
|
||||
@@ -72,14 +74,17 @@ function useBackCloses(open: boolean, onClose: () => void): void {
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
/** The ways the chat hands the screen to the map, a tab of the trip or another page. */
|
||||
interface MapHops {
|
||||
/**
|
||||
* The ways the chat hands the screen to the map, a tab of the trip or another
|
||||
* page. Outside a trip (the home chat) only another page can be opened.
|
||||
*/
|
||||
export interface MapHops {
|
||||
/** What a card or an applied change points at: selected on the map, a tab opened, a page navigated to. */
|
||||
open: (target: EntityTarget) => void;
|
||||
/** A day picked in the chat (its weather): the day is selected. */
|
||||
focusDay: (dayId: number) => void;
|
||||
focusDay?: (dayId: number) => void;
|
||||
/** Before the map moves to something the chat points at, such as a found place. */
|
||||
revealMap: () => void;
|
||||
revealMap?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +95,7 @@ interface MapHops {
|
||||
* the chat closes with it. Read through a ref, so the chat does not re-render
|
||||
* with every render of the trip screen.
|
||||
*/
|
||||
function useMapHops({ planner, shell }: MTripSheetsProps): MapHops {
|
||||
function useMapHops({ planner, shell }: MTripSheetsProps): Required<MapHops> {
|
||||
const navigate = useNavigate();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||
@@ -147,13 +152,21 @@ function useMapHops({ planner, shell }: MTripSheetsProps): MapHops {
|
||||
}
|
||||
|
||||
interface ScreenProps {
|
||||
/** The chat's key: a trip's id, or the home chat's. */
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
hidden: boolean;
|
||||
hops: MapHops;
|
||||
/** The home chat's way into one trip's own chat, from the card that offers it. */
|
||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
||||
}
|
||||
|
||||
function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
/**
|
||||
* The chat screen itself, the same over a trip and (the home chat) on the
|
||||
* pages outside one: the head with the history and the menu, and the chat.
|
||||
* The trip notes are a trip's, so the home chat's menu goes without them.
|
||||
*/
|
||||
export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripChat }: ScreenProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const thread = useAssistantThread(tripId);
|
||||
@@ -163,6 +176,7 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const memory = useTripMemory(tripId);
|
||||
const tripChat = assistantScopeOf(tripId).kind === 'trip';
|
||||
const hints = useAssistantHintsSetting();
|
||||
useBackCloses(!hidden, close);
|
||||
|
||||
@@ -247,11 +261,13 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
onClose={() => setPanel(null)}
|
||||
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)]"
|
||||
>
|
||||
<MListRow
|
||||
icon={NotebookPen}
|
||||
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
|
||||
onClick={() => pick(() => setMemoryOpen(true))}
|
||||
/>
|
||||
{tripChat && (
|
||||
<MListRow
|
||||
icon={NotebookPen}
|
||||
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
|
||||
onClick={() => pick(() => setMemoryOpen(true))}
|
||||
/>
|
||||
)}
|
||||
<MListRow
|
||||
icon={hints.on ? LightbulbOff : Lightbulb}
|
||||
label={t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn')}
|
||||
@@ -286,6 +302,7 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
onOpen={hops.open}
|
||||
onFocusDay={hops.focusDay}
|
||||
onRevealMap={hops.revealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import type { AssistantScope } from '@trek/shared';
|
||||
import { type AssistantScope, HOME_ASSISTANT_SCOPE } from '@trek/shared';
|
||||
|
||||
import type { AssistantTarget } from '../api/assistant';
|
||||
|
||||
/**
|
||||
* The key the assistant store keeps a conversation under, and the one every
|
||||
* part of the chat passes back to it: a trip's id for the planner's chat, and
|
||||
* the journey's id negated for the chat in TREK Studio. Trip ids are positive
|
||||
* (a trip is never made offline with a temporary id), so the two never meet,
|
||||
* and the parts that only make sense on a trip (the map, the day list, found
|
||||
* places) find no trip under a journey's key and stay empty.
|
||||
* part of the chat passes back to it: a trip's id for the planner's chat, the
|
||||
* journey's id negated for the chat in TREK Studio, and 0 for the home chat
|
||||
* outside any one trip (the dashboard, Vacay, Atlas, collections, settings).
|
||||
* Trip ids are positive (a trip is never made offline with a temporary id), so
|
||||
* the three never meet, and the parts that only make sense on a trip (the map,
|
||||
* the day list, found places) find no trip under a journey's key or the home
|
||||
* key and stay empty.
|
||||
*
|
||||
* A number rather than `assistantScopeKey`'s `journey:12`, so the trip chat
|
||||
* keeps the key it always had and every part that reads it stays as it is.
|
||||
*/
|
||||
export type AssistantChatKey = number;
|
||||
|
||||
/** The key of the home chat. */
|
||||
export const HOME_CHAT_KEY: AssistantChatKey = 0;
|
||||
|
||||
/** The key a scope's chat is kept under. */
|
||||
export function assistantChatKey(scope: AssistantScope): AssistantChatKey {
|
||||
if (scope.kind === 'home') return HOME_CHAT_KEY;
|
||||
return scope.kind === 'trip' ? scope.tripId : -scope.journeyId;
|
||||
}
|
||||
|
||||
/** What the chat under a key is about. */
|
||||
export function assistantScopeOf(key: AssistantChatKey): AssistantScope {
|
||||
if (key === HOME_CHAT_KEY) return HOME_ASSISTANT_SCOPE;
|
||||
return key < 0 ? { kind: 'journey', journeyId: -key } : { kind: 'trip', tripId: key };
|
||||
}
|
||||
|
||||
/** What the repo is called with for the chat under a key: a trip's id as it always was, a journey's scope. */
|
||||
/** What the repo is called with for the chat under a key: a trip's id as it always was, any other scope as it is. */
|
||||
export function assistantTargetOf(key: AssistantChatKey): AssistantTarget {
|
||||
return key < 0 ? assistantScopeOf(key) : key;
|
||||
return key > 0 ? key : assistantScopeOf(key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* FE-AI-STORE-HOME-001 to FE-AI-STORE-HOME-005
|
||||
*
|
||||
* The home chat (the assistant outside any one trip) in the same store as the
|
||||
* trips' and the journeys': kept under the key 0, which no trip and no
|
||||
* journey has; every call it has goes out with the home scope; an applied
|
||||
* change does not make a planner jump; the trip-only actions (files, found
|
||||
* places, proposals) do nothing for it. The repo is faked, nothing reaches
|
||||
* Dexie.
|
||||
*/
|
||||
import type { UiStreamChunk } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { assistantChatKey, assistantScopeOf, assistantTargetOf, HOME_CHAT_KEY } from './assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from './assistantStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
const HOME = { kind: 'home' } as const;
|
||||
const WRITE_ID = 'c0ffee00-0000-4000-8000-000000000003';
|
||||
|
||||
const HELD_TURN: UiStreamChunk[] = [
|
||||
{ type: 'start', messageId: 'msg-1' },
|
||||
{ type: 'data-trek-thread', data: { threadId: 21, title: 'Japan' } },
|
||||
{ type: 'text-delta', id: 't', delta: 'Japan goes into your atlas.' },
|
||||
{
|
||||
type: 'tool-input-available',
|
||||
toolCallId: WRITE_ID,
|
||||
toolName: 'mark_country_visited',
|
||||
input: { country_code: 'JP' },
|
||||
},
|
||||
{ type: 'tool-approval-request', approvalId: WRITE_ID, toolCallId: WRITE_ID },
|
||||
{ type: 'finish', finishReason: 'tool-calls' },
|
||||
];
|
||||
|
||||
const APPLIED_TURN: UiStreamChunk[] = [
|
||||
{ type: 'start', messageId: 'msg-1' },
|
||||
{ type: 'tool-output-available', toolCallId: WRITE_ID, output: { country_code: 'JP' } },
|
||||
{
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: WRITE_ID, entity: 'atlas', ids: [], dayId: null, undoUntil: '2026-10-23T08:15:00.000Z' },
|
||||
},
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
];
|
||||
|
||||
async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator<UiStreamChunk> {
|
||||
for (const chunk of chunks) yield chunk;
|
||||
}
|
||||
|
||||
const store = () => useAssistantStore.getState();
|
||||
const chat = () => chatOf(store(), HOME_CHAT_KEY);
|
||||
|
||||
beforeEach(() => {
|
||||
useAssistantStore.setState({
|
||||
status: null,
|
||||
window: { open: false, minimized: false },
|
||||
byTrip: {},
|
||||
lastApplied: null,
|
||||
});
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'en' } }));
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('assistantChatKey: the home chat', () => {
|
||||
it('FE-AI-STORE-HOME-001: is kept under 0, which reads back as the home scope and is called with it', () => {
|
||||
expect(assistantChatKey(HOME)).toBe(HOME_CHAT_KEY);
|
||||
expect(HOME_CHAT_KEY).toBe(0);
|
||||
expect(assistantScopeOf(HOME_CHAT_KEY)).toEqual(HOME);
|
||||
expect(assistantTargetOf(HOME_CHAT_KEY)).toEqual(HOME);
|
||||
// A trip and a journey keep their keys.
|
||||
expect(assistantTargetOf(5)).toBe(5);
|
||||
expect(assistantTargetOf(-5)).toEqual({ kind: 'journey', journeyId: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantStore: the home chat', () => {
|
||||
it('FE-AI-STORE-HOME-002: sends with the home scope, apart from every trip', async () => {
|
||||
const send = vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf(HELD_TURN));
|
||||
|
||||
await store().send(HOME_CHAT_KEY, ' I was in Japan ');
|
||||
|
||||
const [target, body] = send.mock.calls[0] ?? [];
|
||||
expect(target).toEqual(HOME);
|
||||
expect(body).toMatchObject({ locale: 'en', threadId: null, text: 'I was in Japan' });
|
||||
expect(chat()).toMatchObject({ threadId: 21, isRunning: false, error: null });
|
||||
expect(chat().messages.map((m) => [m.role, m.status])).toEqual([
|
||||
['user', 'complete'],
|
||||
['assistant', 'awaiting_approval'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-HOME-003: resume and delete reach the home threads', async () => {
|
||||
const summary = { id: 21, title: 'Japan', lastMessageAt: '2026-10-23T08:00:00.000Z' };
|
||||
const threads = vi.spyOn(assistantRepo, 'threads').mockResolvedValueOnce([summary]);
|
||||
const open = vi.spyOn(assistantRepo, 'thread').mockResolvedValueOnce({ thread: summary, messages: [] });
|
||||
|
||||
await store().resume(HOME_CHAT_KEY);
|
||||
expect(threads).toHaveBeenCalledWith(HOME);
|
||||
expect(open).toHaveBeenCalledWith(HOME, 21);
|
||||
|
||||
const remove = vi.spyOn(assistantRepo, 'deleteThread').mockResolvedValueOnce(undefined);
|
||||
await store().deleteThread(HOME_CHAT_KEY, 21);
|
||||
expect(remove).toHaveBeenCalledWith(HOME, 21);
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-HOME-004: a decision goes to the home thread; an applied change makes no planner jump', async () => {
|
||||
vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf(HELD_TURN));
|
||||
await store().send(HOME_CHAT_KEY, 'I was in Japan');
|
||||
const decide = vi.spyOn(assistantRepo, 'decide').mockResolvedValueOnce(streamOf(APPLIED_TURN));
|
||||
|
||||
await store().decide(HOME_CHAT_KEY, [{ toolCallId: WRITE_ID, approved: true }]);
|
||||
|
||||
expect(decide).toHaveBeenCalledWith(HOME, 21, expect.anything(), expect.any(AbortSignal));
|
||||
expect(store().lastApplied).toBeNull();
|
||||
expect(chat().compare).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-HOME-005: files, found places and proposals do nothing in the home chat', async () => {
|
||||
useAssistantStore.setState({ byTrip: { [HOME_CHAT_KEY]: { ...chat(), threadId: 21 } } });
|
||||
const attach = vi.spyOn(assistantRepo, 'attach');
|
||||
const found = vi.spyOn(assistantRepo, 'addFoundPlace');
|
||||
const proposal = vi.spyOn(assistantRepo, 'takeProposal');
|
||||
|
||||
await store().attach(HOME_CHAT_KEY, new File(['x'], 'booking.pdf', { type: 'application/pdf' }));
|
||||
await store().addFoundPlace(HOME_CHAT_KEY, 's1:0', 31);
|
||||
await expect(store().takeProposal(HOME_CHAT_KEY, WRITE_ID)).resolves.toBe('skipped');
|
||||
|
||||
expect(attach).not.toHaveBeenCalled();
|
||||
expect(found).not.toHaveBeenCalled();
|
||||
expect(proposal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user