mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
Use the shared composer for new tasks, including project and worktree selection, rich mentions and slash commands, and remembered task settings. Save project preferences after successful task updates. Show known agent models by name and use Default for unknown runtime defaults. Co-Authored-By: Paperclip <noreply@paperclip.ing>
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
import { Check, ChevronsUpDown, X } from "lucide-react";
|
|
import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { fuzzyTextMatchesQuery, normalizeSearchText, scoreFuzzyTextFields } from "@/lib/searchable-select";
|
|
import { cn } from "@/lib/utils";
|
|
import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle";
|
|
|
|
export interface SearchableSelectOption<TValue extends string = string> {
|
|
key: string;
|
|
value: TValue;
|
|
label: string;
|
|
title?: string;
|
|
searchText?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface SearchableSelectGroup<TValue extends string = string, TOption extends SearchableSelectOption<TValue> = SearchableSelectOption<TValue>> {
|
|
id: string;
|
|
label?: string;
|
|
options: readonly TOption[];
|
|
}
|
|
|
|
interface SearchableSelectRenderState {
|
|
selected: boolean;
|
|
}
|
|
|
|
export interface SearchableSelectProps<
|
|
TValue extends string = string,
|
|
TOption extends SearchableSelectOption<TValue> = SearchableSelectOption<TValue>,
|
|
> {
|
|
value: TValue | "";
|
|
groups: readonly SearchableSelectGroup<TValue, TOption>[];
|
|
onValueChange: (value: TValue, option: TOption) => void | boolean | { close?: boolean };
|
|
placeholder: string;
|
|
searchPlaceholder?: string;
|
|
emptyMessage?: string;
|
|
loadingMessage?: string;
|
|
loading?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
triggerClassName?: string;
|
|
contentClassName?: string;
|
|
align?: "start" | "center" | "end";
|
|
contentWidth?: "trigger" | "auto";
|
|
renderValue?: (option: TOption | null) => ReactNode;
|
|
renderOption?: (option: TOption, state: SearchableSelectRenderState) => ReactNode;
|
|
deriveGroups?: (
|
|
query: string,
|
|
groups: readonly SearchableSelectGroup<TValue, TOption>[],
|
|
) => readonly SearchableSelectGroup<TValue, TOption>[];
|
|
filterOption?: (option: TOption, query: string) => boolean;
|
|
scoreOption?: (option: TOption, query: string) => number | null;
|
|
disablePortal?: boolean;
|
|
/** Heading for the large mobile selector modal. Defaults to the placeholder. */
|
|
mobileTitle?: string;
|
|
triggerAriaLabel?: string;
|
|
modal?: boolean;
|
|
contentStyle?: CSSProperties;
|
|
listFooter?: ReactNode;
|
|
/**
|
|
* Optional pinned "creatable" item rendered at the bottom of the list,
|
|
* regardless of the query (used e.g. by the secret picker's
|
|
* `+ Create secret "<query>"…` affordance). `render` receives the live
|
|
* query so the label can echo it; `onSelect` fires with the query and the
|
|
* popover closes.
|
|
*/
|
|
createItem?: {
|
|
render: (query: string) => ReactNode;
|
|
onSelect: (query: string) => void;
|
|
};
|
|
}
|
|
|
|
function defaultFilterOption(option: SearchableSelectOption, query: string) {
|
|
return fuzzyTextMatchesQuery(`${option.label} ${option.searchText ?? ""}`, query);
|
|
}
|
|
|
|
function defaultScoreOption(option: SearchableSelectOption, query: string) {
|
|
return scoreFuzzyTextFields([
|
|
{ text: option.label, weight: 0 },
|
|
{ text: option.searchText, weight: 20 },
|
|
], query);
|
|
}
|
|
|
|
export function SearchableSelect<
|
|
TValue extends string = string,
|
|
TOption extends SearchableSelectOption<TValue> = SearchableSelectOption<TValue>,
|
|
>({
|
|
value,
|
|
groups,
|
|
onValueChange,
|
|
placeholder,
|
|
searchPlaceholder = "Search...",
|
|
emptyMessage = "No options found.",
|
|
loadingMessage = "Loading...",
|
|
loading = false,
|
|
disabled = false,
|
|
className,
|
|
triggerClassName,
|
|
contentClassName,
|
|
align = "start",
|
|
contentWidth = "trigger",
|
|
renderValue,
|
|
renderOption,
|
|
deriveGroups,
|
|
filterOption = defaultFilterOption,
|
|
scoreOption,
|
|
disablePortal,
|
|
mobileTitle,
|
|
triggerAriaLabel,
|
|
modal,
|
|
contentStyle,
|
|
listFooter,
|
|
createItem,
|
|
}: SearchableSelectProps<TValue, TOption>) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const mobileViewportStyle = useMobileEntityPickerViewportStyle();
|
|
const pointerFocusRef = useRef(false);
|
|
const suppressNextTriggerFocusRef = useRef(false);
|
|
|
|
const selectedOption = useMemo(() => {
|
|
for (const group of groups) {
|
|
const option = group.options.find((candidate) => candidate.value === value);
|
|
if (option) return option;
|
|
}
|
|
return null;
|
|
}, [groups, value]);
|
|
|
|
const visibleGroups = useMemo(
|
|
() => (deriveGroups ? deriveGroups(query, groups) : groups),
|
|
[deriveGroups, groups, query],
|
|
);
|
|
|
|
const filteredGroups = useMemo(() => {
|
|
if (loading) return [];
|
|
const normalizedQuery = normalizeSearchText(query);
|
|
return visibleGroups
|
|
.map((group) => {
|
|
const options = group.options
|
|
.map((option, index) => {
|
|
if (!normalizedQuery) return { option, index, score: 0 };
|
|
|
|
if (!filterOption(option, query)) return null;
|
|
|
|
if (scoreOption) {
|
|
const score = scoreOption(option, query);
|
|
return score === null ? null : { option, index, score };
|
|
}
|
|
|
|
return {
|
|
option,
|
|
index,
|
|
score: defaultScoreOption(option, query) ?? Number.MAX_SAFE_INTEGER,
|
|
};
|
|
})
|
|
.filter((entry): entry is { option: TOption; index: number; score: number } => entry !== null);
|
|
|
|
if (normalizedQuery) {
|
|
options.sort((a, b) => a.score - b.score || a.index - b.index);
|
|
}
|
|
|
|
return {
|
|
...group,
|
|
options: options.map((entry) => entry.option),
|
|
};
|
|
})
|
|
.filter((group) => group.options.length > 0);
|
|
}, [filterOption, loading, query, scoreOption, visibleGroups]);
|
|
|
|
const hasOptions = filteredGroups.some((group) => group.options.length > 0);
|
|
|
|
function closePopover({ suppressTriggerFocus = false }: { suppressTriggerFocus?: boolean } = {}) {
|
|
if (suppressTriggerFocus) {
|
|
suppressNextTriggerFocusRef.current = true;
|
|
}
|
|
setOpen(false);
|
|
setQuery("");
|
|
}
|
|
|
|
function selectOption(option: TOption) {
|
|
if (option.disabled) return;
|
|
suppressNextTriggerFocusRef.current = true;
|
|
const result = onValueChange(option.value, option);
|
|
if (result === false || (typeof result === "object" && result?.close === false)) {
|
|
return;
|
|
}
|
|
closePopover();
|
|
}
|
|
|
|
return (
|
|
<Popover
|
|
modal={modal}
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (!next) setQuery("");
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={disabled}
|
|
onPointerDown={() => {
|
|
pointerFocusRef.current = true;
|
|
}}
|
|
onFocus={() => {
|
|
const shouldIgnoreFocus = pointerFocusRef.current || suppressNextTriggerFocusRef.current;
|
|
pointerFocusRef.current = false;
|
|
suppressNextTriggerFocusRef.current = false;
|
|
if (shouldIgnoreFocus) {
|
|
return;
|
|
}
|
|
setOpen(true);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && open) {
|
|
event.preventDefault();
|
|
closePopover();
|
|
}
|
|
}}
|
|
aria-expanded={open}
|
|
aria-label={triggerAriaLabel}
|
|
role="combobox"
|
|
className={cn("w-full justify-between overflow-hidden", className, triggerClassName)}
|
|
>
|
|
<span className={cn("min-w-0 flex-1 truncate text-left", !selectedOption && "text-muted-foreground")}>
|
|
{renderValue ? renderValue(selectedOption) : selectedOption?.label ?? placeholder}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 size-4 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
data-mobile-entity-picker=""
|
|
aria-label={mobileTitle ?? placeholder}
|
|
style={{ ...mobileViewportStyle, ...contentStyle }}
|
|
align={align}
|
|
collisionPadding={16}
|
|
disablePortal={disablePortal}
|
|
className={cn(
|
|
"p-0",
|
|
contentWidth === "trigger"
|
|
? "w-(--radix-popover-trigger-width) min-w-56 max-w-(--sz-calc-23)"
|
|
: "w-72 max-w-(--sz-calc-23)",
|
|
contentClassName,
|
|
)}
|
|
onKeyDownCapture={(event) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
closePopover({ suppressTriggerFocus: true });
|
|
}
|
|
}}
|
|
onCloseAutoFocus={() => {
|
|
// Modal outside dismissal restores focus to the trigger. Keep that
|
|
// restore from reopening the picker, without swallowing a later Tab.
|
|
suppressNextTriggerFocusRef.current = true;
|
|
queueMicrotask(() => { suppressNextTriggerFocusRef.current = false; });
|
|
}}
|
|
>
|
|
<div data-mobile-entity-picker-header="" className="hidden items-center justify-between border-b border-border px-4 py-3">
|
|
<span className="text-base font-semibold text-foreground">{mobileTitle ?? placeholder}</span>
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
aria-label="Close selector"
|
|
onClick={() => closePopover({ suppressTriggerFocus: true })}
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</div>
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
value={query}
|
|
onValueChange={setQuery}
|
|
placeholder={searchPlaceholder}
|
|
/>
|
|
<CommandList
|
|
className="overscroll-contain touch-pan-y"
|
|
onWheelCapture={(event) => {
|
|
if (event.currentTarget.scrollHeight > event.currentTarget.clientHeight) {
|
|
event.stopPropagation();
|
|
}
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{loadingMessage}</div>
|
|
) : (
|
|
<>
|
|
{!hasOptions && !createItem ? <CommandEmpty>{emptyMessage}</CommandEmpty> : null}
|
|
{!hasOptions && createItem ? (
|
|
<div className="px-3 py-3 text-center text-xs text-muted-foreground">{emptyMessage}</div>
|
|
) : null}
|
|
{filteredGroups.map((group) => (
|
|
<CommandGroup key={group.id} heading={group.label}>
|
|
{group.options.map((option) => {
|
|
const selected = option.value === value;
|
|
return (
|
|
<CommandItem
|
|
key={option.key}
|
|
value={option.key}
|
|
disabled={option.disabled}
|
|
onSelect={() => selectOption(option)}
|
|
>
|
|
{renderOption
|
|
? renderOption(option, { selected })
|
|
: <span className="min-w-0 truncate">{option.label}</span>}
|
|
<Check className={cn("ml-auto size-4", selected ? "opacity-100" : "opacity-0")} />
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
))}
|
|
{createItem ? (
|
|
<CommandGroup className="border-t border-border">
|
|
<CommandItem
|
|
value="__searchable_select_create__"
|
|
onSelect={() => {
|
|
const q = query;
|
|
suppressNextTriggerFocusRef.current = true;
|
|
closePopover();
|
|
// Defer to the next macrotask so this popover fully
|
|
// closes (and Radix returns focus to the trigger)
|
|
// before the consumer opens its own popover. Firing
|
|
// synchronously lets the close's focus-return land as a
|
|
// `focusOutside` on the newly-opened popover, which
|
|
// Radix would immediately dismiss (see PAP-12476).
|
|
window.setTimeout(() => createItem.onSelect(q), 0);
|
|
}}
|
|
>
|
|
{createItem.render(query)}
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
) : null}
|
|
{listFooter}
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|