Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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,7 @@ export const NoResults: Story = {
"none",
);
await expect(
await body.findByText("No matching chats"),
await body.findByText("No matching chats", { exact: false }),
).toBeInTheDocument();
},
};
Expand Down Expand Up @@ -633,7 +633,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 @@ -132,7 +132,8 @@ type ChatSearchDialogContentProps = Omit<
};

// Build a raw query string from structured filters + freeform text, then
// normalize it through the existing parser that the backend expects.
// normalize it through the existing parser that the backend expects. Freeform
// text becomes the backend's FTS `search:` filter.
const buildQuery = (
filters: readonly SearchFilter[],
freeText: string,
Expand Down Expand Up @@ -193,7 +194,7 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
SEARCH_DEBOUNCE_MS,
);
// When typing into an incomplete filter, only send the filter (not
// freeText as bare title search).
// freeText as bare full-text 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,11 @@ 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. Message content is indexed periodically, so very
recent messages may not be searchable yet.
</p>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,52 +41,74 @@ describe("normalizeChatSearchInput", () => {
);
});

it("converts bare search text into a title filter", () => {
expect(normalizeChatSearchInput("Fix")).toBe('title:"Fix"');
it("converts bare search text into a quoted FTS search filter", () => {
expect(normalizeChatSearchInput("Fix")).toBe('search:"Fix"');
expect(normalizeChatSearchInput("fix auth middleware")).toBe(
'title:"fix auth middleware"',
'search:"fix auth middleware"',
);
expect(normalizeChatSearchInput("fix:lint")).toBe('title:"fix:lint"');
expect(normalizeChatSearchInput("hello world")).toBe(
'search:"hello world"',
);
expect(normalizeChatSearchInput("fix:lint")).toBe('search:"fix:lint"');
});

it("combines key:value filters with a title fallback for bare text", () => {
it("combines key:value filters with an FTS search fallback for bare text", () => {
expect(normalizeChatSearchInput("has_unread:true fix auth")).toBe(
'has_unread:true title:"fix auth"',
'has_unread:true search:"fix auth"',
);
expect(normalizeChatSearchInput("archived:true fix:lint")).toBe(
'archived:true title:"fix:lint"',
'archived:true search:"fix:lint"',
);
expect(normalizeChatSearchInput("fix has_unread:true auth")).toBe(
'has_unread:true title:"fix auth"',
'has_unread:true search:"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"');
).toBe('diff_url:"https://github.com/coder/coder/pull/26016" search:"fix"');
expect(
normalizeChatSearchInput('archived:true title:"chat title" fix'),
).toBe('archived:true title:"chat title fix"');
).toBe('archived:true search:"chat title fix"');
});

it("combines duplicate title filters into one title filter", () => {
it("combines duplicate title filters into one search filter", () => {
expect(normalizeChatSearchInput("title:Fix title:Race")).toBe(
'title:"Fix Race"',
'search:"Fix Race"',
);
expect(
normalizeChatSearchInput('has_unread:true title:"chat title" title:Race'),
).toBe('has_unread:true title:"chat title Race"');
).toBe('has_unread:true search:"chat title Race"');
});

it("strips quotes from bare text", () => {
it("preserves quoted websearch phrases in bare text", () => {
// A leading/trailing quote pair is passed through so websearch_to_tsquery
// can interpret it as a quoted phrase.
expect(normalizeChatSearchInput('"fix race condition"')).toBe(
'search:"fix race condition"',
);
expect(normalizeChatSearchInput('Fix "auth" middleware')).toBe(
'title:"Fix auth middleware"',
'search:Fix "auth" middleware',
);
});

it("preserves websearch operators alongside a quoted phrase", () => {
expect(normalizeChatSearchInput('"fix race" OR deadlock -timeout')).toBe(

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-2] Test asserts an emitted string the backend rejects, codifying the CRF-1 bug as expected behavior. (Netero)

preserves websearch operators alongside a quoted phrase asserts normalizeChatSearchInput('"fix race" OR deadlock -timeout') equals search:"fix race" OR deadlock -timeout. That output is rejected by searchquery.Chats (unsupported search term: "OR deadlock -timeout"), so the test proves the function produces a specific string, not that the string works.

The sibling assertion at :90 (Fix "auth" middleware -> search:Fix "auth" middleware) has the same defect. Both check output shape and never verify backend acceptance, so they stay green while the feature is broken. A regression test for this path should assert against the backend parser's acceptance of the emitted query.

🤖

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 in the successor PR #27973 (this PR was auto-closed as stale and could not be reopened after the rebase).

Rewrote both tests to assert the strip-and-wrap behavior that the backend actually accepts: Fix "auth" middleware -> search:"Fix auth middleware" and "fix race" OR deadlock -timeout -> search:"fix race OR deadlock -timeout". The emitted outputs were verified against the real searchquery.Chats parser to confirm acceptance, addressing the "tests pass green while the feature is broken" concern.

🤖 Coder Agents

'search:"fix race" OR deadlock -timeout',
);
});

it("strips stray quotes from bare text before wrapping", () => {
// Unbalanced quotes would break the backend's query parser, which has no
// escape handling for embedded quotes.
expect(normalizeChatSearchInput("it's a \"test")).toBe(
'search:"it\'s a test"',
);
});

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("treats a trailing-colon filter as bare search text", () => {
// `title:` is not a well-formed key:value pair, so it is wrapped as an
// FTS phrase.
expect(normalizeChatSearchInput("title:")).toBe('search:"title:"');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// 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.
// query the backend cannot parse. Stripping quotes from structured filter
// values keeps the resulting `key:"..."` token well-formed for the backend.
// Bare free text is not sanitized this way so that FTS quoted phrases survive.
const sanitizeChatSearchValue = (value: string): string => {
return value.replaceAll('"', "");
};
Expand All @@ -10,9 +11,32 @@ const addDefaultURLScheme = (value: string): string => {
return /^[a-z][a-z\d+\-.]*:\/\//i.test(value) ? value : `https://${value}`;
};

// Bare free text may contain websearch operators (quoted phrases, OR,
// -negation). Detect a leading/trailing quote pair so those pass through
// unmodified; everything else gets wrapped in a single quoted phrase.
const hasWebSearchQuotes = (value: string): boolean => {
const first = value.indexOf('"');
const last = value.lastIndexOf('"');
return (
first !== -1 && last > first && /\S/.test(value.slice(first + 1, last))

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.

P1 [CRF-1] hasWebSearchQuotes matches any interior quote pair, so bare free text containing a quoted word or websearch operators is emitted verbatim as search:<text>, which the backend rejects. (Netero)

hasWebSearchQuotes only checks that a quote exists (indexOf) and a later quote exists (lastIndexOf) with non-whitespace between them. It does not verify the quotes bracket the whole string. Any input with an interior quote pair passes through toSearchPhrase unchanged and is emitted as a raw search: value.

Verified empirically against searchquery.Chats:

  • Fix "auth" middleware emits search:Fix "auth" middleware -> unsupported search term: "\"auth\" middleware", Search="".
  • "fix race" OR deadlock -timeout emits search:"fix race" OR deadlock -timeout -> unsupported search term: "OR deadlock -timeout", Search="".
  • "fix race condition" emits search:"fix race condition" -> Search="fix race condition" (the only shape that works).

The backend tokenizer (coderd/searchquery/search.go) splits on unquoted whitespace and rejects any non-key:value token, so only text entirely inside one search:"..." token reaches filter.Search. The PR description's claim that websearch operators pass through is false for every case except a single fully-quoted phrase. Fix: only pass through when the entire trimmed string is one balanced quoted phrase, otherwise strip and re-wrap the whole expression in search:"...". Verify the fix against searchquery.Chats, not just the emitted string.

🤖

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 in the successor PR #27973 (this PR was auto-closed as stale and could not be reopened after the rebase).

Dropped the hasWebSearchQuotes/toSearchPhrase passthrough entirely. Bare free text now always strips embedded double quotes and is wrapped in a single search:"..." phrase, so no input produces a rejected query. Verified empirically against searchquery.Chats: all emitted shapes (including Fix "auth" middleware and "fix race" OR deadlock -timeout) now parse with zero validation errors and the expected filter.Search. The PR description no longer claims websearch operators pass through.

🤖 Coder Agents

);
};

// Wrap bare free text in a quoted phrase so multi-word input reaches the
// backend's FTS filter as a single token. Quotes are stripped first because
// the backend's query parser has no escape handling for embedded quotes.
const toSearchPhrase = (terms: string): string => {
const joined = terms.trim();
if (hasWebSearchQuotes(joined)) {
return joined;
}
return `"${sanitizeChatSearchValue(joined)}"`;
};

// 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.
// FTS `search:` filter; see the search-handling branch in
// normalizeChatSearchInput.
const passthroughChatSearchFilterKeys = new Set([
"archived",
"diff_url",
Expand Down Expand Up @@ -107,7 +131,7 @@ const normalizePassthroughChatSearchFilter = ({
/**
* Normalizes raw search input into a query string the chat search API accepts.
*
* Bare text and `title:` filters are merged into a single `title:"..."`
* Bare text and `title:` filters are merged into a single `search:` FTS
* filter (the backend rejects a parameter that appears more than once).
* Recognized `key:value` filters are normalized for backend syntax.
*/
Expand All @@ -122,26 +146,26 @@ export const normalizeChatSearchInput = (
const tokens = splitSearchInput(trimmedInput);
const passthroughFilters: string[] = [];
const normalizedTokens: string[] = [];
const titleTerms: string[] = [];
let hasBareTitleText = false;
const searchTerms: string[] = [];
let hasBareSearchText = false;

for (const token of tokens) {
const keyValuePair = getKeyValuePair(token);
if (!keyValuePair) {
titleTerms.push(token);
hasBareTitleText = true;
searchTerms.push(token);
hasBareSearchText = true;
continue;
}

if (keyValuePair.key === "title") {
normalizedTokens.push(token);
titleTerms.push(keyValuePair.value);
searchTerms.push(keyValuePair.value);
continue;
}

if (!passthroughChatSearchFilterKeys.has(keyValuePair.key)) {
titleTerms.push(token);
hasBareTitleText = true;
searchTerms.push(token);
hasBareSearchText = true;
continue;
}

Expand All @@ -150,18 +174,20 @@ export const normalizeChatSearchInput = (
normalizedTokens.push(normalizedFilter);
}

// Multiple title values must be merged into a single title filter because
// Multiple search values must be merged into a single search filter because
// the backend's query parser rejects the same key appearing more than once.
if (titleTerms.length > 1) {
hasBareTitleText = true;
if (searchTerms.length > 1) {
hasBareSearchText = true;
}

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

// Free text defaults to the backend's full-text search filter, which
// matches chat titles, PR titles, and message bodies.
return [
...passthroughFilters,
`title:"${sanitizeChatSearchValue(titleTerms.join(" "))}"`,
`search:${toSearchPhrase(searchTerms.join(" "))}`,
].join(" ");
};
Loading