mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: wire chat search box to full-text search (#27973)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+300
-28
@@ -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(" ");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user