mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+94
-2
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user