fix: truncate search results (#25566)

1. truncates search results
2. display a loading spinner when retrieving new search results when
existing results are displayed
3. Improve dialog resizing behavior
This commit is contained in:
Jaayden Halko
2026-05-21 20:42:32 +07:00
committed by GitHub
parent 873aa0da6a
commit 2a45262fa2
4 changed files with 140 additions and 14 deletions
@@ -58,6 +58,22 @@ const mockChats: Chat[] = [
},
},
];
const overflowMockChats: Chat[] = [
{
...mockChat,
id: "chat-long-1",
title:
"Review this PR and respond to every inline comment with detailed notes about selected row behavior in Table.tsx",
last_turn_summary:
"Posted review on PR #25069 with 10 inline comments covering 1 P2 issue, 4 P3s, and 2 observations.",
updated_at: "2026-05-20T09:30:00.000Z",
has_unread: false,
diff_status: {
...mockDiffStatus,
chat_id: "chat-long-1",
},
},
];
const cappedMockChats: Chat[] = Array.from(
{ length: CHAT_SEARCH_LIMIT },
(_, index) => ({
@@ -146,6 +162,81 @@ export const Results: Story = {
},
};
export const RefreshingResults: Story = {
beforeEach: () => {
let requestCount = 0;
spyOn(API.experimental, "getChats").mockImplementation(() => {
requestCount += 1;
if (requestCount === 1) {
return Promise.resolve(mockChats);
}
return new Promise<Chat[]>((_resolve) => {
// Keep request pending to show the refresh indicator with stale results.
});
});
},
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "Fix");
await expect(
await body.findByText("Fix race condition in auth middleware"),
).toBeInTheDocument();
// While the first query is in its steady state the inline refresh spinner
// must be absent. Without this assertion, the test would still pass if the
// spinner were always visible.
expect(body.queryByLabelText("Searching chats")).not.toBeInTheDocument();
await userEvent.clear(searchInput);
await userEvent.type(searchInput, "review");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledTimes(2);
});
await expect(body.getByLabelText("Searching chats")).toBeInTheDocument();
await expect(
body.getByText("Fix race condition in auth middleware"),
).toBeInTheDocument();
},
};
export const OverflowResults: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockResolvedValue(overflowMockChats);
},
play: async () => {
const body = within(document.body);
await userEvent.type(
body.getByRole("combobox", { name: "Search chats" }),
"review",
);
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"review"',
});
});
const result = await body.findByRole("option", {
name: /Review this PR and respond/i,
});
const summary = await body.findByText(/Posted review on PR #25069/i);
const dialog = result.closest('[role="dialog"]');
if (!dialog) {
throw new Error("Expected search result to render in a dialog");
}
const dialogRight = Math.ceil(dialog.getBoundingClientRect().right);
expect(Math.ceil(result.getBoundingClientRect().right)).toBeLessThanOrEqual(
dialogRight,
);
expect(
Math.ceil(summary.getBoundingClientRect().right),
).toBeLessThanOrEqual(dialogRight);
},
};
export const CappedResults: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockResolvedValue(cappedMockChats);
@@ -165,8 +256,9 @@ export const CappedResults: Story = {
await expect(
await body.findByText(
(_content, element) =>
element?.textContent?.replace(/\s+/g, " ").trim() ===
`Showing first ${CHAT_SEARCH_LIMIT} results.`,
element?.tagName === "P" &&
element.textContent?.replace(/\s+/g, " ").trim() ===
`Showing first ${CHAT_SEARCH_LIMIT} results.`,
),
).toBeInTheDocument();
},
@@ -27,7 +27,19 @@ export const ChatSearchDialog: FC<ChatSearchDialogProps> = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[560px] gap-4 border-border-default bg-surface-primary p-6 sm:p-6"
// `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.
// 218px is half of the tallest content height (~436px: p-6 padding +
// input + gap-4 + summary + space-y-3 + the 300px scroll area in
// ChatSearchResults). The max(1rem, ...) clamp keeps the dialog
// fully visible on short viewports.
className="top-[max(1rem,_calc(50%_-_218px))] w-[calc(100vw-2rem)] max-w-[560px] translate-y-0 gap-4 border-border-default bg-surface-primary p-6 sm:p-6"
// Suppress the open/close animation. The `animate-in`/`animate-out`
// rules applied via CVA in `dialogVariants` outrank Tailwind class
// overrides, so we disable them with an inline style to avoid the
// dialog resizing visibly as results stream in.
style={{ animation: "none", transition: "none" }}
aria-describedby={undefined}
onOpenAutoFocus={(event) => {
event.preventDefault();
@@ -92,6 +104,11 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
hasQuery &&
(searchQuery.isLoading ||
(searchQuery.isFetching && (searchQuery.data?.length ?? 0) === 0));
const isRefreshing =
hasQuery &&
searchQuery.isFetching &&
searchQuery.isPlaceholderData &&
!showResultsLoading;
const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = (
event,
) => {
@@ -149,6 +166,7 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
listboxId={listboxId}
selectedChatIndex={safeSelectedChatIndex}
showLoading={showResultsLoading}
isRefreshing={isRefreshing}
onSelectChat={closeDialog}
/>
</>
@@ -27,7 +27,7 @@ export const ChatSearchInput: FC<ChatSearchInputProps> = ({
onKeyDown,
}) => {
return (
<div className="relative">
<div className="relative min-w-0">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-content-secondary" />
<Input
ref={inputRef}
@@ -5,6 +5,7 @@ import type { Chat } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { shortRelativeTime } from "#/utils/time";
import { getChatDisplayConfig } from "../tree/statusConfig";
@@ -17,6 +18,7 @@ type ChatSearchResultsProps = {
readonly listboxId: string;
readonly selectedChatIndex: number | undefined;
readonly showLoading: boolean;
readonly isRefreshing: boolean;
readonly onSelectChat: () => void;
};
@@ -28,6 +30,7 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
listboxId,
selectedChatIndex,
showLoading,
isRefreshing,
onSelectChat,
}) => {
if (error) {
@@ -68,9 +71,22 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
return (
<div className="min-h-[260px]">
<div className="space-y-3">
<p className="text-sm text-content-secondary">{resultSummary}</p>
<p className="inline-flex items-center gap-1.5 text-sm text-content-secondary">
<span>{resultSummary}</span>
{isRefreshing && (
<Spinner
loading
size="sm"
className="text-content-secondary"
aria-label="Searching chats"
/>
)}
</p>
<ScrollArea
className="h-[300px]"
// `!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}
@@ -184,20 +200,18 @@ const ChatSearchResultRow: FC<ChatSearchResultRowProps> = ({
to={{ pathname: `/agents/${chat.id}`, search: location.search }}
onClick={onSelect}
className={cn(
"flex items-start gap-2 rounded-md px-1.5 py-1 text-content-secondary no-underline hover:bg-surface-tertiary/40 hover:text-content-primary",
"grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2 rounded-md px-1.5 py-1 text-content-secondary no-underline hover:bg-surface-tertiary/40 hover:text-content-primary",
isSelected && "bg-surface-tertiary/40 text-content-primary",
)}
>
<StatusIcon
className={cn("mt-1 h-3.5 w-3.5 shrink-0", statusClassName)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm text-content-primary">
{chat.title}
</span>
<div className="min-w-0">
<div className="truncate text-sm text-content-primary">
{chat.title}
</div>
<div className="flex items-center gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5 text-xs">
{hasLineStats && (
<span className="inline-flex shrink-0 items-center gap-0.5 tabular-nums">
<span className="text-git-added-bright">+{additions}</span>
@@ -206,7 +220,9 @@ const ChatSearchResultRow: FC<ChatSearchResultRowProps> = ({
</span>
</span>
)}
<span className="truncate text-content-secondary">{subtitle}</span>
<span className="min-w-0 flex-1 truncate text-content-secondary">
{subtitle}
</span>
</div>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 pt-0.5 text-xs text-content-secondary">