Skip to content
Next Next commit
fix: fix search ui filter pills
  • Loading branch information
jaaydenh committed Jun 5, 2026
commit e18953fdc09eda158e9a047a0ec2e76b5368f7aa
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ const cappedMockChats: Chat[] = Array.from(
diff_status: undefined,
}),
);
const longDiffURL =
"https://github.com/coder/coder/pull/26016/files/1234567890abcdef1234567890abcdef1234567890abcdef";

const meta: Meta<typeof ChatSearchDialog> = {
title: "pages/AgentsPage/ChatSearchDialog",
Expand Down Expand Up @@ -460,6 +462,45 @@ export const ParameterizedFilterPill: Story = {
},
};

export const DiffURLFilterPill: 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("Diff URL"));

await expect(await body.findByText("diff_url:")).toBeInTheDocument();

await userEvent.click(searchInput);
await userEvent.type(searchInput, `${longDiffURL} `);

const diffURLPill = await body.findByText(`diff_url:${longDiffURL}`);
Comment thread
jaaydenh marked this conversation as resolved.
await expect(diffURLPill).toBeInTheDocument();
await expect(searchInput).toBeVisible();

const dialog = body.getByRole("dialog");
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
const searchContainer = searchInput.parentElement;
const searchWrapper = searchContainer?.parentElement;
if (!searchContainer || !searchWrapper) {
throw new Error(
"Expected search input to render inside nested containers",
);
}

await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: `diff_url:"${longDiffURL}"`,
});
});
},
};

export const ParameterizedFilterPillEnterCommit: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockResolvedValue(mockChats);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
the dropdown, but clicks within the dropdown (which is
inside the same container) don't trigger blur. */}
<div
className="relative"
className="relative w-full min-w-0 max-w-full"
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsDropdownOpen(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,56 +45,58 @@ export const ChatSearchInput: FC<ChatSearchInputProps> = ({
return (
<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",
"flex min-h-10 w-full min-w-0 items-start gap-1.5 rounded-md border border-solid border-border-default bg-surface-primary px-3 py-2",
"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`}
<SearchIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{completedFilters.map((f) => (
<span
key={f.key}
Comment thread
jaaydenh marked this conversation as resolved.
className="inline-flex max-w-full min-w-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"
>
<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={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}
aria-expanded={hasResults}
aria-haspopup="listbox"
aria-activedescendant={activeResultId}
/>
<span className="block min-w-0 truncate">
{f.key}:{f.value}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveFilter(f.key);
}}
className="inline-flex shrink-0 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={filters.length > 0 ? "" : "Search chats..."}
className="min-w-[60px] flex-1 basis-[60px] border-none bg-transparent py-0.5 text-sm text-content-primary outline-none placeholder:text-content-disabled"
aria-label="Search chats"
role="combobox"
aria-controls={hasResults ? listboxId : undefined}
aria-expanded={hasResults}
aria-haspopup="listbox"
aria-activedescendant={activeResultId}
/>
</div>
<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",
"mt-0.5 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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe("normalizeChatSearchInput", () => {
expect(normalizeChatSearchInput(" ")).toBeUndefined();
});

it("keeps key:value filters unchanged", () => {
it("normalizes key:value filters", () => {
expect(normalizeChatSearchInput("has_unread:true")).toBe("has_unread:true");
expect(normalizeChatSearchInput('title:"chat title" archived:true')).toBe(
'title:"chat title" archived:true',
Expand All @@ -20,6 +20,11 @@ describe("normalizeChatSearchInput", () => {
'diff_url:"https://github.com/coder/coder/pull/25391"',
),
).toBe('diff_url:"https://github.com/coder/coder/pull/25391"');
expect(
normalizeChatSearchInput(
"diff_url:https://github.com/coder/coder/pull/26016",
),
).toBe('diff_url:"https://github.com/coder/coder/pull/26016"');
});

it("converts bare search text into a title filter", () => {
Expand All @@ -40,6 +45,11 @@ describe("normalizeChatSearchInput", () => {
expect(normalizeChatSearchInput("fix has_unread:true auth")).toBe(
'has_unread:true title:"fix auth"',
);
expect(
normalizeChatSearchInput(
"diff_url:https://github.com/coder/coder/pull/26016 fix",
),
).toBe('diff_url:"https://github.com/coder/coder/pull/26016" title:"fix"');
expect(
normalizeChatSearchInput('archived:true title:"chat title" fix'),
).toBe('archived:true title:"chat title fix"');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const getKeyValueDelimiterIndex = (token: string): number | undefined => {

const getKeyValuePair = (
Comment thread
jaaydenh marked this conversation as resolved.
token: string,
): { key: string; value: string } | undefined => {
): { key: string; rawKey: string; value: string } | undefined => {
const delimiterIndex = getKeyValueDelimiterIndex(token);
if (
delimiterIndex === undefined ||
Expand All @@ -72,18 +72,33 @@ const getKeyValuePair = (
return undefined;
}

const rawKey = token.slice(0, delimiterIndex).replaceAll('"', "");
return {
key: token.slice(0, delimiterIndex).replaceAll('"', "").toLowerCase(),
key: rawKey.toLowerCase(),
rawKey,
value: token.slice(delimiterIndex + 1).replace(/^"|"$/g, ""),
};
};

const normalizePassthroughChatSearchFilter = ({
Comment thread
jaaydenh marked this conversation as resolved.
rawKey,
value,
}: {
readonly rawKey: string;
readonly value: string;
}): string => {
const sanitizedValue = sanitizeChatSearchValue(value);
return sanitizedValue.includes(":")
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
? `${rawKey}:"${sanitizedValue}"`
: `${rawKey}:${sanitizedValue}`;
};

/**
* Normalizes raw search input into a query string the chat search API accepts.
*
* Bare text and `title:` filters are merged into a single `title:"..."`
* filter (the backend rejects a parameter that appears more than once).
* Recognized `key:value` filters pass through unchanged.
* Recognized `key:value` filters are normalized for backend syntax.
*/
export const normalizeChatSearchInput = (
rawInput: string,
Expand All @@ -95,6 +110,7 @@ export const normalizeChatSearchInput = (

const tokens = splitSearchInput(trimmedInput);
const keyValuePairs: string[] = [];
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
const normalizedTokens: string[] = [];
const titleTerms: string[] = [];
let hasBareTitleText = false;

Expand All @@ -107,6 +123,7 @@ export const normalizeChatSearchInput = (
}

if (keyValuePair.key === "title") {
normalizedTokens.push(token);
Comment thread
jaaydenh marked this conversation as resolved.
titleTerms.push(keyValuePair.value);
continue;
}
Expand All @@ -117,7 +134,9 @@ export const normalizeChatSearchInput = (
continue;
}

keyValuePairs.push(token);
const normalizedFilter = normalizePassthroughChatSearchFilter(keyValuePair);
keyValuePairs.push(normalizedFilter);
normalizedTokens.push(normalizedFilter);
}

// Multiple title values must be merged into a single title filter because
Expand All @@ -127,7 +146,7 @@ export const normalizeChatSearchInput = (
}

if (!hasBareTitleText) {
return trimmedInput;
return normalizedTokens.join(" ");
}

return [
Expand Down
Loading