mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or OpenRouter key) or use the one the admin set up, including a custom OpenAI-compatible server. It is an addon and only shows while a connection is usable. The chat runs on the existing MCP tools, pinned to the trip. Every change is held as a change card the user applies or discards, and applied changes can be undone. On the desktop it is a floating window that can be moved and resized; on the phone it is fullscreen. Search results show as place cards that can go onto a day, weather lookups as a strip, proposed places as ghost markers on the map with a before/after switch, and an answer can be shared into the trip chat. Booking files can be dropped in and are read into change cards.
This commit is contained in:
@@ -270,9 +270,11 @@ export const PILL = 'inline-flex flex-none items-center gap-1.5 rounded-full bg-
|
||||
export function DialogSection({ label, action, className, children }: { label: ReactNode; action?: ReactNode; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={className}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{/* A fixed height, so a section with an action (an upload pill) starts its content on the
|
||||
same line as its neighbour without one; the action is centred and may overhang. */}
|
||||
<div className="mb-2 flex h-4 items-center gap-2">
|
||||
<div className={EYEBROW} style={fs(9.5)}>{label}</div>
|
||||
{action && <span className="ms-auto">{action}</span>}
|
||||
{action && <span className="ms-auto flex items-center">{action}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* FE-KBD-001 to FE-KBD-002
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import Kbd, { isApplePlatform } from './Kbd';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('Kbd', () => {
|
||||
it('FE-KBD-001: draws one key chip per key, in order, inside one kbd', () => {
|
||||
const { container } = render(<Kbd keys={['Ctrl', 'K']} />);
|
||||
const kbd = container.querySelector('kbd');
|
||||
expect(kbd).not.toBeNull();
|
||||
expect(Array.from(kbd?.children ?? []).map((k) => k.textContent)).toEqual(['Ctrl', 'K']);
|
||||
});
|
||||
|
||||
it('FE-KBD-002: reads the platform off the user agent', () => {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)');
|
||||
expect(isApplePlatform()).toBe(true);
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
|
||||
expect(isApplePlatform()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* A keyboard shortcut as the keys to press, one quiet chip per key: ["⌘", "K"]
|
||||
* reads as two keys, not as the text "⌘K". The chips take the colour of the
|
||||
* text around them, so the same shortcut sits in a tooltip, a menu row or a
|
||||
* button without a palette of its own.
|
||||
*/
|
||||
export default function Kbd({ keys, className = '' }: { keys: readonly string[]; className?: string }) {
|
||||
return (
|
||||
<kbd className={`inline-flex items-center gap-0.5 font-[inherit] ${className}`}>
|
||||
{keys.map((key, i) => (
|
||||
<span
|
||||
key={`${key}-${i}`}
|
||||
className="inline-flex min-w-[1.5em] items-center justify-center rounded border border-edge-secondary px-1 text-caption leading-[1.5] opacity-80"
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether shortcuts here read with ⌘ rather than Ctrl. */
|
||||
export function isApplePlatform(): boolean {
|
||||
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4TIP-001 to FE-W4TIP-015
|
||||
// FE-W4TIP-001 to FE-W4TIP-017
|
||||
import { createRef, useRef } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
||||
@@ -194,3 +194,22 @@ describe('Tooltip — inside a component that owns the ref', () => {
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'nowrap' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip — with a shortcut', () => {
|
||||
it('FE-W4TIP-016: shows the keys beside the label, one chip per key', () => {
|
||||
render(<Tooltip label="Assistant" shortcut={['⌘', 'K']}><button>x</button></Tooltip>)
|
||||
hover(screen.getByRole('button'))
|
||||
|
||||
const tip = screen.getByRole('tooltip')
|
||||
expect(tip).toHaveTextContent('Assistant')
|
||||
const keys = tip.querySelectorAll('kbd > span')
|
||||
expect(Array.from(keys).map(k => k.textContent)).toEqual(['⌘', 'K'])
|
||||
})
|
||||
|
||||
it('FE-W4TIP-017: an empty shortcut leaves the plain label', () => {
|
||||
render(<Tooltip label="Assistant" shortcut={[]}><button>x</button></Tooltip>)
|
||||
hover(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByRole('tooltip').querySelector('kbd')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Kbd from './Kbd'
|
||||
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
@@ -8,10 +9,12 @@ interface TooltipProps {
|
||||
placement?: Placement
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
/** The keys that do the same, shown beside the label: ["⌘", "K"]. */
|
||||
shortcut?: readonly string[]
|
||||
children: React.ReactElement
|
||||
}
|
||||
|
||||
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
|
||||
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, children }: TooltipProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
@@ -100,7 +103,12 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{shortcut && shortcut.length > 0 ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{label}
|
||||
<Kbd keys={shortcut} />
|
||||
</span>
|
||||
) : label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user