diff --git a/client/agent-multi-select-combobox.tsx b/client/agent-multi-select-combobox.tsx new file mode 100644 index 0000000..75aa091 --- /dev/null +++ b/client/agent-multi-select-combobox.tsx @@ -0,0 +1,433 @@ +import { type Handle } from 'remix/component' +import { + colors, + radius, + shadows, + spacing, + transitions, + typography, +} from '#client/styles/tokens.ts' +import { type ManagedChatAgent } from '#shared/chat.ts' + +type AgentMultiSelectComboboxProps = { + id: string + agents: Array + selectedAgentIds: Array + disabled?: boolean + error?: string | null + isLoading?: boolean + onSelectionChange: (agentIds: Array) => void | Promise +} + +function normalizeSearchValue(value: string) { + return value.trim().toLowerCase() +} + +function buildSelectedAgentNames( + agents: Array, + selectedAgentIds: Array, +) { + const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)) + return selectedAgentIds + .map((agentId) => agentsById.get(agentId)?.name ?? null) + .filter((agentName): agentName is string => Boolean(agentName)) +} + +function resolveNextSelectedAgentIds(input: { + selectedAgentIds: Array + toggledAgentId: string +}) { + const selectedAgentIds = [...input.selectedAgentIds] + const selectedAgentIdSet = new Set(selectedAgentIds) + if (selectedAgentIdSet.has(input.toggledAgentId)) { + if (selectedAgentIds.length <= 1) { + return selectedAgentIds + } + return selectedAgentIds.filter((agentId) => agentId !== input.toggledAgentId) + } + return [...selectedAgentIds, input.toggledAgentId] +} + +export function AgentMultiSelectCombobox(handle: Handle) { + let isOpen = false + let search = '' + let highlightedAgentId: string | null = null + + function update() { + handle.update() + } + + function focusInput(inputId: string) { + void handle.queueTask(async () => { + const input = document.getElementById(inputId) + if (!(input instanceof HTMLInputElement)) return + input.focus() + input.select() + }) + } + + function focusButton(buttonId: string) { + void handle.queueTask(async () => { + const button = document.getElementById(buttonId) + if (!(button instanceof HTMLButtonElement)) return + button.focus() + }) + } + + function getPopover(popoverId: string) { + const popover = document.getElementById(popoverId) + return popover instanceof HTMLElement ? popover : null + } + + function openPopover(popoverId: string) { + const popover = getPopover(popoverId) + if (!popover || popover.matches(':popover-open')) return + popover.showPopover() + } + + function closePopover(popoverId: string) { + const popover = getPopover(popoverId) + if (!popover || !popover.matches(':popover-open')) return + popover.hidePopover() + } + + return (props: AgentMultiSelectComboboxProps) => { + const buttonId = `${props.id}-button` + const inputId = `${props.id}-search` + const listboxId = `${props.id}-listbox` + const popoverId = `${props.id}-popover` + const anchorName = `--${props.id}-anchor` + const selectedAgentCount = props.selectedAgentIds.length + const normalizedSearch = normalizeSearchValue(search) + const filteredAgents = props.agents.filter((agent) => { + if (!normalizedSearch) return true + return normalizeSearchValue(agent.name).includes(normalizedSearch) + }) + const selectedAgentNameList = buildSelectedAgentNames( + props.agents, + props.selectedAgentIds, + ) + const selectedAgentSummary = + selectedAgentNameList.length > 0 + ? selectedAgentNameList.join(', ') + : 'No agents selected' + const resolvedHighlightedAgentId = + filteredAgents.some((agent) => agent.id === highlightedAgentId) + ? highlightedAgentId + : filteredAgents[0]?.id ?? null + + function handlePopoverToggle(event: Event) { + if (!(event.currentTarget instanceof HTMLElement)) return + isOpen = event.currentTarget.matches(':popover-open') + if (isOpen) { + highlightedAgentId = filteredAgents[0]?.id ?? null + update() + focusInput(inputId) + return + } + search = '' + highlightedAgentId = null + update() + } + + function handleButtonKeyDown(event: KeyboardEvent) { + if (props.disabled) return + if ( + event.key !== 'ArrowDown' && + event.key !== 'ArrowUp' && + event.key !== 'Enter' && + event.key !== ' ' + ) { + return + } + event.preventDefault() + openPopover(popoverId) + } + + function handleSearchInput(event: Event) { + if (!(event.currentTarget instanceof HTMLInputElement)) return + search = event.currentTarget.value + const nextFilteredAgents = props.agents.filter((agent) => + normalizeSearchValue(agent.name).includes(normalizeSearchValue(search)), + ) + highlightedAgentId = nextFilteredAgents[0]?.id ?? null + update() + } + + function moveHighlight(direction: 1 | -1) { + if (filteredAgents.length === 0) return + const currentIndex = filteredAgents.findIndex( + (agent) => agent.id === resolvedHighlightedAgentId, + ) + const startIndex = currentIndex === -1 ? 0 : currentIndex + const nextIndex = + (startIndex + direction + filteredAgents.length) % filteredAgents.length + highlightedAgentId = filteredAgents[nextIndex]?.id ?? null + update() + } + + function commitToggle(agentId: string) { + const nextSelectedAgentIds = resolveNextSelectedAgentIds({ + selectedAgentIds: props.selectedAgentIds, + toggledAgentId: agentId, + }) + highlightedAgentId = agentId + props.onSelectionChange(nextSelectedAgentIds) + update() + } + + function handleSearchKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault() + closePopover(popoverId) + focusButton(buttonId) + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + moveHighlight(1) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + moveHighlight(-1) + return + } + if (event.key === 'Enter' || event.key === ' ') { + if (!resolvedHighlightedAgentId) return + event.preventDefault() + commitToggle(resolvedHighlightedAgentId) + } + } + + return ( +
+ +
+
+ + Included agents + +

+ {selectedAgentSummary} +

+
+ + {props.error ? ( +

+ {props.error} +

+ ) : null} +
+ {props.isLoading ? ( +

+ Loading agents... +

+ ) : filteredAgents.length === 0 ? ( +

+ No agents match your search. +

+ ) : ( + filteredAgents.map((agent) => { + const isSelected = props.selectedAgentIds.includes(agent.id) + const isHighlighted = agent.id === resolvedHighlightedAgentId + return ( + + ) + }) + )} +
+
+
+ ) + } +} diff --git a/client/app.tsx b/client/app.tsx index 44267ab..cbf41a2 100644 --- a/client/app.tsx +++ b/client/app.tsx @@ -1,13 +1,17 @@ import { type Handle } from 'remix/component' import { clientRoutes } from './routes/index.tsx' -import { getPathname, listenToRouterNavigation, Router } from './client-router.tsx' +import { + getPathname, + listenToRouterNavigation, + Router, +} from './client-router.tsx' import { fetchSessionInfo, type SessionInfo, type SessionStatus, } from './session.ts' import { buildAuthLink } from './auth-links.ts' -import { colors, spacing, typography } from './styles/tokens.ts' +import { colors, mq, spacing, typography } from './styles/tokens.ts' export function App(handle: Handle) { let session: SessionInfo | null = null @@ -97,6 +101,11 @@ export function App(handle: Handle) { minHeight: isChatLayout ? '100vh' : undefined, fontFamily: typography.fontFamily, boxSizing: 'border-box', + [mq.mobile]: { + padding: isChatLayout + ? `${spacing.md} ${spacing.md} ${spacing.sm}` + : spacing.lg, + }, }} >