supermemory/packages/ui/components/combobox.tsx
MaheshtheDev 1706752668 mobile responsiveness pass + connections reauth fix (#959)
Nova mobile pass
- viewport: viewportFit cover for iOS safe-area-inset
- safe-area utilities (pb-safe, pt-safe, bottom-safe-5, scroll-fade-x) in globals.css
- chat FAB pinned above iPhone home indicator; chat sidebar widths responsive across sm/md/lg with min() clamps
- chat input CoT panel max-h capped via min(60dvh, 420px)
- header tab strip swapped from visible scrollbar to scroll-fade-x mask + snap-x
- nova empty state uses svh on mobile, dvh from sm up

Add-memory modal rebuilt for mobile
- mobile shell switched from fullscreen Dialog to vaul Drawer at 85svh with swipe-down dismissal and scaled background
- in-modal header removed; tabs moved to the bottom of the sheet for thumb reach
- four tab compactLabels: Note, Links, Files, Connections
- desktop tabs now render only when !isMobile (no DOM duplication)
- note/link content state lifted to parent so switching tabs preserves typed input
- NoteContent snapshots initialContent via lazy useState so the editor isn't reset on every keystroke
- shared Drawer base uses rounded-t-xl
- removed legacy pt-4 on tab content for mobile

Connections — replace expiresAt with sync-run health
- new useConnectionHealth hook reads the latest sync run and matches auth-failure patterns; backend errorKind field still needed (TODO)
- regex tightened so 401/403 require co-occurring auth/token/grant context; refresh_token requires expired/revoked/invalid/missing qualifier
- badge label changed Disconnected -> Needs reauth
- Reconnect button replaces the sync action when needsReauth, kicks off the same OAuth flow
- per-row reconnect tracking via mutation.variables instead of a single shared id (no race when multiple rows clicked)
- fallback toast when authLink is missing so the spinner can't get stuck
- sync history panel timeline capped at max-h-260 with internal scroll
- useSyncRuns no longer refetches on mount; cache (30s) actually applies, cutting N requests per modal open
2026-05-17 21:49:23 +00:00

161 lines
3.9 KiB
TypeScript

"use client"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@ui/components/command"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { Check, ChevronsUpDown, X } from "lucide-react"
import * as React from "react"
interface Option {
value: string
label: string
}
interface ComboboxProps {
options: Option[]
onSelect: (value: string) => void
onSubmit: (newName: string) => void
selectedValues: string[]
setSelectedValues: React.Dispatch<React.SetStateAction<string[]>>
className?: string
placeholder?: string
triggerClassName?: string
}
export function Combobox({
options,
onSelect,
onSubmit,
selectedValues,
setSelectedValues,
className,
placeholder = "Select...",
triggerClassName,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState("")
const handleSelect = (value: string) => {
onSelect(value)
setOpen(false)
setInputValue("")
}
const handleCreate = () => {
if (inputValue.trim()) {
onSubmit(inputValue)
setOpen(false)
setInputValue("")
}
}
const handleRemove = (valueToRemove: string) => {
setSelectedValues((prev) => prev.filter((value) => value !== valueToRemove))
}
const filteredOptions = options.filter(
(option) => !selectedValues.includes(option.value),
)
const isNewValue =
inputValue.trim() &&
!options.some(
(option) => option.label.toLowerCase() === inputValue.toLowerCase(),
)
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
{/** biome-ignore lint/a11y/useSemanticElements: shadcn*/}
<Button
aria-expanded={open}
className={cn(
"w-full justify-between min-h-10 h-auto",
triggerClassName,
)}
role="combobox"
variant="outline"
>
<div className="flex flex-wrap gap-1 items-center w-full">
{selectedValues.length > 0 ? (
selectedValues.map((value) => {
const option = options.find((opt) => opt.value === value)
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 bg-secondary text-sm rounded-md"
key={value}
>
{option?.label || value}
<button
className="hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleRemove(value)
}}
type="button"
>
<X className="h-3 w-3" />
</button>
</span>
)
})
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
<ChevronsUpDown className="opacity-50 ml-2 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("w-full p-0", className)}>
<Command>
<CommandInput
className="h-9"
onValueChange={setInputValue}
placeholder="Search or create..."
value={inputValue}
/>
<CommandList>
{filteredOptions.length === 0 && !isNewValue && (
<CommandEmpty>No options found.</CommandEmpty>
)}
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleSelect(option.value)}
value={option.value}
>
{option.label}
<Check
className={cn(
"ml-auto",
selectedValues.includes(option.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
{isNewValue && (
<CommandItem
className="text-primary cursor-pointer"
onSelect={handleCreate}
>
Create "{inputValue}"
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}