-
+
{!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 && (
+
+ )}
),
@@ -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(
);
- 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(
);
+ 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(
);
+ 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', () => {
>
);
+ // 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');
diff --git a/client/src/components/Assistant/AssistantWindow.tsx b/client/src/components/Assistant/AssistantWindow.tsx
index e38b57bf1..1ee1add70 100644
--- a/client/src/components/Assistant/AssistantWindow.tsx
+++ b/client/src/components/Assistant/AssistantWindow.tsx
@@ -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
[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}
/>
@@ -177,19 +185,21 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState