From 5d334d7ec88c9149588be8eecbf6d1368f64f35d Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 21:19:25 +0200 Subject: [PATCH] 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. --- client/scripts/feature-pairs.json | 9 + client/src/App.tsx | 7 +- client/src/api/assistant.test.ts | 42 +++ client/src/api/assistant.ts | 8 +- .../Assistant/AssistantComposer.tsx | 7 +- .../components/Assistant/AssistantHeader.tsx | 82 ++++- .../components/Assistant/AssistantHost.tsx | 3 +- .../Assistant/AssistantLauncher.test.tsx | 8 + .../Assistant/AssistantLauncher.tsx | 8 +- .../components/Assistant/AssistantSurface.tsx | 4 +- .../Assistant/AssistantTripPicker.tsx | 5 +- .../components/Assistant/AssistantWelcome.tsx | 19 +- .../Assistant/AssistantWindow.test.tsx | 44 ++- .../components/Assistant/AssistantWindow.tsx | 81 ++-- .../components/Assistant/assistantModel.ts | 11 + .../Assistant/assistantViewContext.ts | 11 + .../components/Assistant/cards/cardLooks.ts | 3 + .../components/Assistant/cards/cardViews.ts | 2 + .../cards/home/TripChatBlock.test.tsx | 84 +++++ .../Assistant/cards/home/TripChatBlock.tsx | 114 ++++++ .../Assistant/cards/home/homeLook.ts | 21 ++ .../Assistant/cards/home/homeViews.tsx | 12 + .../Assistant/useAssistantHome.test.tsx | 102 ++++++ .../components/Assistant/useAssistantHome.ts | 44 +++ .../Assistant/useAssistantSuggestions.ts | 12 +- .../src/mobile/components/MBottomNav.test.tsx | 15 +- client/src/mobile/components/MBottomNav.tsx | 20 +- .../assistant/MAssistantHomeScreen.tsx | 60 +++ .../mobile/screens/dashboard/MDashboard.tsx | 9 +- .../trip/assistant/MAssistantScreen.tsx | 39 +- client/src/store/assistantChatKey.ts | 23 +- client/src/store/assistantStore.home.test.ts | 132 +++++++ .../unit/mobile/MAssistantHomeScreen.test.tsx | 144 ++++++++ ...t_conversation_can_be_about_no_one_trip.ts | 109 ++++++ .../AssistantThreads.repository.ts | 85 +++-- .../AssistantToolCalls.repository.ts | 14 + .../src/nest/assistant/assistant-context.ts | 14 +- server/src/nest/assistant/assistant-home.ts | 245 +++++++++++++ server/src/nest/assistant/assistant-scope.ts | 36 +- .../nest/assistant/assistant-tool-offer.ts | 67 +++- .../nest/assistant/assistant-tools.service.ts | 79 +++- server/src/nest/assistant/assistant.module.ts | 6 + server/src/nest/assistant/cards/card-types.ts | 3 +- server/src/nest/assistant/cards/home/index.ts | 37 ++ server/src/nest/assistant/cards/registry.ts | 2 + .../assistant/home-assistant.controller.ts | 95 +++++ .../nest/common/validate-managed-routes.ts | 7 + server/tests/e2e/assistant-home.e2e.test.ts | 345 ++++++++++++++++++ .../assistant-home-threads-migration.test.ts | 148 ++++++++ server/tests/unit/db/legacy-baseline.test.ts | 1 + .../AssistantConversations.repository.test.ts | 72 ++++ .../assistant/assistant-home-session.test.ts | 127 +++++++ .../nest/assistant/assistant-home.test.ts | 196 ++++++++++ .../nest/assistant/assistant-scope.test.ts | 58 ++- .../assistant/assistant-tools.service.test.ts | 3 + .../nest/assistant/cards/registry.test.ts | 2 +- .../home-assistant.controller.test.ts | 110 ++++++ .../assistant/assistant-scope.schema.spec.ts | 9 +- .../src/assistant/assistant-scope.schema.ts | 22 +- shared/src/assistant/cards/home.contract.ts | 12 + shared/src/assistant/cards/index.ts | 3 + .../assistant/cards/trip-chat.card.spec.ts | 39 ++ shared/src/assistant/cards/trip-chat.card.ts | 30 ++ shared/src/i18n/ar/assistantHome.ts | 27 ++ shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/az/assistantHome.ts | 27 ++ shared/src/i18n/az/index.ts | 2 + shared/src/i18n/br/assistantHome.ts | 27 ++ shared/src/i18n/br/index.ts | 2 + shared/src/i18n/ca/assistantHome.ts | 27 ++ shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/cs/assistantHome.ts | 27 ++ shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/de/assistantHome.ts | 27 ++ shared/src/i18n/de/index.ts | 2 + shared/src/i18n/en/assistantHome.ts | 27 ++ shared/src/i18n/en/index.ts | 2 + shared/src/i18n/es/assistantHome.ts | 27 ++ shared/src/i18n/es/index.ts | 2 + shared/src/i18n/et/assistantHome.ts | 27 ++ shared/src/i18n/et/index.ts | 2 + shared/src/i18n/fr/assistantHome.ts | 27 ++ shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/gr/assistantHome.ts | 27 ++ shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/hu/assistantHome.ts | 27 ++ shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/id/assistantHome.ts | 27 ++ shared/src/i18n/id/index.ts | 2 + shared/src/i18n/it/assistantHome.ts | 27 ++ shared/src/i18n/it/index.ts | 2 + shared/src/i18n/ja/assistantHome.ts | 27 ++ shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ko/assistantHome.ts | 27 ++ shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/nl/assistantHome.ts | 27 ++ shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/pl/assistantHome.ts | 27 ++ shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/ru/assistantHome.ts | 27 ++ shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/sk/assistantHome.ts | 27 ++ shared/src/i18n/sk/index.ts | 2 + shared/src/i18n/sv/assistantHome.ts | 27 ++ shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/th/assistantHome.ts | 27 ++ shared/src/i18n/th/index.ts | 2 + shared/src/i18n/tr/assistantHome.ts | 27 ++ shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/uk/assistantHome.ts | 27 ++ shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/vi/assistantHome.ts | 27 ++ shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/zh-TW/assistantHome.ts | 27 ++ shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh/assistantHome.ts | 27 ++ shared/src/i18n/zh/index.ts | 2 + 117 files changed, 3770 insertions(+), 174 deletions(-) create mode 100644 client/src/components/Assistant/cards/home/TripChatBlock.test.tsx create mode 100644 client/src/components/Assistant/cards/home/TripChatBlock.tsx create mode 100644 client/src/components/Assistant/cards/home/homeLook.ts create mode 100644 client/src/components/Assistant/cards/home/homeViews.tsx create mode 100644 client/src/components/Assistant/useAssistantHome.test.tsx create mode 100644 client/src/components/Assistant/useAssistantHome.ts create mode 100644 client/src/mobile/screens/assistant/MAssistantHomeScreen.tsx create mode 100644 client/src/store/assistantStore.home.test.ts create mode 100644 client/tests/unit/mobile/MAssistantHomeScreen.test.tsx create mode 100644 server/src/db/migrations/Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip.ts create mode 100644 server/src/nest/assistant/assistant-home.ts create mode 100644 server/src/nest/assistant/cards/home/index.ts create mode 100644 server/src/nest/assistant/home-assistant.controller.ts create mode 100644 server/tests/e2e/assistant-home.e2e.test.ts create mode 100644 server/tests/unit/db/assistant-home-threads-migration.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-home-session.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-home.test.ts create mode 100644 server/tests/unit/nest/assistant/home-assistant.controller.test.ts create mode 100644 shared/src/assistant/cards/home.contract.ts create mode 100644 shared/src/assistant/cards/trip-chat.card.spec.ts create mode 100644 shared/src/assistant/cards/trip-chat.card.ts create mode 100644 shared/src/i18n/ar/assistantHome.ts create mode 100644 shared/src/i18n/az/assistantHome.ts create mode 100644 shared/src/i18n/br/assistantHome.ts create mode 100644 shared/src/i18n/ca/assistantHome.ts create mode 100644 shared/src/i18n/cs/assistantHome.ts create mode 100644 shared/src/i18n/de/assistantHome.ts create mode 100644 shared/src/i18n/en/assistantHome.ts create mode 100644 shared/src/i18n/es/assistantHome.ts create mode 100644 shared/src/i18n/et/assistantHome.ts create mode 100644 shared/src/i18n/fr/assistantHome.ts create mode 100644 shared/src/i18n/gr/assistantHome.ts create mode 100644 shared/src/i18n/hu/assistantHome.ts create mode 100644 shared/src/i18n/id/assistantHome.ts create mode 100644 shared/src/i18n/it/assistantHome.ts create mode 100644 shared/src/i18n/ja/assistantHome.ts create mode 100644 shared/src/i18n/ko/assistantHome.ts create mode 100644 shared/src/i18n/nl/assistantHome.ts create mode 100644 shared/src/i18n/pl/assistantHome.ts create mode 100644 shared/src/i18n/ru/assistantHome.ts create mode 100644 shared/src/i18n/sk/assistantHome.ts create mode 100644 shared/src/i18n/sv/assistantHome.ts create mode 100644 shared/src/i18n/th/assistantHome.ts create mode 100644 shared/src/i18n/tr/assistantHome.ts create mode 100644 shared/src/i18n/uk/assistantHome.ts create mode 100644 shared/src/i18n/vi/assistantHome.ts create mode 100644 shared/src/i18n/zh-TW/assistantHome.ts create mode 100644 shared/src/i18n/zh/assistantHome.ts 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 && ( + + )}