diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx index 9bc3e69bdf..042c1417c0 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx @@ -151,6 +151,7 @@ export const ChatsSidebar: FC = (props) => { open={isSearchDialogOpen} onOpenChange={onSearchDialogOpenChange} location={location} + recentChats={chats} /> {onRenameTitle && ( = { args: { open: true, onOpenChange: fn(), + recentChats: mockChats, location: { pathname: "/agents", search: "", @@ -106,6 +107,8 @@ const meta: Meta = { { 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"', + }); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx index 912fe40271..25aa41dca7 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx @@ -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 = ({ open, onOpenChange, + focusInputOnOpen = true, location, + recentChats = [], }) => { + const contentRef = useRef(null); const inputRef = useRef(null); return ( = ({ // 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 }); }} > = ({ onOpenChange={onOpenChange} location={location} inputRef={inputRef} + recentChats={recentChats} /> ); }; -type ChatSearchDialogContentProps = ChatSearchDialogProps & { +type ChatSearchDialogContentProps = Omit< + ChatSearchDialogProps, + "focusInputOnOpen" +> & { readonly inputRef: RefObject; }; +// 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 = ({ open, onOpenChange, location, inputRef, + recentChats = [], }) => { const navigate = useNavigate(); - const [inputValue, setInputValue] = useState(""); + const [filters, setFilters] = useState([]); + 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( + 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 = ({ 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 = ({ 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 = ( 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 = ({ return ( <> Search chats - 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. */} +
{ + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsDropdownOpen(false); + } }} - onKeyDown={handleInputKeyDown} - /> + > + 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 && ( + + )} +
= ({ 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 ( +
+

+ Filter by +

+
+ {FILTER_DEFINITIONS.map((def) => { + const Icon = def.icon; + const isActive = activeKeys.has(def.key); + return ( + + ); + })} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx index 1d5cc66dc0..b134f8854b 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx @@ -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; readonly listboxId: string; + readonly filters: readonly SearchFilter[]; readonly value: string; readonly onChange: ChangeEventHandler; readonly onKeyDown: KeyboardEventHandler; + readonly onRemoveFilter: (key: string) => void; + readonly isDropdownOpen: boolean; + readonly onToggleDropdown: () => void; }; export const ChatSearchInput: FC = ({ @@ -22,20 +31,58 @@ export const ChatSearchInput: FC = ({ 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 ( -
- - + + {completedFilters.map((f) => ( + + + {f.key}:{f.value} + + + + ))} + {incompleteFilter && ( + + {incompleteFilter.key}: + + )} + 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 = ({ aria-haspopup="listbox" aria-activedescendant={activeResultId} /> +
); }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx index e4a2cb170a..c49c900332 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx @@ -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 = ({ chats, + recentChats, error, hasQuery, location, @@ -31,25 +44,28 @@ export const ChatSearchResults: FC = ({ selectedChatIndex, showLoading, isRefreshing, - onSelectChat, + onDismiss, }) => { if (error) { return (
- +
); } if (!hasQuery) { return ( -
-
- Type to search by title, or use filters like{" "} - has_unread:true, archived:true,{" "} - pr_status:open, or diff_url:"...". -
-
+ ); } @@ -71,33 +87,25 @@ export const ChatSearchResults: FC = ({ return (
-

+

{resultSummary} {isRefreshing && ( )}

- +
@@ -105,13 +113,72 @@ export const ChatSearchResults: FC = ({ ); }; +// --------------------------------------------------------------------------- +// 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 = ({ + recentChats, + location, + listboxId, + selectedChatIndex, + onDismiss, +}) => { + const visibleRecentChats = recentChats.slice(0, RECENT_CHATS_COUNT); + + return ( +
+
+ {visibleRecentChats.length > 0 && ( +
+

+ Recent chats +

+ +
+ {visibleRecentChats.map((chat, index) => ( + + ))} +
+
+
+ )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 = ({ @@ -120,7 +187,7 @@ const ChatSearchResultsList: FC = ({ listboxId, selectedChatIndex, showLoading, - onSelectChat, + onDismiss, }) => { if (showLoading) { return ; @@ -128,9 +195,9 @@ const ChatSearchResultsList: FC = ({ if ((chats?.length ?? 0) === 0) { return ( -

- No matching chats -

+
+

No matching chats

+
); } @@ -148,7 +215,7 @@ const ChatSearchResultsList: FC = ({ id={`${listboxId}-option-${index}`} isSelected={selectedChatIndex === index} location={location} - onSelect={onSelectChat} + onSelect={onDismiss} /> ))}