mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: update search dialog with filter pills and default view (#25753)
Update the ChatSearchDialog with four interaction states: 1. **Default (empty)**: quick actions (New chat, Settings, Personal Skills, View usage) + recent chats list 2. **Focused**: filter-by dropdown overlay with clickable chips (Unread, Archived, PR status, Diff URL) 3. **Active search**: filter pills and/or freeform text with debounced live results showing "N results" count 4. **Parameterized filter**: incomplete pill (dashed border) for filters needing a value (e.g. `pr_status:`); Space or Enter commits the value into a solid pill Filter pills and freeform text are combinable. Backspace on empty text removes the last pill. The filter dropdown overlays content below and dismisses on blur or Escape. Changes: - `ChatSearchDialog.tsx`: manages structured filter state with separate `incompleteFilterKey` tracking, renders filter dropdown, passes recent chats and quick action callbacks - `ChatSearchInput.tsx`: renders completed filter pills (solid border, x dismiss) and incomplete pills (dashed border) inline with the text input - `ChatSearchResults.tsx`: default view shows quick actions + recent chats instead of the old help text; "No matching chats" is centered in the modal - `ChatsSidebar.tsx`: passes `recentChats`, `onNewChat`, `onOpenSettings` to the dialog <details> <summary>Implementation notes</summary> - No backend changes; reuses existing `chatSearch()` API and `normalizeChatSearchInput()` normalizer - Filter state uses a clean model: `filters` array for committed filters + `incompleteFilterKey` string for the parameterized filter being typed; `freeText` serves dual purpose (filter value when incomplete key is set, otherwise freeform search) - The filter dropdown is absolutely positioned (`top-full`) below the search input, dismisses via container `onBlur` with `contains(relatedTarget)` check - Quick action links (Personal Skills, View usage) use `Link` components that close the dialog on click > Generated with [Coder Agents](https://coder.com/agents) </details>
This commit is contained in:
@@ -151,6 +151,7 @@ export const ChatsSidebar: FC<ChatsSidebarProps> = (props) => {
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={onSearchDialogOpenChange}
|
||||
location={location}
|
||||
recentChats={chats}
|
||||
/>
|
||||
{onRenameTitle && (
|
||||
<RenameChatDialog
|
||||
|
||||
+204
@@ -91,6 +91,7 @@ const meta: Meta<typeof ChatSearchDialog> = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: fn(),
|
||||
recentChats: mockChats,
|
||||
location: {
|
||||
pathname: "/agents",
|
||||
search: "",
|
||||
@@ -106,6 +107,8 @@ const meta: Meta<typeof ChatSearchDialog> = {
|
||||
{ path: "/agents", useStoryElement: true },
|
||||
{ path: "/agents/:agentId", useStoryElement: true },
|
||||
{ path: "/agents/settings", useStoryElement: true },
|
||||
{ path: "/agents/settings/personal-skills", useStoryElement: true },
|
||||
{ path: "/agents/analytics", useStoryElement: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -188,6 +191,13 @@ export const RefreshingResults: Story = {
|
||||
// spinner were always visible.
|
||||
expect(body.queryByLabelText("Searching chats")).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the first debounced API call has been registered before
|
||||
// clearing, so the clear+retype cycle triggers a distinct second call
|
||||
// rather than coalescing within a single debounce window.
|
||||
await waitFor(() => {
|
||||
expect(API.experimental.getChats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await userEvent.clear(searchInput);
|
||||
await userEvent.type(searchInput, "review");
|
||||
|
||||
@@ -338,3 +348,197 @@ export const ErrorState: Story = {
|
||||
await expect(await body.findByRole("alert")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorStateWithStackTrace: Story = {
|
||||
beforeEach: () => {
|
||||
const err = new Error(
|
||||
"NetworkError: Failed to fetch chats from the server API endpoint /api/v2/chats",
|
||||
);
|
||||
err.stack = [
|
||||
"Error: NetworkError: Failed to fetch chats from the server API endpoint /api/v2/chats",
|
||||
" at fetchChats (http://localhost:6006/src/api/queries/chats.ts:42:11)",
|
||||
" at async queryFn (http://localhost:6006/src/api/queries/chats.ts:58:14)",
|
||||
" at async Object.fetchQuery (http://localhost:6006/node_modules/@tanstack/react-query/src/queryClient.ts:198:16)",
|
||||
" at async ChatSearchDialogContent (http://localhost:6006/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx:180:20)",
|
||||
" at async renderWithHooks (http://localhost:6006/node_modules/react-dom/cjs/react-dom.development.js:14985:18)",
|
||||
" at async mountIndeterminateComponent (http://localhost:6006/node_modules/react-dom/cjs/react-dom.development.js:17811:13)",
|
||||
" at async beginWork (http://localhost:6006/node_modules/react-dom/cjs/react-dom.development.js:19049:16)",
|
||||
].join("\n");
|
||||
spyOn(API.experimental, "getChats").mockRejectedValue(err);
|
||||
},
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.type(
|
||||
body.getByRole("combobox", { name: "Search chats" }),
|
||||
"title:",
|
||||
);
|
||||
const alert = await body.findByRole("alert");
|
||||
await expect(alert).toBeInTheDocument();
|
||||
|
||||
// Open the stack trace details and verify it stays contained.
|
||||
const details = body.getByText("Stack Trace");
|
||||
await userEvent.click(details);
|
||||
await expect(body.getByText(/fetchChats/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interaction states: default view, filter pills, dropdown.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DefaultViewWithRecentChats: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
await expect(await body.findByText("Recent chats")).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByText("Fix race condition in auth middleware"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const FilterDropdownOnFocus: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const toggleButton = body.getByRole("button", { name: "Toggle filters" });
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
await expect(await body.findByText("Filter by")).toBeInTheDocument();
|
||||
await expect(body.getByText("Unread")).toBeInTheDocument();
|
||||
await expect(body.getByText("Archived")).toBeInTheDocument();
|
||||
await expect(body.getByText("PR status")).toBeInTheDocument();
|
||||
await expect(body.getByText("Diff URL")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const BooleanFilterPill: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const toggleButton = body.getByRole("button", { name: "Toggle filters" });
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
await userEvent.click(await body.findByText("Unread"));
|
||||
|
||||
await expect(await body.findByText("has_unread:true")).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByRole("button", { name: "Remove has_unread filter" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.experimental.getChats).toHaveBeenCalledWith({
|
||||
limit: CHAT_SEARCH_LIMIT,
|
||||
q: "has_unread:true",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ParameterizedFilterPill: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChats").mockResolvedValue(mockChats);
|
||||
},
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const searchInput = body.getByRole("combobox", { name: "Search chats" });
|
||||
const toggleButton = body.getByRole("button", { name: "Toggle filters" });
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
await userEvent.click(await body.findByText("PR status"));
|
||||
|
||||
await expect(await body.findByText("pr_status:")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchInput);
|
||||
await userEvent.type(searchInput, "open ");
|
||||
|
||||
await expect(await body.findByText("pr_status:open")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.experimental.getChats).toHaveBeenCalledWith({
|
||||
limit: CHAT_SEARCH_LIMIT,
|
||||
q: "pr_status:open",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ParameterizedFilterPillEnterCommit: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChats").mockResolvedValue(mockChats);
|
||||
},
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const searchInput = body.getByRole("combobox", { name: "Search chats" });
|
||||
const toggleButton = body.getByRole("button", { name: "Toggle filters" });
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
await userEvent.click(await body.findByText("PR status"));
|
||||
|
||||
await expect(await body.findByText("pr_status:")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchInput);
|
||||
await userEvent.type(searchInput, "closed");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
await expect(await body.findByText("pr_status:closed")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.experimental.getChats).toHaveBeenCalledWith({
|
||||
limit: CHAT_SEARCH_LIMIT,
|
||||
q: "pr_status:closed",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const BackspaceRemovesFilter: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const searchInput = body.getByRole("combobox", { name: "Search chats" });
|
||||
const toggleButton = body.getByRole("button", { name: "Toggle filters" });
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
await userEvent.click(await body.findByText("Unread"));
|
||||
await expect(await body.findByText("has_unread:true")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchInput);
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
await waitFor(() => {
|
||||
expect(body.queryByText("has_unread:true")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TypedFilterAutoDetection: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const searchInput = body.getByRole("combobox", { name: "Search chats" });
|
||||
|
||||
await userEvent.type(searchInput, "has_unread:true ");
|
||||
|
||||
await expect(await body.findByText("has_unread:true")).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByRole("button", { name: "Remove has_unread filter" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const CombinedFilterAndText: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
const searchInput = body.getByRole("combobox", { name: "Search chats" });
|
||||
const toggleButton = body.getByRole("button", { name: "Toggle filters" });
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
await userEvent.click(await body.findByText("Unread"));
|
||||
await expect(await body.findByText("has_unread:true")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchInput);
|
||||
await userEvent.type(searchInput, "Fix");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.experimental.getChats).toHaveBeenCalledWith({
|
||||
limit: CHAT_SEARCH_LIMIT,
|
||||
q: 'has_unread:true title:"Fix"',
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,72 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CircleDotIcon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC, RefObject } from "react";
|
||||
import { type KeyboardEventHandler, useId, useRef, useState } from "react";
|
||||
import {
|
||||
type KeyboardEventHandler,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { keepPreviousData, useQuery } from "react-query";
|
||||
import { type Location, useNavigate } from "react-router";
|
||||
import { chatSearch } from "#/api/queries/chats";
|
||||
import type { Chat } from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "#/components/Dialog/Dialog";
|
||||
import { useDebouncedValue } from "#/hooks/debounce";
|
||||
import { ChatSearchInput } from "./ChatSearchInput";
|
||||
import { ChatSearchInput, type SearchFilter } from "./ChatSearchInput";
|
||||
import { ChatSearchResults } from "./ChatSearchResults";
|
||||
import { normalizeChatSearchInput } from "./searchQuery";
|
||||
|
||||
// Filter definitions. Filters with a defaultValue are inserted as complete
|
||||
// pills (e.g. has_unread:true). Filters without one are inserted as
|
||||
// incomplete pills so the user can type the value.
|
||||
type FilterDefinition = {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly icon: FC<{ className?: string }>;
|
||||
readonly defaultValue: string | null;
|
||||
};
|
||||
|
||||
const FILTER_DEFINITIONS: readonly FilterDefinition[] = [
|
||||
{
|
||||
key: "has_unread",
|
||||
label: "Unread",
|
||||
icon: CircleDotIcon,
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "archived",
|
||||
label: "Archived",
|
||||
icon: ArchiveIcon,
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "pr_status",
|
||||
label: "PR status",
|
||||
icon: FileTextIcon,
|
||||
defaultValue: null,
|
||||
},
|
||||
{ key: "diff_url", label: "Diff URL", icon: LinkIcon, defaultValue: null },
|
||||
];
|
||||
|
||||
// Set of recognized filter keys for detecting typed filter patterns
|
||||
// (e.g. "has_unread:true" typed directly into the input). Derived from
|
||||
// FILTER_DEFINITIONS; the backend equivalent lives in searchQuery.ts as
|
||||
// passthroughChatSearchFilterKeys.
|
||||
const KNOWN_FILTER_KEYS = new Set(FILTER_DEFINITIONS.map((def) => def.key));
|
||||
|
||||
type ChatSearchDialogProps = {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly focusInputOnOpen?: boolean;
|
||||
readonly location: Location;
|
||||
readonly recentChats?: readonly Chat[];
|
||||
};
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 500;
|
||||
@@ -20,13 +74,17 @@ const SEARCH_DEBOUNCE_MS = 500;
|
||||
export const ChatSearchDialog: FC<ChatSearchDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
focusInputOnOpen = true,
|
||||
location,
|
||||
recentChats = [],
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
ref={contentRef}
|
||||
// `top` is pinned (rather than the default `top-1/2 -translate-y-1/2`)
|
||||
// so the dialog doesn't re-center when its content height changes
|
||||
// between the empty hint, loading skeleton, and results states.
|
||||
@@ -41,11 +99,17 @@ export const ChatSearchDialog: FC<ChatSearchDialogProps> = ({
|
||||
// dialog resizing visibly as results stream in.
|
||||
style={{ animation: "none", transition: "none" }}
|
||||
aria-describedby={undefined}
|
||||
tabIndex={-1}
|
||||
// When opened from the mobile sidebar button, skip autofocusing
|
||||
// the input so the virtual keyboard doesn't push the dialog
|
||||
// off-screen. Focus the dialog container instead to keep the
|
||||
// element in the accessibility tree.
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (focusInputOnOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
contentRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<ChatSearchDialogContent
|
||||
@@ -53,31 +117,90 @@ export const ChatSearchDialog: FC<ChatSearchDialogProps> = ({
|
||||
onOpenChange={onOpenChange}
|
||||
location={location}
|
||||
inputRef={inputRef}
|
||||
recentChats={recentChats}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type ChatSearchDialogContentProps = ChatSearchDialogProps & {
|
||||
type ChatSearchDialogContentProps = Omit<
|
||||
ChatSearchDialogProps,
|
||||
"focusInputOnOpen"
|
||||
> & {
|
||||
readonly inputRef: RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
// Build a raw query string from structured filters + freeform text, then
|
||||
// normalize it through the existing parser that the backend expects.
|
||||
const buildQuery = (
|
||||
filters: readonly SearchFilter[],
|
||||
freeText: string,
|
||||
): string | undefined => {
|
||||
const parts: string[] = [];
|
||||
for (const f of filters) {
|
||||
if (f.value !== null && f.value !== "") {
|
||||
// Strip internal quotes before wrapping so the resulting
|
||||
// key:"value" token stays well-formed for the backend.
|
||||
const stripped = f.value.replaceAll('"', "");
|
||||
const v = stripped.includes(" ") ? `"${stripped}"` : stripped;
|
||||
parts.push(`${f.key}:${v}`);
|
||||
}
|
||||
}
|
||||
if (freeText.trim()) {
|
||||
parts.push(freeText.trim());
|
||||
}
|
||||
const raw = parts.join(" ");
|
||||
return normalizeChatSearchInput(raw);
|
||||
};
|
||||
|
||||
const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
location,
|
||||
inputRef,
|
||||
recentChats = [],
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
||||
const [freeText, setFreeText] = useState("");
|
||||
// Tracks the key of a parameterized filter being typed (e.g. "pr_status").
|
||||
// While set, freeText holds the in-progress value and the pill shows as
|
||||
// incomplete (dashed border). Space or Enter commits the value.
|
||||
const [incompleteFilterKey, setIncompleteFilterKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [selectedChatIndex, setSelectedChatIndex] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
const listboxId = useId();
|
||||
const debouncedInput = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS);
|
||||
const normalizedQuery = normalizeChatSearchInput(debouncedInput);
|
||||
const hasQuery = inputValue.trim() !== "" && normalizedQuery !== undefined;
|
||||
|
||||
// Build the full filter list for query building. When an incomplete filter
|
||||
// has text, include it so debounced search can run against partial values.
|
||||
const effectiveFilters = useMemo(
|
||||
() =>
|
||||
incompleteFilterKey && freeText.trim()
|
||||
? [...filters, { key: incompleteFilterKey, value: freeText.trim() }]
|
||||
: filters,
|
||||
[filters, incompleteFilterKey, freeText],
|
||||
);
|
||||
const hasActiveSearch = effectiveFilters.length > 0 || freeText.trim() !== "";
|
||||
|
||||
const debouncedFreeText = useDebouncedValue(freeText, SEARCH_DEBOUNCE_MS);
|
||||
const debouncedFilters = useDebouncedValue(
|
||||
effectiveFilters,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
);
|
||||
// When typing into an incomplete filter, only send the filter (not
|
||||
// freeText as bare title search).
|
||||
// When freeText is cleared (e.g. after committing a filter), zero
|
||||
// queryFreeText immediately instead of waiting for the debounce to
|
||||
// flush. Otherwise the stale debouncedFreeText leaks into the query.
|
||||
const queryFreeText =
|
||||
incompleteFilterKey || !freeText.trim() ? "" : debouncedFreeText;
|
||||
const normalizedQuery = buildQuery(debouncedFilters, queryFreeText);
|
||||
const hasQuery = hasActiveSearch && normalizedQuery !== undefined;
|
||||
|
||||
const searchQuery = useQuery({
|
||||
...chatSearch(normalizedQuery ?? ""),
|
||||
@@ -85,14 +208,21 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const resultCount = searchQuery.data?.length ?? 0;
|
||||
// Use search results count when a query is active, otherwise count
|
||||
// recent chats so keyboard navigation works in the default view too.
|
||||
const recentChatsSlice = (recentChats ?? []).slice(0, 10);
|
||||
const resultCount = hasQuery
|
||||
? (searchQuery.data?.length ?? 0)
|
||||
: recentChatsSlice.length;
|
||||
const safeSelectedChatIndex =
|
||||
selectedChatIndex !== undefined && selectedChatIndex < resultCount
|
||||
? selectedChatIndex
|
||||
: undefined;
|
||||
const selectedChat =
|
||||
safeSelectedChatIndex !== undefined
|
||||
? searchQuery.data?.[safeSelectedChatIndex]
|
||||
? hasQuery
|
||||
? searchQuery.data?.[safeSelectedChatIndex]
|
||||
: recentChatsSlice[safeSelectedChatIndex]
|
||||
: undefined;
|
||||
const activeResultId =
|
||||
safeSelectedChatIndex !== undefined
|
||||
@@ -109,9 +239,130 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
|
||||
searchQuery.isFetching &&
|
||||
searchQuery.isPlaceholderData &&
|
||||
!showResultsLoading;
|
||||
|
||||
const commitIncompleteFilter = () => {
|
||||
if (incompleteFilterKey && freeText.trim()) {
|
||||
setFilters((prev) => [
|
||||
...prev,
|
||||
{ key: incompleteFilterKey, value: freeText.trim() },
|
||||
]);
|
||||
setFreeText("");
|
||||
setIncompleteFilterKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addFilter = (def: FilterDefinition) => {
|
||||
if (
|
||||
filters.some((f) => f.key === def.key) ||
|
||||
incompleteFilterKey === def.key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
commitIncompleteFilter();
|
||||
|
||||
if (def.defaultValue !== null) {
|
||||
setFilters((prev) => [
|
||||
...prev,
|
||||
{ key: def.key, value: def.defaultValue },
|
||||
]);
|
||||
} else {
|
||||
setIncompleteFilterKey(def.key);
|
||||
setFreeText("");
|
||||
}
|
||||
setIsDropdownOpen(false);
|
||||
setSelectedChatIndex(undefined);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
};
|
||||
|
||||
const removeFilter = (key: string) => {
|
||||
if (incompleteFilterKey === key) {
|
||||
setIncompleteFilterKey(null);
|
||||
setFreeText("");
|
||||
} else {
|
||||
setFilters((prev) => prev.filter((f) => f.key !== key));
|
||||
}
|
||||
setSelectedChatIndex(undefined);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setFreeText(value);
|
||||
setSelectedChatIndex(undefined);
|
||||
};
|
||||
|
||||
// Build the display filters for ChatSearchInput: completed filters plus
|
||||
// the incomplete one (shown with dashed border).
|
||||
const displayFilters: SearchFilter[] = incompleteFilterKey
|
||||
? [...filters, { key: incompleteFilterKey, value: null }]
|
||||
: filters;
|
||||
|
||||
const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = (
|
||||
event,
|
||||
) => {
|
||||
if (
|
||||
(event.key === " " || event.key === "Enter") &&
|
||||
incompleteFilterKey &&
|
||||
freeText.trim()
|
||||
) {
|
||||
event.preventDefault();
|
||||
commitIncompleteFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(event.key === " " || event.key === "Enter") &&
|
||||
!incompleteFilterKey &&
|
||||
freeText.trim()
|
||||
) {
|
||||
const activeKeys = new Set(filters.map((f) => f.key));
|
||||
const tokens = freeText.trim().split(/\s+/);
|
||||
const newFilters: SearchFilter[] = [];
|
||||
const remaining: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
const colonIndex = token.indexOf(":");
|
||||
if (colonIndex > 0 && colonIndex < token.length - 1) {
|
||||
const key = token.slice(0, colonIndex);
|
||||
const val = token.slice(colonIndex + 1);
|
||||
if (KNOWN_FILTER_KEYS.has(key)) {
|
||||
// Drop duplicate filter keys silently instead of
|
||||
// letting them fall through to freeform text.
|
||||
if (!activeKeys.has(key)) {
|
||||
newFilters.push({ key, value: val });
|
||||
activeKeys.add(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
remaining.push(token);
|
||||
}
|
||||
|
||||
if (newFilters.length > 0) {
|
||||
event.preventDefault();
|
||||
setFilters((prev) => [...prev, ...newFilters]);
|
||||
setFreeText(remaining.join(" "));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Backspace" && freeText === "") {
|
||||
if (incompleteFilterKey) {
|
||||
setIncompleteFilterKey(null);
|
||||
return;
|
||||
}
|
||||
if (filters.length > 0) {
|
||||
const lastFilter = filters[filters.length - 1];
|
||||
removeFilter(lastFilter.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && isDropdownOpen) {
|
||||
setIsDropdownOpen(false);
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
if (resultCount === 0) {
|
||||
return;
|
||||
@@ -145,21 +396,38 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
|
||||
return (
|
||||
<>
|
||||
<DialogTitle className="sr-only">Search chats</DialogTitle>
|
||||
<ChatSearchInput
|
||||
activeResultId={activeResultId}
|
||||
hasResults={resultCount > 0}
|
||||
inputRef={inputRef}
|
||||
listboxId={listboxId}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
setInputValue(event.target.value);
|
||||
setSelectedChatIndex(undefined);
|
||||
{/* Wrap input + dropdown so onBlur on the container closes
|
||||
the dropdown, but clicks within the dropdown (which is
|
||||
inside the same container) don't trigger blur. */}
|
||||
<div
|
||||
className="relative"
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
>
|
||||
<ChatSearchInput
|
||||
activeResultId={activeResultId}
|
||||
hasResults={resultCount > 0}
|
||||
inputRef={inputRef}
|
||||
listboxId={listboxId}
|
||||
filters={displayFilters}
|
||||
value={freeText}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onRemoveFilter={removeFilter}
|
||||
isDropdownOpen={isDropdownOpen}
|
||||
onToggleDropdown={() => setIsDropdownOpen((prev) => !prev)}
|
||||
/>
|
||||
{isDropdownOpen && (
|
||||
<FilterDropdown filters={displayFilters} onSelectFilter={addFilter} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatSearchResults
|
||||
chats={searchQuery.data}
|
||||
recentChats={recentChats}
|
||||
error={searchQuery.error}
|
||||
hasQuery={hasQuery}
|
||||
location={location}
|
||||
@@ -167,8 +435,45 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
|
||||
selectedChatIndex={safeSelectedChatIndex}
|
||||
showLoading={showResultsLoading}
|
||||
isRefreshing={isRefreshing}
|
||||
onSelectChat={closeDialog}
|
||||
onDismiss={closeDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter dropdown: appears on focus, shows clickable filter chips.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FilterDropdown: FC<{
|
||||
readonly filters: readonly SearchFilter[];
|
||||
readonly onSelectFilter: (def: FilterDefinition) => void;
|
||||
}> = ({ filters, onSelectFilter }) => {
|
||||
const activeKeys = new Set(filters.map((f) => f.key));
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-full z-10 mt-1 rounded-md border border-solid border-border bg-surface-primary p-3 shadow-md">
|
||||
<h3 className="m-0 mb-2 text-xs font-medium text-content-secondary">
|
||||
Filter by
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_DEFINITIONS.map((def) => {
|
||||
const Icon = def.icon;
|
||||
const isActive = activeKeys.has(def.key);
|
||||
return (
|
||||
<Button
|
||||
key={def.key}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isActive}
|
||||
onClick={() => onSelectFilter(def)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{def.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { ListFilterIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import type {
|
||||
ChangeEventHandler,
|
||||
FC,
|
||||
KeyboardEventHandler,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export type SearchFilter = {
|
||||
readonly key: string;
|
||||
readonly value: string | null;
|
||||
};
|
||||
|
||||
type ChatSearchInputProps = {
|
||||
readonly activeResultId: string | undefined;
|
||||
readonly hasResults: boolean;
|
||||
readonly inputRef: RefObject<HTMLInputElement | null>;
|
||||
readonly listboxId: string;
|
||||
readonly filters: readonly SearchFilter[];
|
||||
readonly value: string;
|
||||
readonly onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
readonly onKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
readonly onRemoveFilter: (key: string) => void;
|
||||
readonly isDropdownOpen: boolean;
|
||||
readonly onToggleDropdown: () => void;
|
||||
};
|
||||
|
||||
export const ChatSearchInput: FC<ChatSearchInputProps> = ({
|
||||
@@ -22,20 +31,58 @@ export const ChatSearchInput: FC<ChatSearchInputProps> = ({
|
||||
hasResults,
|
||||
inputRef,
|
||||
listboxId,
|
||||
filters,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onRemoveFilter,
|
||||
isDropdownOpen,
|
||||
onToggleDropdown,
|
||||
}) => {
|
||||
const completedFilters = filters.filter((f) => f.value !== null);
|
||||
const incompleteFilter = filters.find((f) => f.value === null);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-content-secondary" />
|
||||
<Input
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-10 w-full items-center gap-1.5 rounded-md border border-solid border-border-default bg-surface-primary px-3",
|
||||
"focus-within:ring-2 focus-within:ring-content-link",
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 text-content-secondary" />
|
||||
{completedFilters.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-solid border-border bg-surface-secondary px-2 py-0.5 text-xs text-content-secondary"
|
||||
>
|
||||
<span>
|
||||
{f.key}:{f.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFilter(f.key);
|
||||
}}
|
||||
className="inline-flex cursor-pointer items-center border-none bg-transparent p-0 text-content-secondary hover:text-content-primary"
|
||||
aria-label={`Remove ${f.key} filter`}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{incompleteFilter && (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-dashed border-border bg-surface-secondary px-2 py-0.5 text-xs text-content-secondary">
|
||||
{incompleteFilter.key}:
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search chats..."
|
||||
className="h-10 border-border-default bg-surface-primary pl-9 pr-3 placeholder:text-content-disabled"
|
||||
placeholder={filters.length > 0 ? "" : "Search chats..."}
|
||||
className="min-w-[60px] flex-1 border-none bg-transparent py-2 text-sm text-content-primary outline-none placeholder:text-content-disabled"
|
||||
aria-label="Search chats"
|
||||
role="combobox"
|
||||
aria-controls={hasResults ? listboxId : undefined}
|
||||
@@ -43,6 +90,18 @@ export const ChatSearchInput: FC<ChatSearchInputProps> = ({
|
||||
aria-haspopup="listbox"
|
||||
aria-activedescendant={activeResultId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDropdown}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0 text-content-secondary hover:text-content-primary",
|
||||
isDropdownOpen && "text-content-primary",
|
||||
)}
|
||||
aria-label="Toggle filters"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<ListFilterIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getChatDisplayConfig } from "../tree/statusConfig";
|
||||
|
||||
type ChatSearchResultsProps = {
|
||||
readonly chats: readonly Chat[] | undefined;
|
||||
readonly recentChats: readonly Chat[];
|
||||
readonly error: unknown;
|
||||
readonly hasQuery: boolean;
|
||||
readonly location: Location;
|
||||
@@ -19,11 +20,23 @@ type ChatSearchResultsProps = {
|
||||
readonly selectedChatIndex: number | undefined;
|
||||
readonly showLoading: boolean;
|
||||
readonly isRefreshing: boolean;
|
||||
readonly onSelectChat: () => void;
|
||||
readonly onDismiss: () => void;
|
||||
};
|
||||
|
||||
const RECENT_CHATS_COUNT = 10;
|
||||
|
||||
// !block overrides Radix ScrollArea viewport's display:table so truncated text can shrink.
|
||||
const SCROLL_AREA_PROPS = {
|
||||
className:
|
||||
"h-[300px] w-full [&_[data-radix-scroll-area-viewport]>div]:!block",
|
||||
scrollBarClassName: "w-[0.375rem]",
|
||||
viewportClassName: "pr-3",
|
||||
viewportTabIndex: -1,
|
||||
};
|
||||
|
||||
export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
|
||||
chats,
|
||||
recentChats,
|
||||
error,
|
||||
hasQuery,
|
||||
location,
|
||||
@@ -31,25 +44,28 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
|
||||
selectedChatIndex,
|
||||
showLoading,
|
||||
isRefreshing,
|
||||
onSelectChat,
|
||||
onDismiss,
|
||||
}) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-[260px]">
|
||||
<ErrorAlert error={error} />
|
||||
<ErrorAlert
|
||||
error={error}
|
||||
className="max-h-[340px] overflow-y-auto [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_pre]:w-auto [&_pre]:min-w-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasQuery) {
|
||||
return (
|
||||
<div className="min-h-[260px]">
|
||||
<div className="pt-2 text-sm text-content-secondary">
|
||||
Type to search by title, or use filters like{" "}
|
||||
<code>has_unread:true</code>, <code>archived:true</code>,{" "}
|
||||
<code>pr_status:open</code>, or <code>diff_url:"..."</code>.
|
||||
</div>
|
||||
</div>
|
||||
<DefaultView
|
||||
recentChats={recentChats}
|
||||
location={location}
|
||||
listboxId={listboxId}
|
||||
selectedChatIndex={selectedChatIndex}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,33 +87,25 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
|
||||
return (
|
||||
<div className="min-h-[260px]">
|
||||
<div className="space-y-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-sm text-content-secondary">
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
<span>{resultSummary}</span>
|
||||
{isRefreshing && (
|
||||
<Spinner
|
||||
loading
|
||||
size="sm"
|
||||
className="text-content-secondary"
|
||||
className="ml-1.5 inline-block align-text-bottom text-content-secondary"
|
||||
aria-label="Searching chats"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<ScrollArea
|
||||
// `!block` overrides the Radix ScrollArea viewport wrapper's inline
|
||||
// `display: table` so that descendants using `truncate` can shrink
|
||||
// inside the scroll container instead of forcing the table to grow.
|
||||
className="h-[300px] w-full [&_[data-radix-scroll-area-viewport]>div]:!block"
|
||||
scrollBarClassName="w-[0.375rem]"
|
||||
viewportClassName="pr-3"
|
||||
viewportTabIndex={-1}
|
||||
>
|
||||
<ScrollArea {...SCROLL_AREA_PROPS}>
|
||||
<ChatSearchResultsList
|
||||
chats={chats}
|
||||
location={location}
|
||||
listboxId={listboxId}
|
||||
selectedChatIndex={selectedChatIndex}
|
||||
showLoading={showLoading}
|
||||
onSelectChat={onSelectChat}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -105,13 +113,72 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default view: recent chats (shown when no query is active).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DefaultViewProps = {
|
||||
readonly recentChats: readonly Chat[];
|
||||
readonly location: Location;
|
||||
readonly listboxId: string;
|
||||
readonly selectedChatIndex: number | undefined;
|
||||
readonly onDismiss: () => void;
|
||||
};
|
||||
|
||||
const DefaultView: FC<DefaultViewProps> = ({
|
||||
recentChats,
|
||||
location,
|
||||
listboxId,
|
||||
selectedChatIndex,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const visibleRecentChats = recentChats.slice(0, RECENT_CHATS_COUNT);
|
||||
|
||||
return (
|
||||
<div className="min-h-[260px]">
|
||||
<div className="space-y-3">
|
||||
{visibleRecentChats.length > 0 && (
|
||||
<div>
|
||||
<h3 className="m-0 mb-3 text-sm font-medium text-content-secondary">
|
||||
Recent chats
|
||||
</h3>
|
||||
<ScrollArea {...SCROLL_AREA_PROPS}>
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label="Recent chats"
|
||||
className="space-y-1"
|
||||
>
|
||||
{visibleRecentChats.map((chat, index) => (
|
||||
<ChatSearchResultRow
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
isSelected={selectedChatIndex === index}
|
||||
location={location}
|
||||
onSelect={onDismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Results list and row components.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ChatSearchResultsListProps = {
|
||||
readonly chats: readonly Chat[] | undefined;
|
||||
readonly location: Location;
|
||||
readonly listboxId: string;
|
||||
readonly selectedChatIndex: number | undefined;
|
||||
readonly showLoading: boolean;
|
||||
readonly onSelectChat: () => void;
|
||||
readonly onDismiss: () => void;
|
||||
};
|
||||
|
||||
const ChatSearchResultsList: FC<ChatSearchResultsListProps> = ({
|
||||
@@ -120,7 +187,7 @@ const ChatSearchResultsList: FC<ChatSearchResultsListProps> = ({
|
||||
listboxId,
|
||||
selectedChatIndex,
|
||||
showLoading,
|
||||
onSelectChat,
|
||||
onDismiss,
|
||||
}) => {
|
||||
if (showLoading) {
|
||||
return <ChatSearchResultsSkeleton />;
|
||||
@@ -128,9 +195,9 @@ const ChatSearchResultsList: FC<ChatSearchResultsListProps> = ({
|
||||
|
||||
if ((chats?.length ?? 0) === 0) {
|
||||
return (
|
||||
<p className="px-1.5 py-2 text-sm text-content-secondary">
|
||||
No matching chats
|
||||
</p>
|
||||
<div className="flex h-[300px] items-center justify-center">
|
||||
<p className="text-sm text-content-secondary">No matching chats</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,7 +215,7 @@ const ChatSearchResultsList: FC<ChatSearchResultsListProps> = ({
|
||||
id={`${listboxId}-option-${index}`}
|
||||
isSelected={selectedChatIndex === index}
|
||||
location={location}
|
||||
onSelect={onSelectChat}
|
||||
onSelect={onDismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user