Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export const Results: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"Fix"',
q: 'search:"Fix"',
});
});
await expect(
Expand Down Expand Up @@ -263,7 +263,7 @@ export const OverflowResults: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"review"',
q: 'search:"review"',
});
});

Expand Down Expand Up @@ -299,7 +299,7 @@ export const CappedResults: Story = {
await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: 'title:"Fix"',
q: 'search:"Fix"',
});
});
await expect(
Expand Down Expand Up @@ -367,7 +367,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();
},
};
Expand All @@ -380,10 +385,19 @@ 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.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("PR status"));
await userEvent.type(searchInput, "badvalue");
await userEvent.keyboard("{Enter}");

await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:badvalue",
});
});
await expect(await body.findByRole("alert")).toBeInTheDocument();
},
};
Expand All @@ -407,10 +421,19 @@ 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.click(body.getByRole("button", { name: "Toggle filters" }));
await userEvent.click(await body.findByText("PR status"));
await userEvent.type(searchInput, "badvalue");
await userEvent.keyboard("{Enter}");

await waitFor(() => {
expect(API.experimental.getChats).toHaveBeenCalledWith({
limit: CHAT_SEARCH_LIMIT,
q: "pr_status:badvalue",
});
});
const alert = await body.findByRole("alert");
await expect(alert).toBeInTheDocument();

Expand Down Expand Up @@ -617,6 +640,131 @@ 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("");
},
};

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 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 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);
Expand All @@ -633,7 +781,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"',
});
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,7 +15,7 @@ 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, extractTypedFilters } from "./searchQuery";

// Filter definitions. Filters with a defaultValue are inserted as complete
// pills (e.g. has_unread:true). Filters without one are inserted as
Expand Down Expand Up @@ -55,10 +49,7 @@ const FILTER_DEFINITIONS: readonly FilterDefinition[] = [
{ key: "diff_url", label: "Diff URL", icon: LinkIcon, defaultValue: null },
];

// 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.
// Typed filter detection uses the same keys as the filter dropdown.
const KNOWN_FILTER_KEYS = new Set(FILTER_DEFINITIONS.map((def) => def.key));

type ChatSearchDialogProps = {
Expand Down Expand Up @@ -131,28 +122,9 @@ 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);
};
// Structured filters and free text are already separate UI state, so query
// construction can write the backend wire format without parsing it again.
const buildQuery = buildChatSearchQuery;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-34] const buildQuery = buildChatSearchQuery; is a single-use alias, and its comment narrates the deleted parser. (Gon P2, Nami Nit, Meruem Nit, Netero Note)

Two names for one function in one file; the reader traces the alias to learn nothing. The comment's "without parsing it again" refers to normalizeChatSearchInput, which this PR removed; a fresh reader has no antecedent for "again." Call buildChatSearchQuery directly at line 163 and delete lines 125-127.

Same class, two more new comments in this commit restate what the code already shows: ChatSearchDialog.tsx:52 (the derivation is visible on the next line) and the first sentence of ChatSearchDialog.tsx:151 ("Debounce filters and free text as one snapshot" narrates the call; only the second sentence, the why, earns its line). Minor consistency: the new code uses (previous) and (filter) at lines 284/288 where the file convention is (prev) and (f).

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Deleted the const buildQuery = buildChatSearchQuery alias and its stale comment; the call site uses buildChatSearchQuery directly. Also trimmed the redundant KNOWN_FILTER_KEYS derivation comment and the narrative first sentence of the debounce comment (kept the why), and aligned the new code to the file's prev/f naming convention.

🤖 Coder Agents


const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
open,
Expand All @@ -176,30 +148,22 @@ 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(
() =>
// Debounce filters and free text as one snapshot. This prevents a committed
// incomplete-filter value from briefly reappearing as full-text search.
const queryInput = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-20] queryInput is a fresh object literal every render, so useDebouncedValue's correctness now rests entirely on React Compiler memoization, a dependency that is invisible at the call site. (Komugi P1, Netero P2, Pariston P2, Leorio P2, Takumi P2, Hisoka Note, Mafuuu Note, Nami Note, Meruem Note)

useDebouncedValue's effect keys on [value, debounceTimeoutMs] and compares by reference (site/src/hooks/debounce.ts:112). The R1 code debounced a primitive (freeText) and a useMemo'd array (effectiveFilters); this commit deleted the memo and the import. Uncompiled, the behavior is verified by four independent probe harnesses: the timer fires, setDebouncedValue stores a fresh identity, the render mints another queryInput, the effect re-arms, and the loop self-sustains at one render per 500ms for as long as the dialog is open. Worse, every unrelated render resets the pending timer: Komugi traced ChatsSidebar.tsx:168 passing the live chats array as recentChats, so with an agent actively streaming, sidebar re-renders arriving faster than 500ms starve the debounce entirely and the search never fires until the stream pauses.

