feat: wire chat search box to full-text search (#27973)

This commit is contained in:
Danielle Maywood
2026-08-12 15:04:01 +01:00
committed by GitHub
parent 6765731ea9
commit c424a76a12
21 changed files with 1006 additions and 423 deletions
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from "@playwright/test";
import { login } from "../../helpers";
import { beforeCoderTest } from "../../hooks";
test.beforeEach(async ({ page }) => {
beforeCoderTest(page);
await login(page);
});
test("searches chats with backend full-text search", async ({ page }) => {
await page.goto("/agents", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Search chats" }).first().click();
const searchInput = page.getByRole("combobox", { name: "Search chats" });
await expect(searchInput).toBeVisible();
const searchResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
url.pathname === "/api/experimental/chats" &&
url.searchParams.get("q") === 'search:"full-text-smoke"'
);
});
await searchInput.fill("full-text-smoke");
await expect((await searchResponse).status()).toBe(200);
await expect(
page.getByText("No matching chats", { exact: false }),
).toBeVisible();
await expect(page.getByRole("alert")).not.toBeVisible();
});
+22
View File
@@ -43,6 +43,7 @@ import {
createChatMessage,
deleteChatQueuedMessage,
editChatMessage,
getChatListQueryString,
infiniteChats,
interruptChat,
invalidateChatACL,
@@ -1791,6 +1792,27 @@ describe("chatListKey shape", () => {
});
});
describe("getChatListQueryString", () => {
it("emits sidebar query shapes accepted by searchquery.Chats", () => {
// These strings must match TestSearchChatsFrontendEmitted in
// coderd/searchquery/search_test.go.
expect(getChatListQueryString(toChatListParams())).toBe("archived:false");
expect(
getChatListQueryString(toChatListParams({ chatStatus: "unread" })),
).toBe("archived:false has_unread:true");
expect(
getChatListQueryString(
toChatListParams({
prStatuses: ["draft", "closed"],
sources: ["created_by_me", "shared_with_me"],
}),
),
).toBe(
"archived:false pr_status:draft,closed source:created_by_me,shared_with_me",
);
});
});
describe("chatsByWorkspace", () => {
it("disables the query when no workspace IDs are given", () => {
expect(chatsByWorkspace([]).enabled).toBe(false);
+5 -1
View File
@@ -1076,7 +1076,11 @@ export const toChatListParams = (input?: ChatListInput): ChatListParams => ({
sources: canonicalizeChatSources(input?.sources ?? []),
});
const getChatListQueryString = (params: ChatListParams): string | undefined => {
// Sidebar-emitted query shapes must match TestSearchChatsFrontendEmitted in
// coderd/searchquery/search_test.go.
export const getChatListQueryString = (
params: ChatListParams,
): string | undefined => {
const qParts: string[] = [];
qParts.push(`archived:${params.archived}`);
if (params.prStatuses.length) {
@@ -4,6 +4,7 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import { CHAT_SEARCH_LIMIT } from "#/api/queries/chats";
import type { Chat } from "#/api/typesGenerated";
import { MockChat } from "#/testHelpers/chatEntities";
import { ChatSearchDialog } from "./ChatSearchDialog";
const mockDiffStatus: NonNullable<Chat["diff_status"]> = {
@@ -19,35 +20,26 @@ const mockDiffStatus: NonNullable<Chat["diff_status"]> = {
};
const mockChat: Chat = {
...MockChat,
id: "chat-1",
organization_id: "org-1",
owner_id: "owner-1",
owner_username: "jaayden",
title: "Fix race condition in auth middleware",
status: "waiting",
last_model_config_id: "model-1",
mcp_server_ids: [],
labels: {},
last_turn_summary: "Added migration script",
summary: "Investigated and fixed a race condition in the auth middleware.",
created_at: "2026-05-20T05:00:00.000Z",
updated_at: "2026-05-20T07:30:00.000Z",
archived: false,
shared: false,
pin_order: 0,
has_unread: true,
client_type: "ui",
children: [],
diff_status: mockDiffStatus,
};
const mockChats: Chat[] = [
mockChat,
{
...mockChat,
...MockChat,
id: "chat-2",
title: "Fix flaky workspace search story",
last_turn_summary: "Updated keyboard interactions",
summary: "Investigated and fixed a race condition in the auth middleware.",
created_at: "2026-05-20T05:00:00.000Z",
updated_at: "2026-05-20T08:45:00.000Z",
has_unread: false,
diff_status: {
@@ -62,12 +54,14 @@ const mockChats: Chat[] = [
];
const overflowMockChats: Chat[] = [
{
...mockChat,
...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.",
summary: "Investigated and fixed a race condition in the auth middleware.",
created_at: "2026-05-20T05:00:00.000Z",
updated_at: "2026-05-20T09:30:00.000Z",
has_unread: false,
diff_status: {
@@ -79,9 +73,13 @@ const overflowMockChats: Chat[] = [
const cappedMockChats: Chat[] = Array.from(
{ length: CHAT_SEARCH_LIMIT },
(_, index) => ({
...mockChat,
...MockChat,
id: `chat-${index + 1}`,
title: `Fix capped search result ${index + 1}`,
last_turn_summary: "Added migration script",
summary: "Investigated and fixed a race condition in the auth middleware.",
created_at: "2026-05-20T05:00:00.000Z",
updated_at: "2026-05-20T07:30:00.000Z",
has_unread: false,
diff_status: undefined,
}),
@@ -195,7 +193,7 @@ export const Results: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"Fix"',
q: 'search:"Fix"',
});
});
await expect(
@@ -263,7 +261,7 @@ export const OverflowResults: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"review"',
q: 'search:"review"',
});
});
@@ -299,7 +297,7 @@ export const CappedResults: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"Fix"',
q: 'search:"Fix"',
});
});
await expect(
@@ -367,7 +365,12 @@ export const NoResults: Story = {
"none",
);
await expect(
await body.findByText("No matching chats"),
await body.findByText("No matching chats", { exact: false }),
).toBeInTheDocument();
await expect(
body.getByText("Message content is indexed periodically", {
exact: false,
}),
).toBeInTheDocument();
},
};
@@ -380,14 +383,40 @@ export const ErrorState: Story = {
},
play: async () => {
const body = within(document.body);
await userEvent.type(
body.getByRole("combobox", { name: "Search chats" }),
"title:",
);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "backend failure");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'search:"backend failure"',
});
});
await expect(await body.findByRole("alert")).toBeInTheDocument();
},
};
export const ClearingErrorReturnsToDefaultView: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockRejectedValue(
new Error("Bad filter"),
);
},
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "backend failure");
await expect(await body.findByRole("alert")).toBeInTheDocument();
await userEvent.clear(searchInput);
await expect(await body.findByText("Recent chats")).toBeInTheDocument();
await expect(body.queryByRole("alert")).not.toBeInTheDocument();
},
};
export const ErrorStateWithStackTrace: Story = {
beforeEach: () => {
const err = new Error(
@@ -407,10 +436,16 @@ export const ErrorStateWithStackTrace: Story = {
},
play: async () => {
const body = within(document.body);
await userEvent.type(
body.getByRole("combobox", { name: "Search chats" }),
"title:",
);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "backend failure");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'search:"backend failure"',
});
});
const alert = await body.findByRole("alert");
await expect(alert).toBeInTheDocument();
@@ -499,6 +534,34 @@ export const ParameterizedFilterPill: Story = {
},
};
export const ParameterizedPRStatusCommaContinuation: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("PR status"));
await userEvent.type(searchInput, "open,");
await userEvent.keyboard(" ");
await userEvent.type(searchInput, "merged");
await userEvent.keyboard("{Enter}");
await expect(
await body.findByText("pr_status:open,merged"),
).toBeInTheDocument();
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:open,merged",
});
});
expect(API.experimental.getChats).not.toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'pr_status:open search:"merged"',
});
},
};
export const DiffURLFilterPill: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockResolvedValue(mockChats);
@@ -617,6 +680,215 @@ export const TypedFilterAutoDetection: Story = {
},
};
export const TypedFilterWithoutTrailingSpace: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "has_unread:true");
await userEvent.keyboard("{Enter}");
await expect(await body.findByText("has_unread:true")).toBeInTheDocument();
await expect(searchInput).toHaveValue("");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "has_unread:true",
});
});
},
};
export const TypedFilterMidString: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "fix has_unread:true auth");
await expect(await body.findByText("has_unread:true")).toBeInTheDocument();
await expect(searchInput).toHaveValue("fix auth");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'has_unread:true search:"fix auth"',
});
});
},
};
export const TypedTitleStaysSearchText: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "title:auth");
await expect(searchInput).toHaveValue("title:auth");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'search:"title:auth"',
});
});
},
};
export const QuotedTypedFilterDoesNotCommitEarly: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, 'pr_status:"open ');
await expect(searchInput).toHaveValue('pr_status:"open ');
await expect(body.queryByText("pr_status:open")).not.toBeInTheDocument();
await userEvent.type(searchInput, 'merged" ');
await expect(
await body.findByText("pr_status:open,merged"),
).toBeInTheDocument();
await expect(searchInput).toHaveValue("");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:open,merged",
});
});
},
};
export const EmptyIncompleteFilterDoesNotCommit: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("PR status"));
await userEvent.type(searchInput, ",,");
await userEvent.keyboard("{Enter}");
await expect(searchInput).toHaveValue(",,");
await expect(body.getByText("pr_status:")).toBeInTheDocument();
await userEvent.clear(searchInput);
await userEvent.type(searchInput, "open");
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:open",
});
});
expect(API.experimental.getChats).not.toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:",
});
},
};
export const CommittedFilterDoesNotLeakStaleText: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("PR status"));
await userEvent.type(searchInput, "open");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:open",
});
});
await userEvent.keyboard("{Enter}");
await userEvent.type(searchInput, "fix");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'pr_status:open search:"fix"',
});
});
expect(API.experimental.getChats).not.toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'pr_status:open search:"open"',
});
},
};
export const EmptySearchResultsShowNoAlert: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockResolvedValue([]);
},
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.type(searchInput, "or");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'search:"or"',
});
});
await expect(
await body.findByText("No matching chats", { exact: false }),
).toBeInTheDocument();
await expect(body.queryByRole("alert")).not.toBeInTheDocument();
},
};
export const DuplicateTypedFilterReplacesPill: Story = {
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("Unread"));
await userEvent.type(searchInput, "has_unread:false ");
await expect(await body.findByText("has_unread:false")).toBeInTheDocument();
await expect(body.queryByText("has_unread:true")).not.toBeInTheDocument();
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "has_unread:false",
});
});
},
};
export const PunctuationOnlyTextHidesIndexingNote: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockResolvedValue([]);
},
play: async () => {
const body = within(document.body);
const searchInput = body.getByRole("combobox", { name: "Search chats" });
await userEvent.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("Unread"));
await userEvent.type(searchInput, "???");
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'has_unread:true search:"???"',
});
});
await expect(
await body.findByText("No matching chats", { exact: false }),
).toBeInTheDocument();
await expect(
body.queryByText("Message content is indexed periodically", {
exact: false,
}),
).not.toBeInTheDocument();
},
};
export const CombinedFilterAndText: Story = {
play: async () => {
const body = within(document.body);
@@ -633,7 +905,7 @@ export const CombinedFilterAndText: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'has_unread:true title:"Fix"',
q: 'has_unread:true search:"Fix"',
});
});
},
@@ -5,13 +5,7 @@ import {
LinkIcon,
} from "lucide-react";
import type { FC, RefObject } from "react";
import {
type KeyboardEventHandler,
useId,
useMemo,
useRef,
useState,
} from "react";
import { type KeyboardEventHandler, useId, useRef, useState } from "react";
import { keepPreviousData, useQuery } from "react-query";
import { type Location, useNavigate } from "react-router";
import { chatSearch } from "#/api/queries/chats";
@@ -21,45 +15,60 @@ import { Dialog, DialogContent, DialogTitle } from "#/components/Dialog/Dialog";
import { useDebouncedValue } from "#/hooks/debounce";
import { ChatSearchInput, type SearchFilter } from "./ChatSearchInput";
import { ChatSearchResults } from "./ChatSearchResults";
import { normalizeChatSearchInput } from "./searchQuery";
import {
buildChatSearchQuery,
CHAT_SEARCH_FILTER_KEYS,
type ChatSearchFilterKey,
extractTypedFilters,
isValidChatSearchFilterValue,
normalizeChatSearchFilterValue,
} 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 key: ChatSearchFilterKey;
readonly label: string;
readonly icon: FC<{ className?: string }>;
readonly defaultValue: string | null;
readonly validate: (value: string) => boolean;
};
const FILTER_DEFINITIONS: readonly FilterDefinition[] = [
{
key: "has_unread",
const FILTER_DEFINITIONS_BY_KEY: Readonly<
Record<ChatSearchFilterKey, Omit<FilterDefinition, "key">>
> = {
has_unread: {
label: "Unread",
icon: CircleDotIcon,
defaultValue: "true",
validate: (value) => isValidChatSearchFilterValue("has_unread", value),
},
{
key: "archived",
archived: {
label: "Archived",
icon: ArchiveIcon,
defaultValue: "true",
validate: (value) => isValidChatSearchFilterValue("archived", value),
},
{
key: "pr_status",
pr_status: {
label: "PR status",
icon: FileTextIcon,
defaultValue: null,
validate: (value) => isValidChatSearchFilterValue("pr_status", value),
},
{ key: "diff_url", label: "Diff URL", icon: LinkIcon, defaultValue: null },
];
diff_url: {
label: "Diff URL",
icon: LinkIcon,
defaultValue: null,
validate: (value) => isValidChatSearchFilterValue("diff_url", value),
},
};
// 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));
const FILTER_DEFINITIONS: readonly FilterDefinition[] =
CHAT_SEARCH_FILTER_KEYS.map((key) => ({
key,
...FILTER_DEFINITIONS_BY_KEY[key],
}));
type ChatSearchDialogProps = {
readonly open: boolean;
@@ -131,29 +140,6 @@ type ChatSearchDialogContentProps = Omit<
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,
@@ -176,34 +162,24 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
>(undefined);
const listboxId = useId();
// 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;
// Debounce as one snapshot so a committed incomplete-filter value cannot
// reappear as full-text search.
const queryFilters =
incompleteFilterKey && freeText.trim()
? [...filters, { key: incompleteFilterKey, value: freeText.trim() }]
: filters;
const queryFreeText = incompleteFilterKey ? "" : freeText;
const currentQuery = buildChatSearchQuery(queryFilters, queryFreeText);
const hasActiveSearch =
queryFilters.length > 0 || queryFreeText.trim() !== "";
// Keep the debounced value primitive. An object would reset the debounce when
// its identity changes on each render.
const debouncedQuery = useDebouncedValue(currentQuery, SEARCH_DEBOUNCE_MS);
const hasSearchText = /[\p{L}\p{N}]/u.test(queryFreeText.replaceAll('"', ""));
const hasQuery = hasActiveSearch && debouncedQuery !== undefined;
const searchQuery = useQuery({
...chatSearch({ q: normalizedQuery ?? "" }),
...chatSearch({ q: debouncedQuery ?? "" }),
enabled: open && hasQuery,
placeholderData: keepPreviousData,
});
@@ -241,10 +217,18 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
!showResultsLoading;
const commitIncompleteFilter = () => {
if (incompleteFilterKey && freeText.trim()) {
setFilters((prev) => [
...prev,
{ key: incompleteFilterKey, value: freeText.trim() },
const value = freeText.trim();
const definition = FILTER_DEFINITIONS.find(
(def) => def.key === incompleteFilterKey,
);
if (incompleteFilterKey && definition?.validate(value)) {
const committedValue =
incompleteFilterKey === "pr_status"
? normalizeChatSearchFilterValue(incompleteFilterKey, value)
: value;
setFilters((previous) => [
...previous.filter((filter) => filter.key !== incompleteFilterKey),
{ key: incompleteFilterKey, value: committedValue },
]);
setFreeText("");
setIncompleteFilterKey(null);
@@ -302,7 +286,12 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
if (
(event.key === " " || event.key === "Enter") &&
incompleteFilterKey &&
freeText.trim()
freeText.trim() &&
!(
event.key === " " &&
incompleteFilterKey === "pr_status" &&
freeText.trimEnd().endsWith(",")
)
) {
event.preventDefault();
commitIncompleteFilter();
@@ -312,35 +301,32 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
if (
(event.key === " " || event.key === "Enter") &&
!incompleteFilterKey &&
freeText.trim()
freeText.trim() &&
event.currentTarget.selectionStart === freeText.length &&
event.currentTarget.selectionEnd === freeText.length
) {
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) {
const extracted = extractTypedFilters(freeText, filters);
if (extracted.consumed) {
event.preventDefault();
setFilters((prev) => [...prev, ...newFilters]);
setFreeText(remaining.join(" "));
setFilters((previous) => {
const replacements = new Map(
extracted.filters.map((filter) => [filter.key, filter]),
);
const next = previous.map(
(filter) => replacements.get(filter.key) ?? filter,
);
for (const filter of extracted.filters) {
if (!previous.some((existing) => existing.key === filter.key)) {
next.push(filter);
}
}
return next;
});
setFreeText(
event.key === " " && extracted.remainingText
? `${extracted.remainingText.trimEnd()} `
: extracted.remainingText.trimEnd(),
);
return;
}
}
@@ -428,8 +414,9 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
<ChatSearchResults
chats={searchQuery.data}
recentChats={recentChats}
error={searchQuery.error}
error={hasQuery ? searchQuery.error : undefined}
hasQuery={hasQuery}
hasSearchText={hasSearchText}
location={location}
listboxId={listboxId}
selectedChatIndex={safeSelectedChatIndex}
@@ -15,6 +15,7 @@ type ChatSearchResultsProps = {
readonly recentChats: readonly Chat[];
readonly error: unknown;
readonly hasQuery: boolean;
readonly hasSearchText: boolean;
readonly location: Location;
readonly listboxId: string;
readonly selectedChatIndex: number | undefined;
@@ -39,6 +40,7 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
recentChats,
error,
hasQuery,
hasSearchText,
location,
listboxId,
selectedChatIndex,
@@ -101,6 +103,7 @@ export const ChatSearchResults: FC<ChatSearchResultsProps> = ({
<ScrollArea {...SCROLL_AREA_PROPS}>
<ChatSearchResultsList
chats={chats}
hasSearchText={hasSearchText}
location={location}
listboxId={listboxId}
selectedChatIndex={selectedChatIndex}
@@ -174,6 +177,7 @@ const DefaultView: FC<DefaultViewProps> = ({
type ChatSearchResultsListProps = {
readonly chats: readonly Chat[] | undefined;
readonly hasSearchText: boolean;
readonly location: Location;
readonly listboxId: string;
readonly selectedChatIndex: number | undefined;
@@ -183,6 +187,7 @@ type ChatSearchResultsListProps = {
const ChatSearchResultsList: FC<ChatSearchResultsListProps> = ({
chats,
hasSearchText,
location,
listboxId,
selectedChatIndex,
@@ -195,8 +200,17 @@ const ChatSearchResultsList: FC<ChatSearchResultsListProps> = ({
if ((chats?.length ?? 0) === 0) {
return (
<div className="flex h-[300px] items-center justify-center">
<p className="text-sm text-content-secondary">No matching chats</p>
<div className="flex h-[300px] items-center justify-center px-6 text-center">
<p className="text-sm text-content-secondary">
No matching chats.
{hasSearchText && (
<>
{" "}
Message content is indexed periodically, so very recent messages
may not be searchable yet.
</>
)}
</p>
</div>
);
}
@@ -1,92 +1,281 @@
import { describe, expect, it } from "vitest";
import { normalizeChatSearchInput } from "./searchQuery";
import { buildChatSearchQuery, extractTypedFilters } from "./searchQuery";
describe("normalizeChatSearchInput", () => {
it("returns undefined for empty input", () => {
expect(normalizeChatSearchInput("")).toBeUndefined();
expect(normalizeChatSearchInput(" ")).toBeUndefined();
describe("buildChatSearchQuery", () => {
it("returns no query for empty input", () => {
expect(buildChatSearchQuery([], "")).toBe(undefined);
expect(buildChatSearchQuery([], " ")).toBe(undefined);
});
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',
it("wraps free text in one FTS token", () => {
expect(buildChatSearchQuery([], "Fix")).toBe('search:"Fix"');
expect(buildChatSearchQuery([], "fix auth middleware")).toBe(
'search:"fix auth middleware"',
);
expect(normalizeChatSearchInput("pr_status:open,merged")).toBe(
"pr_status:open,merged",
expect(buildChatSearchQuery([], "fix:lint")).toBe('search:"fix:lint"');
expect(buildChatSearchQuery([], "http://example.com")).toBe(
'search:"http://example.com"',
);
});
it("combines structured filters with free text", () => {
expect(
normalizeChatSearchInput(
'diff_url:"https://github.com/coder/coder/pull/25391"',
),
).toBe('diff_url:"https://github.com/coder/coder/pull/25391"');
buildChatSearchQuery([{ key: "has_unread", value: "true" }], "fix auth"),
).toBe('has_unread:true search:"fix auth"');
});
it("normalizes structured filter values", () => {
expect(
normalizeChatSearchInput(
"diff_url:https://github.com/coder/coder/pull/26016",
buildChatSearchQuery([{ key: "pr_status", value: "open merged" }], ""),
).toBe("pr_status:open,merged");
for (const value of [
"open, merged",
"open merged",
"open,merged",
" open , merged ",
",,open,,, merged,,",
]) {
expect(buildChatSearchQuery([{ key: "pr_status", value }], "")).toBe(
"pr_status:open,merged",
);
}
expect(
buildChatSearchQuery([{ key: "pr_status", value: ",, ," }], ""),
).toBe(undefined);
expect(
buildChatSearchQuery(
[
{
key: "diff_url",
value: "github.com/coder/coder/pull/26016",
},
],
"",
),
).toBe('diff_url:"https://github.com/coder/coder/pull/26016"');
});
it("emits no-lexeme text without marking it searchable", () => {
for (const input of ["???", "___", ":-)", "!!!"]) {
expect(buildChatSearchQuery([], input)).toBe(`search:"${input}"`);
}
expect(buildChatSearchQuery([], '"')).toBe('search:" "');
// OR/AND/NOT are lexemes under the simple config (operators only between
// operands), so a lone operator word is searchable in any casing.
for (const input of ["or", "OR", "Or", "AND", "NOT"]) {
expect(buildChatSearchQuery([], input)).toBe(`search:"${input}"`);
}
expect(
normalizeChatSearchInput("diff_url:github.com/coder/coder/pull/26016"),
).toBe('diff_url:"https://github.com/coder/coder/pull/26016"');
expect(
normalizeChatSearchInput('diff_url:"github.com/coder/coder/pull/26016"'),
).toBe('diff_url:"https://github.com/coder/coder/pull/26016"');
buildChatSearchQuery([{ key: "has_unread", value: "true" }], "???"),
).toBe('has_unread:true search:"???"');
});
it("re-quotes passthrough values containing spaces so the result round-trips", () => {
const normalized = normalizeChatSearchInput('pr_status:"open merged"');
expect(normalized).toBe('pr_status:"open merged"');
expect(normalizeChatSearchInput(normalized ?? "")).toBe(
'pr_status:"open merged"',
it("emits Unicode letters as searchable text", () => {
expect(buildChatSearchQuery([], "日本語")).toBe('search:"日本語"');
});
it("does not emit invalid structured filters", () => {
for (const filter of [
{ key: "pr_status", value: "banana" },
{ key: "has_unread", value: "maybe" },
{ key: "archived", value: "no" },
{ key: "diff_url", value: "ftp://example.com/x" },
]) {
expect(buildChatSearchQuery([filter], "")).toBe(undefined);
}
});
it("skips filters whose sanitized value is empty", () => {
for (const key of ["pr_status", "diff_url"]) {
for (const value of ['"', '""']) {
expect(buildChatSearchQuery([{ key, value }], "")).toBe(undefined);
}
}
});
it("strips embedded quotes and trims before wrapping", () => {
expect(buildChatSearchQuery([], ' Fix "auth" middleware ')).toBe(
'search:"Fix auth middleware"',
);
});
it("converts bare search text into a title filter", () => {
expect(normalizeChatSearchInput("Fix")).toBe('title:"Fix"');
expect(normalizeChatSearchInput("fix auth middleware")).toBe(
'title:"fix auth middleware"',
);
expect(normalizeChatSearchInput("fix:lint")).toBe('title:"fix:lint"');
});
it("combines key:value filters with a title fallback for bare text", () => {
expect(normalizeChatSearchInput("has_unread:true fix auth")).toBe(
'has_unread:true title:"fix auth"',
);
expect(normalizeChatSearchInput("archived:true fix:lint")).toBe(
'archived:true title:"fix:lint"',
);
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"');
});
it("combines duplicate title filters into one title filter", () => {
expect(normalizeChatSearchInput("title:Fix title:Race")).toBe(
'title:"Fix Race"',
);
expect(
normalizeChatSearchInput('has_unread:true title:"chat title" title:Race'),
).toBe('has_unread:true title:"chat title Race"');
});
it("strips quotes from bare text", () => {
expect(normalizeChatSearchInput('Fix "auth" middleware')).toBe(
'title:"Fix auth middleware"',
it("preserves OR and negation while flattening quoted phrases", () => {
expect(buildChatSearchQuery([], '"fix race" OR deadlock -timeout')).toBe(
'search:"fix race OR deadlock -timeout"',
);
});
it("treats a trailing-colon filter as bare title text", () => {
// `title:` is not a well-formed key:value pair, so it should be searched
// for as a literal title substring.
expect(normalizeChatSearchInput("title:")).toBe('title:"title:"');
it("never parses free text as structured filters", () => {
for (const text of ["title:auth", "search:fix", "pr:12", "foo:bar"]) {
expect(buildChatSearchQuery([], text)).toBe(`search:"${text}"`);
}
});
});
describe("extractTypedFilters", () => {
it("extracts leading, middle, and trailing filters", () => {
expect(extractTypedFilters("has_unread:true fix", [])).toEqual({
filters: [{ key: "has_unread", value: "true" }],
remainingText: "fix",
consumed: true,
});
expect(extractTypedFilters("fix has_unread:true auth", [])).toEqual({
filters: [{ key: "has_unread", value: "true" }],
remainingText: "fix auth",
consumed: true,
});
expect(extractTypedFilters("fix has_unread:true", [])).toEqual({
filters: [{ key: "has_unread", value: "true" }],
remainingText: "fix",
consumed: true,
});
});
it("returns multiple recognized filters", () => {
expect(extractTypedFilters("has_unread:true archived:false", [])).toEqual({
filters: [
{ key: "has_unread", value: "true" },
{ key: "archived", value: "false" },
],
remainingText: "",
consumed: true,
});
});
it("extracts complete quoted multi-word values", () => {
expect(extractTypedFilters('pr_status:"open merged"', [])).toEqual({
filters: [{ key: "pr_status", value: "open,merged" }],
remainingText: "",
consumed: true,
});
});
it("merges whitespace-separated PR status continuations", () => {
expect(extractTypedFilters("pr_status:open, merged", [])).toEqual({
filters: [{ key: "pr_status", value: "open,merged" }],
remainingText: "",
consumed: true,
});
expect(extractTypedFilters("pr_status:open,merged", [])).toEqual({
filters: [{ key: "pr_status", value: "open,merged" }],
remainingText: "",
consumed: true,
});
expect(extractTypedFilters("pr_status:open, merged, closed", [])).toEqual({
filters: [{ key: "pr_status", value: "open,merged,closed" }],
remainingText: "",
consumed: true,
});
});
it("leaves invalid or incomplete PR status continuations as text", () => {
expect(extractTypedFilters("pr_status:open, bogus", [])).toEqual({
filters: [],
remainingText: "pr_status:open, bogus",
consumed: false,
});
expect(extractTypedFilters("pr_status:open,", [])).toEqual({
filters: [],
remainingText: "pr_status:open,",
consumed: false,
});
});
it("does not consume an unbalanced quoted value", () => {
expect(extractTypedFilters('pr_status:"open', [])).toEqual({
filters: [],
remainingText: 'pr_status:"open',
consumed: false,
});
});
it("returns active key replacements", () => {
expect(
extractTypedFilters("has_unread:false", [
{ key: "has_unread", value: "true" },
]),
).toEqual({
filters: [{ key: "has_unread", value: "false" }],
remainingText: "",
consumed: true,
});
});
it("can consume an unchanged active value without returning a replacement", () => {
expect(
extractTypedFilters("has_unread:true", [
{ key: "has_unread", value: "true" },
]),
).toEqual({
filters: [],
remainingText: "",
consumed: true,
});
});
it("uses the last value for duplicate keys in the same input", () => {
expect(extractTypedFilters("has_unread:true has_unread:false", [])).toEqual(
{
filters: [{ key: "has_unread", value: "false" }],
remainingText: "",
consumed: true,
},
);
});
it("leaves unknown, incomplete, empty, and invalid filter-like text unchanged", () => {
for (const text of [
"foo:bar",
"title:",
"title:auth",
"search:fix",
"pr:12",
"has_unread:",
"has_unread:maybe",
"archived:no",
"pr_status:banana",
"pr_status:,,",
'diff_url:"ftp://example.com/x"',
'pr_status:""',
"http://example.com",
"fix:lint",
]) {
expect(extractTypedFilters(text, [])).toEqual({
filters: [],
remainingText: text,
consumed: false,
});
}
});
it("keeps invalid recognized filters as literal search text", () => {
const extracted = extractTypedFilters("pr_status:banana", []);
expect(buildChatSearchQuery([], extracted.remainingText)).toBe(
'search:"pr_status:banana"',
);
});
it("keeps everything after the first colon in diff URLs", () => {
expect(
extractTypedFilters("diff_url:https://github.com/coder/coder/pull/1", []),
).toEqual({
filters: [
{
key: "diff_url",
value: "https://github.com/coder/coder/pull/1",
},
],
remainingText: "",
consumed: true,
});
});
it("normalizes recognized key casing", () => {
expect(extractTypedFilters("Has_Unread:true", [])).toEqual({
filters: [{ key: "has_unread", value: "true" }],
remainingText: "",
consumed: true,
});
});
});
@@ -1,7 +1,23 @@
// The backend's search-query parser toggles its quoted-state on every `"` and
// has no backslash-escape handling, so escaping quotes here would produce a
// query the backend cannot parse. Stripping quotes from bare text keeps the
// resulting `title:"..."` filter well-formed for the backend.
import type { SearchFilter } from "./ChatSearchInput";
export const CHAT_SEARCH_FILTER_KEYS = [
"has_unread",
"archived",
"pr_status",
"diff_url",
] as const;
export type ChatSearchFilterKey = (typeof CHAT_SEARCH_FILTER_KEYS)[number];
const CHAT_SEARCH_KNOWN_FILTER_KEYS: ReadonlySet<string> = new Set(
CHAT_SEARCH_FILTER_KEYS,
);
const isChatSearchFilterKey = (key: string): key is ChatSearchFilterKey =>
CHAT_SEARCH_KNOWN_FILTER_KEYS.has(key);
// The backend toggles its quoted state on every `"` and has no escape handling.
// Stripping embedded quotes keeps the wrapper token well-formed.
const sanitizeChatSearchValue = (value: string): string => {
return value.replaceAll('"', "");
};
@@ -10,18 +26,113 @@ const addDefaultURLScheme = (value: string): string => {
return /^[a-z][a-z\d+\-.]*:\/\//i.test(value) ? value : `https://${value}`;
};
// Filter keys that may pass through to the backend unchanged. `title` is not
// listed here because bare text and `title:` filters are merged into a single
// title filter; see the title-handling branch in normalizeChatSearchInput.
const passthroughChatSearchFilterKeys = new Set([
"archived",
"diff_url",
"has_unread",
"pr_status",
]);
export const normalizeChatSearchFilterValue = (
key: string,
value: string,
): string => {
const sanitizedValue = sanitizeChatSearchValue(value).trim();
if (sanitizedValue === "") {
return "";
}
if (key === "diff_url") {
return addDefaultURLScheme(sanitizedValue);
}
if (key === "pr_status") {
return sanitizedValue
.split(/[\s,]+/)
.filter(Boolean)
.join(",");
}
return sanitizedValue;
};
const splitSearchInput = (input: string): string[] => {
const tokens: string[] = [];
const validPRStatuses = new Set(["draft", "open", "merged", "closed"]);
const validBooleans = new Set(["true", "false"]);
const isValidDiffURL = (value: string): boolean => {
try {
const url = new URL(value);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
Boolean(url.host)
);
} catch {
return false;
}
};
const CHAT_SEARCH_FILTER_VALIDATORS: Readonly<
Record<ChatSearchFilterKey, (value: string) => boolean>
> = {
has_unread: (value) => validBooleans.has(value.toLowerCase()),
archived: (value) => validBooleans.has(value.toLowerCase()),
pr_status: (value) =>
value
.split(",")
.every((status) => validPRStatuses.has(status.toLowerCase())),
diff_url: isValidDiffURL,
};
export const isValidChatSearchFilterValue = (
key: string,
value: string,
): boolean => {
if (!isChatSearchFilterKey(key)) {
return false;
}
const normalizedValue = normalizeChatSearchFilterValue(key, value);
if (normalizedValue === "") {
return false;
}
return CHAT_SEARCH_FILTER_VALIDATORS[key](normalizedValue);
};
const formatChatSearchFilterToken = (key: string, value: string): string => {
const formattedValue = normalizeChatSearchFilterValue(key, value);
// The backend splits on unquoted whitespace and colons, so filter values that
// contain either delimiter must be wrapped in quotes.
return formattedValue.includes(":") || formattedValue.includes(" ")
? `${key}:"${formattedValue}"`
: `${key}:${formattedValue}`;
};
// Frontend-emitted query shapes must match TestSearchChatsFrontendEmitted in
// coderd/searchquery/search_test.go.
export const buildChatSearchQuery = (
filters: readonly SearchFilter[],
freeText: string,
): string | undefined => {
const parts: string[] = [];
for (const filter of filters) {
if (
filter.value !== null &&
isValidChatSearchFilterValue(filter.key, filter.value)
) {
parts.push(formatChatSearchFilterToken(filter.key, filter.value));
}
}
const text = sanitizeChatSearchValue(freeText).trim();
if (freeText.trim() !== "") {
// Quotes make the value one backend token and are stripped before FTS, so
// OR and -negation stay live but typed phrase quotes are lost. A lone
// quote would emit an empty value, which the backend rejects, so a single
// space stands in to force an empty result instead of recent chats.
parts.push(`search:"${text === "" ? " " : text}"`);
}
return parts.length > 0 ? parts.join(" ") : undefined;
};
type SearchInputToken = {
readonly value: string;
readonly quotesBalanced: boolean;
};
const splitSearchInput = (input: string): SearchInputToken[] => {
const tokens: SearchInputToken[] = [];
let token = "";
let quoted = false;
@@ -32,7 +143,7 @@ const splitSearchInput = (input: string): string[] => {
if (/\s/.test(character) && !quoted) {
if (token !== "") {
tokens.push(token);
tokens.push({ value: token, quotesBalanced: true });
token = "";
}
continue;
@@ -42,126 +153,97 @@ const splitSearchInput = (input: string): string[] => {
}
if (token !== "") {
tokens.push(token);
tokens.push({ value: token, quotesBalanced: !quoted });
}
return tokens;
};
const getKeyValueDelimiterIndex = (token: string): number | undefined => {
let quoted = false;
/**
* Extracts recognized filters from typed text. Unbalanced-quoted and invalid
* tokens pass through unchanged. `consumed` is true if any filter token was
* removed, even when `filters` is empty (a typed value equal to the active
* pill). The caller owns any separator after a suppressed Space keystroke.
*/
export const extractTypedFilters = (
text: string,
activeFilters: readonly SearchFilter[],
): {
filters: SearchFilter[];
remainingText: string;
consumed: boolean;
} => {
const tokens = splitSearchInput(text.trim());
const activeValues = new Map(
activeFilters.map((filter) => [
filter.key.toLowerCase(),
filter.value === null
? null
: normalizeChatSearchFilterValue(filter.key, filter.value),
]),
);
const filtersByKey = new Map<string, SearchFilter>();
const remainingTokens: string[] = [];
let consumed = false;
for (const [index, character] of [...token].entries()) {
if (character === '"') {
quoted = !quoted;
let tokenIndex = 0;
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
if (!token.quotesBalanced) {
remainingTokens.push(token.value);
tokenIndex += 1;
continue;
}
if (character === ":" && !quoted) {
return index;
const colonIndex = token.value.indexOf(":");
if (colonIndex <= 0 || colonIndex === token.value.length - 1) {
remainingTokens.push(token.value);
tokenIndex += 1;
continue;
}
const key = token.value.slice(0, colonIndex).toLowerCase();
let value = token.value
.slice(colonIndex + 1)
.replace(/^"|"$/g, "")
.trim();
const candidateTokens = [token.value];
let nextTokenIndex = tokenIndex + 1;
if (key === "pr_status" && value.endsWith(",")) {
while (value.endsWith(",") && nextTokenIndex < tokens.length) {
const nextToken = tokens[nextTokenIndex];
value = `${value} ${nextToken.value}`;
candidateTokens.push(nextToken.value);
nextTokenIndex += 1;
}
}
if (
!CHAT_SEARCH_KNOWN_FILTER_KEYS.has(key) ||
value.endsWith(",") ||
!isValidChatSearchFilterValue(key, value)
) {
remainingTokens.push(...candidateTokens);
tokenIndex = nextTokenIndex;
continue;
}
consumed = true;
const normalizedValue = normalizeChatSearchFilterValue(key, value);
if (activeValues.get(key) === normalizedValue) {
filtersByKey.delete(key);
} else {
filtersByKey.set(key, {
key,
value: key === "pr_status" ? normalizedValue : value,
});
}
tokenIndex = nextTokenIndex;
}
return undefined;
};
const getKeyValuePair = (
token: string,
): { key: string; rawKey: string; value: string } | undefined => {
const delimiterIndex = getKeyValueDelimiterIndex(token);
if (
delimiterIndex === undefined ||
delimiterIndex === 0 ||
delimiterIndex === token.length - 1
) {
return undefined;
}
const rawKey = token.slice(0, delimiterIndex).replaceAll('"', "");
return {
key: rawKey.toLowerCase(),
rawKey,
value: token.slice(delimiterIndex + 1).replace(/^"|"$/g, ""),
filters: [...filtersByKey.values()],
remainingText: remainingTokens.join(" "),
consumed,
};
};
// The backend splits on unquoted whitespace and colons, so values containing
// either (e.g. a diff URL) must be quoted.
const normalizePassthroughChatSearchFilter = ({
key,
rawKey,
value,
}: {
readonly key: string;
readonly rawKey: string;
readonly value: string;
}): string => {
const sanitizedValue =
key === "diff_url"
? addDefaultURLScheme(sanitizeChatSearchValue(value))
: sanitizeChatSearchValue(value);
return sanitizedValue.includes(":") || sanitizedValue.includes(" ")
? `${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 are normalized for backend syntax.
*/
export const normalizeChatSearchInput = (
rawInput: string,
): string | undefined => {
const trimmedInput = rawInput.trim();
if (trimmedInput === "") {
return undefined;
}
const tokens = splitSearchInput(trimmedInput);
const passthroughFilters: string[] = [];
const normalizedTokens: string[] = [];
const titleTerms: string[] = [];
let hasBareTitleText = false;
for (const token of tokens) {
const keyValuePair = getKeyValuePair(token);
if (!keyValuePair) {
titleTerms.push(token);
hasBareTitleText = true;
continue;
}
if (keyValuePair.key === "title") {
normalizedTokens.push(token);
titleTerms.push(keyValuePair.value);
continue;
}
if (!passthroughChatSearchFilterKeys.has(keyValuePair.key)) {
titleTerms.push(token);
hasBareTitleText = true;
continue;
}
const normalizedFilter = normalizePassthroughChatSearchFilter(keyValuePair);
passthroughFilters.push(normalizedFilter);
normalizedTokens.push(normalizedFilter);
}
// Multiple title values must be merged into a single title filter because
// the backend's query parser rejects the same key appearing more than once.
if (titleTerms.length > 1) {
hasBareTitleText = true;
}
if (!hasBareTitleText) {
return normalizedTokens.join(" ");
}
return [
...passthroughFilters,
`title:"${sanitizeChatSearchValue(titleTerms.join(" "))}"`,
].join(" ");
};