Add resume button for process_lost runs on agent detail page. Fix activity row text overflow with truncation. Pass entityTitleMap to Dashboard activity feed. Fix InlineEntitySelector stealing focus on close when advancing to next field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
190 lines
6.6 KiB
TypeScript
190 lines
6.6 KiB
TypeScript
import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { Check } from "lucide-react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { cn } from "../lib/utils";
|
|
|
|
export interface InlineEntityOption {
|
|
id: string;
|
|
label: string;
|
|
searchText?: string;
|
|
}
|
|
|
|
interface InlineEntitySelectorProps {
|
|
value: string;
|
|
options: InlineEntityOption[];
|
|
placeholder: string;
|
|
noneLabel: string;
|
|
searchPlaceholder: string;
|
|
emptyMessage: string;
|
|
onChange: (id: string) => void;
|
|
onConfirm?: () => void;
|
|
className?: string;
|
|
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
|
|
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
|
}
|
|
|
|
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
|
function InlineEntitySelector(
|
|
{
|
|
value,
|
|
options,
|
|
placeholder,
|
|
noneLabel,
|
|
searchPlaceholder,
|
|
emptyMessage,
|
|
onChange,
|
|
onConfirm,
|
|
className,
|
|
renderTriggerValue,
|
|
renderOption,
|
|
},
|
|
ref,
|
|
) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const shouldPreventCloseAutoFocusRef = useRef(false);
|
|
|
|
const allOptions = useMemo<InlineEntityOption[]>(
|
|
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
|
|
[noneLabel, options],
|
|
);
|
|
|
|
const filteredOptions = useMemo(() => {
|
|
const term = query.trim().toLowerCase();
|
|
if (!term) return allOptions;
|
|
return allOptions.filter((option) => {
|
|
const haystack = `${option.label} ${option.searchText ?? ""}`.toLowerCase();
|
|
return haystack.includes(term);
|
|
});
|
|
}, [allOptions, query]);
|
|
|
|
const currentOption = options.find((option) => option.id === value) ?? null;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const selectedIndex = filteredOptions.findIndex((option) => option.id === value);
|
|
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
|
}, [filteredOptions, open, value]);
|
|
|
|
const commitSelection = (index: number, moveNext: boolean) => {
|
|
const option = filteredOptions[index] ?? filteredOptions[0];
|
|
if (option) onChange(option.id);
|
|
shouldPreventCloseAutoFocusRef.current = moveNext;
|
|
setOpen(false);
|
|
setQuery("");
|
|
if (moveNext && onConfirm) {
|
|
requestAnimationFrame(() => {
|
|
onConfirm();
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (!next) setQuery("");
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
className,
|
|
)}
|
|
onFocus={() => setOpen(true)}
|
|
>
|
|
{renderTriggerValue
|
|
? renderTriggerValue(currentOption)
|
|
: (currentOption?.label ?? <span className="text-muted-foreground">{placeholder}</span>)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
className="w-[min(20rem,calc(100vw-2rem))] p-1"
|
|
onOpenAutoFocus={(event) => {
|
|
event.preventDefault();
|
|
inputRef.current?.focus();
|
|
}}
|
|
onCloseAutoFocus={(event) => {
|
|
if (!shouldPreventCloseAutoFocusRef.current) return;
|
|
event.preventDefault();
|
|
shouldPreventCloseAutoFocusRef.current = false;
|
|
}}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground/60"
|
|
placeholder={searchPlaceholder}
|
|
value={query}
|
|
onChange={(event) => {
|
|
setQuery(event.target.value);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
setHighlightedIndex((current) =>
|
|
filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length,
|
|
);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
setHighlightedIndex((current) => {
|
|
if (filteredOptions.length === 0) return 0;
|
|
return current <= 0 ? filteredOptions.length - 1 : current - 1;
|
|
});
|
|
return;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
commitSelection(highlightedIndex, true);
|
|
return;
|
|
}
|
|
if (event.key === "Tab" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
commitSelection(highlightedIndex, true);
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setOpen(false);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="max-h-56 overflow-y-auto overscroll-contain py-1">
|
|
{filteredOptions.length === 0 ? (
|
|
<p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p>
|
|
) : (
|
|
filteredOptions.map((option, index) => {
|
|
const isSelected = option.id === value;
|
|
const isHighlighted = index === highlightedIndex;
|
|
return (
|
|
<button
|
|
key={option.id || "__none__"}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
|
|
isHighlighted && "bg-accent",
|
|
)}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
onClick={() => commitSelection(index, true)}
|
|
>
|
|
{renderOption ? renderOption(option, isSelected) : <span className="truncate">{option.label}</span>}
|
|
<Check className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground", isSelected ? "opacity-100" : "opacity-0")} />
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
},
|
|
);
|