diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 34c6f881d..863dcb414 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -69,6 +69,15 @@ "desktop": ["src/components/Assistant/AssistantWindow.tsx"], "mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"] }, + { + "feature": "AI assistant home chat", + "hooks": ["src/components/Assistant/useAssistantHome.ts"], + "desktop": ["src/components/Assistant/AssistantWindow.tsx"], + "mobile": [ + "src/mobile/screens/assistant/MAssistantHomeScreen.tsx", + "src/mobile/screens/dashboard/MDashboard.tsx" + ] + }, { "feature": "Assistant hints", "hooks": ["src/components/Assistant/useAssistantHints.ts"], diff --git a/client/src/App.tsx b/client/src/App.tsx index 638ff11de..61bd92380 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 && } {!hideAuthedWidgets && (isPhone ? : )} {!hideAuthedWidgets && keepingTour && } - {/* 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 && ( - + {isPhone ? : } )} diff --git a/client/src/api/assistant.test.ts b/client/src/api/assistant.test.ts index ccf0ed029..4f6d2a819 100644 --- a/client/src/api/assistant.test.ts +++ b/client/src/api/assistant.test.ts @@ -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']); + }); }); diff --git a/client/src/api/assistant.ts b/client/src/api/assistant.ts index 7ad919e7f..e4773bc36 100644 --- a/client/src/api/assistant.ts +++ b/client/src/api/assistant.ts @@ -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`; } diff --git a/client/src/components/Assistant/AssistantComposer.tsx b/client/src/components/Assistant/AssistantComposer.tsx index 1d1464e6f..5cfed93bf 100644 --- a/client/src/components/Assistant/AssistantComposer.tsx +++ b/client/src/components/Assistant/AssistantComposer.tsx @@ -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(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, diff --git a/client/src/components/Assistant/AssistantHeader.tsx b/client/src/components/Assistant/AssistantHeader.tsx index 691a2905d..7cc74950a 100644 --- a/client/src/components/Assistant/AssistantHeader.tsx +++ b/client/src/components/Assistant/AssistantHeader.tsx @@ -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; + /** + * 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 && } {tripChat && ( + + ); + } + if (!onPick) return null; + return ( + <> + + + + + { + close(); + onPick({ id, title }); + }} + /> + + + ); +} diff --git a/client/src/components/Assistant/AssistantHost.tsx b/client/src/components/Assistant/AssistantHost.tsx index 0a6989484..f1123bf03 100644 --- a/client/src/components/Assistant/AssistantHost.tsx +++ b/client/src/components/Assistant/AssistantHost.tsx @@ -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 diff --git a/client/src/components/Assistant/AssistantLauncher.test.tsx b/client/src/components/Assistant/AssistantLauncher.test.tsx index 38b6f2f0e..e1a5806d2 100644 --- a/client/src/components/Assistant/AssistantLauncher.test.tsx +++ b/client/src/components/Assistant/AssistantLauncher.test.tsx @@ -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(); + expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute('aria-busy', 'true'); + }); }); describe('the shortcut', () => { diff --git a/client/src/components/Assistant/AssistantLauncher.tsx b/client/src/components/Assistant/AssistantLauncher.tsx index 9ea247cc4..877650f89 100644 --- a/client/src/components/Assistant/AssistantLauncher.tsx +++ b/client/src/components/Assistant/AssistantLauncher.tsx @@ -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(); diff --git a/client/src/components/Assistant/AssistantSurface.tsx b/client/src/components/Assistant/AssistantSurface.tsx index 03835c160..74d0f5d21 100644 --- a/client/src/components/Assistant/AssistantSurface.tsx +++ b/client/src/components/Assistant/AssistantSurface.tsx @@ -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 ( diff --git a/client/src/components/Assistant/AssistantTripPicker.tsx b/client/src/components/Assistant/AssistantTripPicker.tsx index 84ceb818d..2e0180569 100644 --- a/client/src/components/Assistant/AssistantTripPicker.tsx +++ b/client/src/components/Assistant/AssistantTripPicker.tsx @@ -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(); diff --git a/client/src/components/Assistant/AssistantWelcome.tsx b/client/src/components/Assistant/AssistantWelcome.tsx index 617b5a59d..084742434 100644 --- a/client/src/components/Assistant/AssistantWelcome.tsx +++ b/client/src/components/Assistant/AssistantWelcome.tsx @@ -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 (
- + {!canEdit && (

{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, diff --git a/client/src/components/Assistant/AssistantWindow.test.tsx b/client/src/components/Assistant/AssistantWindow.test.tsx index cb2717e04..aef656774 100644 --- a/client/src/components/Assistant/AssistantWindow.test.tsx +++ b/client/src/components/Assistant/AssistantWindow.test.tsx @@ -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; }) => (

@@ -66,6 +69,11 @@ vi.mock('./AssistantSurfaceLazy', () => ({ )} + {onOpenTripChat && ( + + )}