Repository navigation
feat: wire chat search box to full-text search #27973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
7fc93ab
2ee34e5
86445a0
026020b
3132d51
1fcef68
5a925b1
f0bd801
4a47b35
b1846b7
450d239
dbfde13
60a3a6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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 | ||
|
|
@@ -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 = { | ||
|
|
@@ -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; | ||
|
|
||
| const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({ | ||
| open, | ||
|
|
@@ -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 = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 [CRF-20]
The panel verified the mitigating fence:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Round 1's
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
| }; | ||
| 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({ | ||
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
| event.key === " " | ||
| ? extracted.remainingText | ||
| : extracted.remainingText.trimEnd(), | ||
| ); | ||
| return; | ||
| } | ||
| } | ||
|
|
@@ -430,6 +380,7 @@ const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({ | |
| recentChats={recentChats} | ||
| error={searchQuery.error} | ||
| hasQuery={hasQuery} | ||
| hasSearchText={hasSearchText} | ||
| location={location} | ||
| listboxId={listboxId} | ||
| selectedChatIndex={safeSelectedChatIndex} | ||
|
|
||
There was a problem hiding this comment.
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." CallbuildChatSearchQuerydirectly 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).There was a problem hiding this comment.
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 = buildChatSearchQueryalias and its stale comment; the call site usesbuildChatSearchQuerydirectly. 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'sprev/fnaming convention.