mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user