The panel verified the mitigating fence: vite.config.mts opts src/pages/AgentsPage/ into babel-plugin-react-compiler, three reviewers compiled this file and confirmed the compiler memoizes queryInput on [filters, freeText, incompleteFilterKey], and pnpm lint:compiler (part of make lint) fails on any bailout in those directories. So as shipped, the loop does not run. The severity disagreement is about the residual state: the pattern looks copy-pasteable, and outside the compiled directories (or under a "use no memo" bailout) it degrades into the loop with no local signal. site/AGENTS.md forbids manual useMemo in this directory, so the fix is not "restore the memo" against convention; the honest options are a short comment at the call site naming the compiler dependency, or restructuring so the debounced value is a primitive (e.g. debounce a joined key or the built query string). At minimum, the next person to copy this pattern elsewhere needs the warning.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The debounced value is now a primitive string snapshot (the emitted query with a 1/0 hasSearchText prefix), not a fresh object literal, so useDebouncedValue's reference comparison no longer depends on React Compiler memoizing queryInput. The atomic-debounce property (no stale incomplete-filter leak) is preserved, and the CommittedFilterDoesNotLeakStaleText story covers it.

🤖 Coder Agents

filters:
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,
freeText: incompleteFilterKey ? "" : freeText,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-35] Clearing free text while a filter pill is active leaves the stale search: token in the query for one debounce period; the old code zeroed it immediately. (Takumi)

Round 1's queryFreeText = incompleteFilterKey || !freeText.trim() ? "" : debouncedFreeText short-circuited cleared text past the debounce. Now the empty text rides the 500ms snapshot debounce, so with a pill active the previous text's results (and hasSearchText, which gates the indexing-lag copy) linger for up to 500ms after deletion. It converges, and symmetric debounce on deletion is defensible, so this is informational. The commit-a-filter case the snapshot comment targets is genuinely atomic and correct.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Symmetric debounce on deletion is the deliberate tradeoff here: clearing free text now rides the same 500ms snapshot debounce rather than zeroing immediately, so results (and hasSearchText) linger briefly. It converges and keeps the snapshot atomic. Accepting as-is; thanks for calling out the distinction from the commit-a-filter path.

🤖 Coder Agents

};
const hasActiveSearch =
queryInput.filters.length > 0 || queryInput.freeText.trim() !== "";
const debouncedQueryInput = useDebouncedValue(queryInput, SEARCH_DEBOUNCE_MS);
const { query: normalizedQuery, hasSearchText } = buildQuery(
debouncedQueryInput.filters,
debouncedQueryInput.freeText,
);
// 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;

const searchQuery = useQuery({
Expand Down Expand Up @@ -314,33 +278,19 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
!incompleteFilterKey &&
freeText.trim()
) {
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,
KNOWN_FILTER_KEYS,
new Set(filters.map((filter) => filter.key)),
);
if (extracted.consumed) {
event.preventDefault();
setFilters((prev) => [...prev, ...newFilters]);
setFreeText(remaining.join(" "));
setFilters((previous) => [...previous, ...extracted.filters]);
setFreeText(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-25] When space triggers typed-filter extraction and the consumed token is not the last token, the typed space is swallowed and the next keystroke glues onto the preceding word. (Netero P3, Mafuuu P3, Nami P3)

The handler calls event.preventDefault(), so the space is never inserted; the only way it survives is needsTrailingSeparator (searchQuery.ts:141-142), which appends a separator only when the consumed token is the last one. Paste has_unread:true fix (paste bypasses keydown extraction), then press space: the pill extracts, remainingText is "fix" with no trailing space, and typing auth produces fixauth, emitted as search:"fixauth". This is the sibling of the mid-string separator bug the PR notes claim fixed; that fix covers inter-token separators but not the just-typed space when a trailing token survives. Nami adds a second edge: extraction fires regardless of cursor position, and setFreeText(remainingText) resets the cursor to the end, so mid-string edits teleport the caret. Fix: on event.key === " ", append the separator whenever remainingText is non-empty, and consider running extraction only when the cursor is at the end of the input.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. On a Space trigger, the separator is appended whenever the remaining text is non-empty, so the next keystroke no longer glues onto the preceding word. Extraction also only runs when the caret is at the end of the input, which avoids the caret-teleport on mid-string edits.

🤖 Coder Agents

event.key === " "
? extracted.remainingText
: extracted.remainingText.trimEnd(),
);
return;
}
}
Expand Down Expand Up @@ -430,6 +380,7 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
recentChats={recentChats}
error={searchQuery.error}
hasQuery={hasQuery}
hasSearchText={hasSearchText}
location={location}
listboxId={listboxId}
selectedChatIndex={safeSelectedChatIndex}
Expand Down
Loading
Loading