fix(assistant): lint, types and tests for the CI round

- agent loop and decisions without await in loops: steps as recursion,
  calls and applied changes in order through one promise chain, the
  independent reads and claims in parallel; part mapping in its own helpers
- place card, header and weather strip without refs or components created
  in render; long hooks and components split; state reset during render
- trip notes only in a turn's tool session, never an undo's; map view
  reported only while the assistant can run
- place strip scrolls through the strip's own smooth scroll, no physical side
- tests follow the raised limits and the new card fields; toasts read through
  a mocked useToast instead of a window global; baselines lowered where the
  counts fell
This commit is contained in:
Maurice
2026-10-21 21:45:25 +02:00
parent b6f7488591
commit 3c3a228737
47 changed files with 892 additions and 610 deletions
@@ -167,7 +167,9 @@ describe('AI parsing', () => {
expect(
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Connection' }));
// The link in the hint, not the tab of the same name above it.
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
await user.click(link as HTMLElement);
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
});
@@ -26,6 +26,72 @@ function savedOf(cfg: Record<string, unknown>): SavedParsing {
};
}
/**
* The models a local (Ollama) server has, and pulling the recommended ones.
* The list loads while the form shows the local provider: a provider change or
* opening the form loads it, the address field's own blur reloads it.
*/
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
const { t } = useTranslation();
const toast = useToast();
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (!isLocal) return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && isLocal) void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [isLocal, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success(t('admin.addons.ai.parsing.pulled'));
onPulled(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
}
/**
* AI parsing's admin settings behind both admin shells. Parsing reads with the
* AI connection the assistant runs on unless the instance keeps one of its own
@@ -45,14 +111,6 @@ export function useLlmParsingConfig(addon: Addon) {
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
const [saving, setSaving] = useState(false);
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
/** A connection of its own, which wins over the shared one. */
const ownConnection = !!(saved.provider && saved.model);
const formOpen = ownConnection || editing;
@@ -68,57 +126,7 @@ export function useLlmParsingConfig(addon: Addon) {
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (provider !== 'local') return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
// Load installed models while the form shows the local provider. Only a provider
// change (or opening the form) triggers it; the base URL's own blur reloads.
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && provider === 'local') void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [provider, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success(t('admin.addons.ai.parsing.pulled'));
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
@@ -186,15 +194,7 @@ export function useLlmParsingConfig(addon: Addon) {
startOwn: () => setEditing(true),
cancelOwn: () => setEditing(false),
switchToShared,
installed,
modelsErr,
loadingModels,
pulling,
pullPct,
pullStatus,
isInstalled,
loadModels,
pull,
...local,
save,
};
}
@@ -20,6 +20,18 @@ import AssistantCompareSwitch from './AssistantCompareSwitch';
import type { ChangeItem } from './assistantParts';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const TRIP_INITIAL = useTripStore.getState();
@@ -98,18 +110,15 @@ const undoBlock =
const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>();
const requestCamera = vi.fn();
const setMinimized = vi.fn();
const addToast = vi.fn();
beforeEach(() => {
decide.mockResolvedValue(undefined);
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
window.__addToast = addToast as unknown as typeof window.__addToast;
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(TRIP_INITIAL, true);
delete window.__addToast;
vi.clearAllMocks();
vi.useRealTimers();
});
@@ -119,9 +128,13 @@ describe('AssistantChangeCard: deciding', () => {
renderCard([pending('c1', 'Kinkaku-ji')]);
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
expect(screen.getByText('Add place')).toBeInTheDocument();
expect(screen.getByText('Address')).toBeInTheDocument();
// The time is a chip of its own, old to new; the address waits behind "Details".
expect(screen.getByText('09:00')).toBeInTheDocument();
expect(screen.getByText('10:30')).toBeInTheDocument();
expect(screen.queryByText('Kyoto')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Details' }));
expect(screen.getByText('Address')).toBeInTheDocument();
expect(screen.getByText('Kyoto')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
});
@@ -80,7 +80,7 @@ describe('AssistantComposer mentions', () => {
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
expect(send).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
// The pick shows in the text only, never as a chip above it.
@@ -48,6 +48,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
const hints = useAssistantHintsSetting();
const openHistory = () => {
@@ -140,12 +141,12 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
</Tooltip>
<Tooltip label={t('assistant.history.open')}>
<button
ref={history.triggerRef}
ref={historyTrigger}
type="button"
onClick={openHistory}
aria-label={t('assistant.history.open')}
aria-expanded={history.open}
aria-controls={history.open ? history.listId : undefined}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyListId : undefined}
className={ICON_BUTTON}
>
<History size={13} aria-hidden="true" />
@@ -1,6 +1,6 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { type ReactNode, useRef } from 'react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { getCategoryIcon } from '../shared/categoryIcons';
@@ -23,11 +23,12 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const { open, toggle, close, triggerRef, listId } = list;
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
list.close();
close();
void strip.suggest(place, dayId);
};
@@ -50,12 +51,12 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
</button>
<button
ref={list.triggerRef}
ref={triggerRef}
type="button"
disabled={disabled}
onClick={list.toggle}
aria-expanded={list.open}
aria-controls={list.open ? list.listId : undefined}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={t('assistant.place.pickDay')}
className={`${ACTION} ${height} rounded-s-none px-1.5`}
>
@@ -64,12 +65,12 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
</>
) : (
<button
ref={list.triggerRef}
ref={triggerRef}
type="button"
disabled={disabled}
onClick={list.toggle}
aria-expanded={list.open}
aria-controls={list.open ? list.listId : undefined}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={`${ACTION} ${height} px-2`}
>
<Plus size={12} aria-hidden="true" />
@@ -122,14 +123,113 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
function PlaceMedia({
place,
photo,
category,
}: {
place: AssistantFoundPlace;
photo: string | null;
category: Category;
}) {
const { t } = useTranslation();
const icon = category ? getCategoryIcon(category.icon) : MapPin;
return (
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
{createElement(icon, {
size: 30,
'aria-hidden': true,
style: { color: category?.color ?? 'var(--text-faint)' },
})}
</span>
)}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
{createElement(icon, {
size: 11,
'aria-hidden': true,
className: 'flex-shrink-0',
style: { color: category.color },
})}
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
);
}
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
function PlaceAction({
place,
strip,
suggested,
tall,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
tall: boolean;
}): ReactNode {
const { t } = useTranslation();
if (place.inTripPlaceId !== null) {
return (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
}
if (suggested) {
return (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
}
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
}
/**
* One place a search found, as a card: its picture from TREK's photo proxy
* with the category it most likely belongs in on it (or that category's icon
* on its colour), its name, address and rating, whether it is open now while
* the trip runs, how far it is from the last stop of the day in focus, and one
* way on: "+ Day 3" for a new
* place, "On map" for one the trip has. A click on the card shows it on the
* map, and resting the pointer on it pins it there for a moment.
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
* the card shows it on the map, and resting the pointer on it pins it there
* for a moment.
*/
export default function AssistantPlaceCard({
place,
@@ -146,30 +246,8 @@ export default function AssistantPlaceCard({
const photo = useFoundPlacePhoto(place, ref);
const category = strip.categoryOf(place);
const distance = strip.distanceOf(place);
const Icon = category ? getCategoryIcon(category.icon) : MapPin;
const tall = variant === 'fullscreen';
let action: ReactNode = null;
if (place.inTripPlaceId !== null) {
action = (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
} else if (suggested) {
action = (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
} else if (strip.canSuggest) {
action = <SuggestButton place={place} strip={strip} tall={tall} />;
}
return (
<li
ref={ref}
@@ -186,44 +264,7 @@ export default function AssistantPlaceCard({
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
>
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
<Icon size={30} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
</span>
)}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
<Icon size={11} aria-hidden="true" className="flex-shrink-0" style={{ color: category.color }} />
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
<PlaceMedia place={place} photo={photo} category={category} />
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
@@ -241,7 +282,9 @@ export default function AssistantPlaceCard({
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">{action}</div>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
</div>
</li>
);
}
@@ -3,7 +3,6 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { prefersReducedMotion } from '../../utils/reducedMotion';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
@@ -71,10 +70,8 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
const step = card.offsetWidth + gap;
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
const rtl = getComputedStyle(el).direction === 'rtl';
el.scrollBy({
left: (rtl ? -towards : towards) * cards * step,
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
};
const total = found.places.length + found.more;
@@ -114,7 +111,7 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5"
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
@@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
@@ -197,11 +198,12 @@ describe('the context menu rows', () => {
const openWith = vi.fn();
useAssistantStore.setState({ status: null, send, openWith });
const louvre = buildPlace({ id: 5, name: 'Louvre' });
const off = renderHook(() => useAssistantMenu(TRIP));
const translated = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
const off = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
expect(off.result.current.place(louvre)).toEqual([]);
useAssistantStore.setState({ status: { available: true } as never });
const on = renderHook(() => useAssistantMenu(TRIP));
const on = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
const rows = on.result.current.place(louvre);
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
@@ -1,5 +1,6 @@
import type { AssistantWeatherDay } from '@trek/shared';
import { Droplet } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -31,7 +32,7 @@ function WeatherChip({
onPick: ((dayId: number) => void) | null;
}) {
const { t, locale } = useTranslation();
const Icon = weatherIconFor(day.main);
const icon = weatherIconFor(day.main);
const { weekday, day: dayOfMonth } = dateParts(day.date, locale);
// A climate figure is an average for the date, not a forecast; it says so with "≈".
const approx = day.kind === 'climate' ? '≈' : '';
@@ -45,7 +46,7 @@ function WeatherChip({
<span className="text-caption text-content-muted">
{weekday} {dayOfMonth}
</span>
<Icon size={16} strokeWidth={1.8} aria-hidden="true" className="text-content-secondary" />
{createElement(icon, { size: 16, strokeWidth: 1.8, 'aria-hidden': true, className: 'text-content-secondary' })}
<span className="flex items-baseline gap-1 tabular-nums">
<span className="text-body font-semibold text-content">{max}</span>
{min && <span className="text-caption text-content-faint">{min}</span>}
@@ -225,6 +225,7 @@ export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: Assis
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
useEffect(() => {
void loadStatus();
@@ -232,9 +233,10 @@ export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: Assis
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one asks again.
useEffect(() => {
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setPicked(null);
}, [open]);
}
const current = trip ?? picked;
const showPicked = useCallback(() => {
@@ -50,14 +50,7 @@ const DETOUR_FACTOR = 1.3;
const TIGHT_LEG_SLACK_MINUTES = 10;
export type HintRule =
| 'overlap'
| 'closed'
| 'outsideHours'
| 'tightLeg'
| 'gap'
| 'longLeg'
| 'noStay'
| 'unplacedBooking';
'overlap' | 'closed' | 'outsideHours' | 'tightLeg' | 'gap' | 'longLeg' | 'noStay' | 'unplacedBooking';
const RULE_ORDER: readonly HintRule[] = [
'overlap',
@@ -363,7 +356,7 @@ function legHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
{ lat: from.assignment.place.lat as number, lng: from.assignment.place.lng as number },
{ lat: to.assignment.place.lat as number, lng: to.assignment.place.lng as number }
);
const tooTight = tight ? null : tightLegHint(pd, from, to, km, cls);
const tooTight: AssistantHint | null = tight ? null : tightLegHint(pd, from, to, km, cls);
if (tooTight) {
tight = tooTight;
continue;
@@ -82,6 +82,19 @@ function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDa
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Adds a change to the change block before it, or starts one. A suggestion
* from the strip is a block of its own, decided apart from the changes before
* and after it, even where the answer joins them.
*/
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
const last = blocks[blocks.length - 1];
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
}
/** The steps block a new step joins: the last one, past any strip shown after it. */
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
for (let i = blocks.length - 1; i >= 0; i--) {
@@ -134,7 +147,6 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
const blocks: MessageBlock[] = [];
message.parts.forEach((part, index) => {
const last = blocks[blocks.length - 1];
if (part.type === 'text') {
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
return;
@@ -153,12 +165,7 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
if (part.approval || preview) {
const applied = receipts.get(part.toolCallId) ?? null;
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
// A suggestion from the strip is a block of its own, decided apart from
// the changes before and after it, even where the answer joins them.
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(part.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${part.toolCallId}`, items: [item] });
pushChange(blocks, item, fromStrip);
return;
}
const step: ToolStep = {
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useTranslation } from '../../i18n';
import { isApplePlatform } from '../shared/Kbd';
import { isApplePlatform } from '../../utils/applePlatform';
/** Whether a key press is the assistant's shortcut: ⌘K on a Mac, Ctrl+K elsewhere, either works on both. */
export function isAssistantHotkey(e: KeyboardEvent): boolean {
@@ -20,6 +20,18 @@ import { useTripStore } from '../../store/tripStore';
import AssistantShareDialog from './AssistantShareDialog';
import { useAssistantShare } from './useAssistantShare';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
@@ -73,8 +85,6 @@ function Harness({ message, writing = false }: { message: AssistantMessage; writ
);
}
const addToast = vi.fn();
function seed({ chat = true, threadId = 7 as number | null } = {}) {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
@@ -85,9 +95,7 @@ function seed({ chat = true, threadId = 7 as number | null } = {}) {
});
}
beforeEach(() => {
window.__addToast = addToast as unknown as typeof window.__addToast;
});
beforeEach(() => {});
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
@@ -95,7 +103,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
useAddonStore.setState(ADDONS, true);
usePermissionsStore.setState(PERMISSIONS, true);
delete window.__addToast;
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -14,6 +14,18 @@ import { TranslationProvider } from '../../i18n/TranslationContext';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAssistantThread } from './useAssistantThread';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
@@ -21,7 +33,6 @@ const resume = vi.fn(async () => {});
const cancel = vi.fn();
const deleteThread = vi.fn(async () => {});
const openThread = vi.fn(async () => {});
const addToast = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
@@ -45,13 +56,10 @@ function seed(threadId: number | null, retention: AiStatusResponse['retention'])
});
}
beforeEach(() => {
window.__addToast = addToast as unknown as typeof window.__addToast;
});
beforeEach(() => {});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
delete window.__addToast;
vi.clearAllMocks();
});
@@ -80,7 +88,7 @@ describe('useAssistantThread', () => {
it('FE-AI-THREAD-003: opens and deletes conversations of this trip only', async () => {
seed(7, { mode: 'days', days: 30 });
const { result } = renderHook(() => useAssistantThread(TRIP), { wrapper });
act(() => result.current.openThread(8));
await act(() => result.current.openThread(8));
expect(openThread).toHaveBeenCalledWith(TRIP, 8);
await act(() => result.current.removeThread());
expect(deleteThread).toHaveBeenCalledWith(TRIP, 7);
@@ -35,15 +35,12 @@ export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<El
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const key = photoKeyOf(place);
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
const [visible, setVisible] = useState(false);
// Without an IntersectionObserver every card counts as in view.
const [visible, setVisible] = useState(() => typeof IntersectionObserver === 'undefined');
useEffect(() => {
const el = ref.current;
if (!enabled || !key || !el) return;
if (typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
if (!enabled || !key || !el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
+11 -3
View File
@@ -39,11 +39,19 @@ function report(tripId: number | null, edges: Edges | null): void {
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
}
/** The trip whose view is reported: only while the assistant can run, so a map without it listens to nothing. */
function useReportedTrip(): number | null {
const available = useAssistantStore((s) => s.status?.available === true);
const tripId = useTripStore((s) => s.trip?.id ?? null);
return available ? tripId : null;
}
/** Leaflet: read from inside the map container, after each move and once on mount. */
export function useReportViewLeaflet(): void {
const map = useMap();
const tripId = useTripStore((s) => s.trip?.id ?? null);
const tripId = useReportedTrip();
useEffect(() => {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
@@ -71,10 +79,10 @@ interface ViewMap {
/** GL: the same, once the map is ready. */
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
const tripId = useTripStore((s) => s.trip?.id ?? null);
const tripId = useReportedTrip();
useEffect(() => {
const map = mapRef.current as Partial<ViewMap> | null;
if (!mapReady || !map?.on || !map.off || !map.getBounds) return;
if (tripId === null || !mapReady || !map?.on || !map.off || !map.getBounds) return;
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
const read = () => {
const b = getBounds();
@@ -202,6 +202,43 @@ function AssistantBehaviour() {
);
}
/** The connect panel, opened on purpose or because nothing is connected yet. */
function ConnectPanel({ S }: { S: AiConnections }) {
const { t } = useTranslation();
return (
<div className={`flex flex-col gap-3 ${S.view === 'setup' ? '' : 'border-t border-edge-faint pt-4'}`}>
<AiConnectPanel key={S.panelMode ?? 'any'} scope="user" onConnected={S.onConnected} initialMode={S.panelMode} />
{S.view !== 'setup' && (
<button type="button" onClick={S.closePanel} className={`${SETTINGS_BUTTON} self-start`} style={fs(13, 'body')}>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** How long chats are kept, and for an admin the way to the setting for everyone. */
function SectionFooter({ S }: { S: AiConnections }) {
const { t } = useTranslation();
const retention = S.view !== 'setup' && !!S.retentionText;
if (!S.isAdmin && !retention) return null;
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{S.view !== 'setup' && <SettingsHint>{S.retentionText}</SettingsHint>}
{S.isAdmin && (
<button
type="button"
onClick={S.openAdmin}
className="font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.setup.forEveryone')}
</button>
)}
</div>
);
}
/**
* Settings → Integrations → the user's AI connection: connect a provider, see
* which one answers, pick its model, leave again. The assistant runs on it and
@@ -242,42 +279,8 @@ export default function AiConnectionSection({
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{S.panel && (
<div className={`flex flex-col gap-3 ${S.view === 'setup' ? '' : 'border-t border-edge-faint pt-4'}`}>
<AiConnectPanel
key={S.panelMode ?? 'any'}
scope="user"
onConnected={S.onConnected}
initialMode={S.panelMode}
/>
{S.view !== 'setup' && (
<button
type="button"
onClick={S.closePanel}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(13, 'body')}
>
{t('common.cancel')}
</button>
)}
</div>
)}
{(S.isAdmin || (S.retentionText && S.view !== 'setup')) && (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{S.view !== 'setup' && <SettingsHint>{S.retentionText}</SettingsHint>}
{S.isAdmin && (
<button
type="button"
onClick={S.openAdmin}
className="font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.setup.forEveryone')}
</button>
)}
</div>
)}
{S.panel && <ConnectPanel S={S} />}
<SectionFooter S={S} />
<ConfirmDialog
isOpen={S.ownActions.confirmingDisconnect}
@@ -70,11 +70,7 @@ export function useAiModels(load: (() => Promise<AiModelInfo[]>) | null, connect
* The choices of a model picker: what the provider offers, the recommended
* one first with its badge, and the current model kept when it is not listed.
*/
export function modelOptions(
models: AiModelInfo[],
current: string,
labels: { free: string; recommended: string }
) {
export function modelOptions(models: AiModelInfo[], current: string, labels: { free: string; recommended: string }) {
const options = models.map((m) => ({
value: m.id,
label: m.label,
@@ -113,7 +109,10 @@ function useOwnConnection(own: AiConnectionView | null, reload: () => Promise<vo
return {
provider,
models: own
? modelOptions(models, own.model, { free: t('assistant.model.free'), recommended: t('assistant.model.recommended') })
? modelOptions(models, own.model, {
free: t('assistant.model.free'),
recommended: t('assistant.model.recommended'),
})
: [],
busy,
setModel: (model: string) => run(() => aiConnectionsRepo.setModel(model), 'common.saved'),
@@ -148,14 +147,10 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) {
/** The panel opened on purpose: to renew a sign-in, replace a key, or connect one's own beside the admin's. */
const [connecting, setConnecting] = useState<AiConnectMode | 'any' | null>(null);
const reload = async () => {
await loadStatus();
setLoaded(true);
};
const loadOnMount = useEffectEvent(() => void reload());
const reload = () => loadStatus().then(() => setLoaded(true));
useEffect(() => {
loadOnMount();
}, []);
void loadStatus().then(() => setLoaded(true));
}, [loadStatus]);
const onConnected = () => {
setConnecting(null);
@@ -181,10 +176,9 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) {
openWindow();
void navigate('/dashboard');
};
tripRepo.active().then(
({ trip }) => (trip ? void navigate(`/trips/${trip.id}?assistant=1`) : withoutTrip()),
withoutTrip
);
tripRepo
.active()
.then(({ trip }) => (trip ? void navigate(`/trips/${trip.id}?assistant=1`) : withoutTrip()), withoutTrip);
};
return {
+2 -1
View File
@@ -4,7 +4,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import Kbd, { isApplePlatform } from './Kbd';
import { isApplePlatform } from '../../utils/applePlatform';
import Kbd from './Kbd';
afterEach(() => vi.restoreAllMocks());
-5
View File
@@ -18,8 +18,3 @@ export default function Kbd({ keys, className = '' }: { keys: readonly string[];
</kbd>
);
}
/** Whether shortcuts here read with ⌘ rather than Ctrl. */
export function isApplePlatform(): boolean {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
}