mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
b64da26651
Adds an in-app assistant that reads and plans the open trip. Users connect their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or OpenRouter key) or use the one the admin set up, including a custom OpenAI-compatible server. It is an addon and only shows while a connection is usable. The chat runs on the existing MCP tools, pinned to the trip. Every change is held as a change card the user applies or discards, and applied changes can be undone. On the desktop it is a floating window that can be moved and resized; on the phone it is fullscreen. Search results show as place cards that can go onto a day, weather lookups as a strip, proposed places as ghost markers on the map with a before/after switch, and an answer can be shared into the trip chat. Booking files can be dropped in and are read into change cards.
208 lines
8.5 KiB
TypeScript
208 lines
8.5 KiB
TypeScript
import type { AiStatusResponse, AssistantApplied } from '@trek/shared';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import MAssistantScreen from '../../../../src/mobile/screens/trip/assistant/MAssistantScreen';
|
|
import { chatOf, useAssistantStore } from '../../../../src/store/assistantStore';
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
|
|
import { act, cleanup, fireEvent, render, screen } from '../../../helpers/render';
|
|
|
|
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-011
|
|
//
|
|
// The phone's full-screen chat around the shared chat surface, which is
|
|
// replaced here: what is tested is the frame, the header and the way to the
|
|
// map and back.
|
|
|
|
const surface = vi.hoisted(() => ({
|
|
onShowOnMap: null as ((applied: AssistantApplied) => void) | null,
|
|
onFocusDay: null as ((dayId: number) => void) | null,
|
|
onRevealMap: null as (() => void) | null,
|
|
}));
|
|
const thread = vi.hoisted(() => ({
|
|
threadId: 3 as number | null,
|
|
threads: [{ id: 3, title: 'Temples on day 2', lastMessageAt: '2026-10-20T09:00:00.000Z' }],
|
|
title: 'Temples on day 2' as string | null,
|
|
isRunning: false,
|
|
retentionNote: 'Chats are deleted after 90 days.',
|
|
refreshThreads: vi.fn(),
|
|
openThread: vi.fn(),
|
|
newThread: vi.fn(),
|
|
removeThread: vi.fn(async () => undefined),
|
|
stop: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../../src/components/Assistant/AssistantSurfaceLazy', () => ({
|
|
default: (props: {
|
|
onShowOnMap?: (applied: AssistantApplied) => void;
|
|
onFocusDay?: (dayId: number) => void;
|
|
onRevealMap?: () => void;
|
|
}) => {
|
|
surface.onShowOnMap = props.onShowOnMap ?? null;
|
|
surface.onFocusDay = props.onFocusDay ?? null;
|
|
surface.onRevealMap = props.onRevealMap ?? null;
|
|
return <div data-testid="chat-surface" />;
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../../src/components/Assistant/useAssistantThread', () => ({
|
|
useAssistantThread: () => thread,
|
|
}));
|
|
|
|
const INITIAL = useAssistantStore.getState();
|
|
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
|
|
|
function renderScreen(plannerOverrides = {}, shellOverrides = {}) {
|
|
const planner = buildPlanner(plannerOverrides);
|
|
const shell = buildShell(shellOverrides);
|
|
const view = render(<MAssistantScreen planner={planner} shell={shell} />);
|
|
return { planner, shell, ...view };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// Closing the screen takes its history entry back a tick later; that step stays queued here.
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
window.history.replaceState(null, '');
|
|
// The screen asks for the status itself; here it is already known.
|
|
useAssistantStore.setState({
|
|
status: STATUS,
|
|
window: { open: true, minimized: false },
|
|
loadStatus: vi.fn(async () => undefined),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
useAssistantStore.setState(INITIAL, true);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('MAssistantScreen', () => {
|
|
it('FE-MOB-ASSIST-001: shows nothing until the assistant has a connection to use', () => {
|
|
useAssistantStore.setState({ status: { ...STATUS, available: false } });
|
|
const { container } = renderScreen();
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-002: asks for the status once when the trip opens', () => {
|
|
renderScreen();
|
|
expect(useAssistantStore.getState().loadStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-003: opens full screen with the chat, named after the conversation and the trip', () => {
|
|
renderScreen();
|
|
expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: 'Temples on day 2' })).toBeInTheDocument();
|
|
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
|
|
expect(screen.getByTestId('chat-surface')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-004: the back button closes the chat', () => {
|
|
renderScreen();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
|
expect(useAssistantStore.getState().window.open).toBe(false);
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-005: the recent chats list switches the conversation and names how long chats are kept', () => {
|
|
renderScreen();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Recent chats' }));
|
|
expect(thread.refreshThreads).toHaveBeenCalled();
|
|
expect(screen.getByText('Chats are deleted after 90 days.')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /Temples on day 2/ }));
|
|
expect(thread.openThread).toHaveBeenCalledWith(3);
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-006: deleting the chat asks first', () => {
|
|
renderScreen();
|
|
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete chat' }));
|
|
expect(thread.removeThread).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
|
expect(thread.removeThread).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-007: "Show on map" steps aside for the map, and the pill brings the chat back as it was', () => {
|
|
const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
|
|
act(() => surface.onShowOnMap?.({ toolCallId: 'c1', entity: 'place', ids: [42], dayId: 2, undoUntil: null }));
|
|
|
|
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: true });
|
|
expect(shell.setTrTab).toHaveBeenCalledWith('plan');
|
|
expect(shell.toggleView).toHaveBeenCalled();
|
|
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(42);
|
|
// Hidden, not gone: the answer being written keeps coming in.
|
|
expect(screen.getByTestId('chat-surface')).toBeInTheDocument();
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Back to chat' }));
|
|
expect(useAssistantStore.getState().window.minimized).toBe(false);
|
|
expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-009: a day picked in the chat is selected on the map, and the pill says where a step looks', () => {
|
|
const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
|
|
act(() => surface.onFocusDay?.(7));
|
|
expect(useAssistantStore.getState().window.minimized).toBe(true);
|
|
expect(shell.setTrTab).toHaveBeenCalledWith('plan');
|
|
expect(planner.handleSelectDay).toHaveBeenCalledWith(7, false);
|
|
|
|
act(() =>
|
|
useAssistantStore.setState((s) => ({
|
|
byTrip: {
|
|
...s.byTrip,
|
|
[planner.tripId]: {
|
|
...chatOf(s, planner.tripId),
|
|
messages: [
|
|
{
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
status: 'complete',
|
|
createdAt: '2026-10-21T08:00:00.000Z',
|
|
parts: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search_nearby_places', args: {} }],
|
|
},
|
|
],
|
|
live: { activity: { toolCallId: 'c1', label: 'Gion', area: null } },
|
|
},
|
|
},
|
|
}))
|
|
);
|
|
expect(screen.getByRole('button', { name: 'Back to chat' })).toHaveTextContent('Searching near Gion');
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-010: before the map moves to a found place, the chat steps aside', () => {
|
|
const { shell } = renderScreen({}, { trTab: 'plan', view: 'plan' });
|
|
act(() => surface.onRevealMap?.());
|
|
expect(useAssistantStore.getState().window.minimized).toBe(true);
|
|
expect(shell.toggleView).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-011: while a block can be compared, its switch sits beside the pill over the map', () => {
|
|
const { planner } = renderScreen({}, { trTab: 'plan', view: 'plan' });
|
|
act(() => surface.onRevealMap?.());
|
|
expect(screen.queryByRole('group', { name: 'Compare on the map' })).not.toBeInTheDocument();
|
|
|
|
act(() =>
|
|
useAssistantStore.setState((s) => ({
|
|
byTrip: {
|
|
...s.byTrip,
|
|
[planner.tripId]: {
|
|
...chatOf(s, planner.tripId),
|
|
compare: { blockKey: 'c1', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] },
|
|
},
|
|
},
|
|
}))
|
|
);
|
|
expect(screen.getByRole('group', { name: 'Compare on the map' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-008: the back gesture closes the chat instead of leaving the trip', () => {
|
|
renderScreen();
|
|
expect(window.history.state).toMatchObject({ trekAssistant: true });
|
|
act(() => {
|
|
window.history.replaceState({ idx: 0 }, '');
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
});
|
|
expect(useAssistantStore.getState().window.open).toBe(false);
|
|
});
|
|
});
|