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

This commit is contained in:
Danielle Maywood
2026-08-12 15:04:01 +01:00
committed by GitHub
parent 6765731ea9
commit c424a76a12
21 changed files with 1006 additions and 423 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Search query. Supports ` + "`" + `title:\u003csubstring\u003e` + "`" + ` (case-insensitive, quote multi-word values), ` + "`" + `archived:bool` + "`" + `, ` + "`" + `has_unread:bool` + "`" + `, ` + "`" + `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` + "`" + ` as repeated or comma-separated values, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.",
"description": "Search query. Supports ` + "`" + `title:\u003csubstring\u003e` + "`" + ` (case-insensitive, quote multi-word values), ` + "`" + `archived:bool` + "`" + `, ` + "`" + `has_unread:bool` + "`" + `, ` + "`" + `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` + "`" + ` as repeated or comma-separated values, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.",
"name": "q",
"in": "query"
},
+1 -1
View File
@@ -59,7 +59,7 @@
"parameters": [
{
"type": "string",
"description": "Search query. Supports `title:\u003csubstring\u003e` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` as repeated or comma-separated values, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.",
"description": "Search query. Supports `title:\u003csubstring\u003e` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` as repeated or comma-separated values, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.",
"name": "q",
"in": "query"
},
-7
View File
@@ -1889,13 +1889,6 @@ func (q *querier) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Con
return q.db.CalculateAIBridgeInterceptionsTelemetrySummary(ctx, arg)
}
func (q *querier) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
return false, err
}
return q.db.ChatSearchQueryIsEmpty(ctx, search)
}
func (q *querier) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
empty := database.ClaimPrebuiltWorkspaceRow{}
-4
View File
@@ -962,10 +962,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), int32(100)).Return(int64(0), nil).AnyTimes()
check.Args(int32(100)).Asserts(rbac.ResourceChat, policy.ActionUpdate)
}))
s.Run("ChatSearchQueryIsEmpty", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().ChatSearchQueryIsEmpty(gomock.Any(), "!!!").Return(true, nil).AnyTimes()
check.Args("!!!").Asserts(rbac.ResourceChat, policy.ActionRead)
}))
s.Run("GetChatRetentionDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(30), nil).AnyTimes()
check.Args().Asserts()
-8
View File
@@ -265,14 +265,6 @@ func (m queryMetricsStore) CalculateAIBridgeInterceptionsTelemetrySummary(ctx co
return r0, r1
}
func (m queryMetricsStore) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
start := time.Now()
r0, r1 := m.s.ChatSearchQueryIsEmpty(ctx, search)
m.queryLatencies.WithLabelValues("ChatSearchQueryIsEmpty").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ChatSearchQueryIsEmpty").Inc()
return r0, r1
}
func (m queryMetricsStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
start := time.Now()
r0, r1 := m.s.ClaimPrebuiltWorkspace(ctx, arg)
-15
View File
@@ -337,21 +337,6 @@ func (mr *MockStoreMockRecorder) CalculateAIBridgeInterceptionsTelemetrySummary(
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CalculateAIBridgeInterceptionsTelemetrySummary", reflect.TypeOf((*MockStore)(nil).CalculateAIBridgeInterceptionsTelemetrySummary), ctx, arg)
}
// ChatSearchQueryIsEmpty mocks base method.
func (m *MockStore) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ChatSearchQueryIsEmpty", ctx, search)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ChatSearchQueryIsEmpty indicates an expected call of ChatSearchQueryIsEmpty.
func (mr *MockStoreMockRecorder) ChatSearchQueryIsEmpty(ctx, search any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChatSearchQueryIsEmpty", reflect.TypeOf((*MockStore)(nil).ChatSearchQueryIsEmpty), ctx, search)
}
// ClaimPrebuiltWorkspace mocks base method.
func (m *MockStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
m.ctrl.T.Helper()
-3
View File
@@ -86,9 +86,6 @@ type sqlcQuerier interface {
// Calculates the telemetry summary for a given provider, model, and client
// combination for telemetry reporting.
CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Context, arg CalculateAIBridgeInterceptionsTelemetrySummaryParams) (CalculateAIBridgeInterceptionsTelemetrySummaryRow, error)
// Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
// Used to reject input that would silently match nothing.
ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error)
ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebuiltWorkspaceParams) (ClaimPrebuiltWorkspaceRow, error)
CleanTailnetCoordinators(ctx context.Context) error
CleanTailnetLostPeers(ctx context.Context) error
-13
View File
@@ -6993,19 +6993,6 @@ func (q *sqlQuerier) BatchUpsertChatHeartbeats(ctx context.Context, arg BatchUps
return err
}
const chatSearchQueryIsEmpty = `-- name: ChatSearchQueryIsEmpty :one
SELECT numnode(websearch_to_tsquery('simple', $1::text)) = 0 AS is_empty
`
// Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
// Used to reject input that would silently match nothing.
func (q *sqlQuerier) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
row := q.db.QueryRowContext(ctx, chatSearchQueryIsEmpty, search)
var is_empty bool
err := row.Scan(&is_empty)
return is_empty, err
}
const countChatQueuedMessages = `-- name: CountChatQueuedMessages :one
SELECT COUNT(*)::bigint AS count
FROM chat_queued_messages
-5
View File
@@ -335,11 +335,6 @@ SET search_tsv = COALESCE(
''::tsvector)
FROM batch WHERE cm.id = batch.id;
-- name: ChatSearchQueryIsEmpty :one
-- Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
-- Used to reject input that would silently match nothing.
SELECT numnode(websearch_to_tsquery('simple', @search::text)) = 0 AS is_empty;
-- name: GetChatByID :one
SELECT *
FROM chats_expanded
+1 -23
View File
@@ -357,7 +357,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param q query string false "Search query. Supports `title:<substring>` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:<draft\|open\|merged\|closed>` as repeated or comma-separated values, `source:<created_by_me\|shared_with_me>`, `diff_url:<url>` (quote values containing colons), `pr:<number>` (exact PR number match), `repo:<owner/repo>` (case-insensitive substring match against git remote origin or URL), `pr_title:<text>` (case-insensitive PR title substring), `search:<text>` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use `title:<value>` or `search:<value>`."
// @Param q query string false "Search query. Supports `title:<substring>` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:<draft\|open\|merged\|closed>` as repeated or comma-separated values, `source:<created_by_me\|shared_with_me>`, `diff_url:<url>` (quote values containing colons), `pr:<number>` (exact PR number match), `repo:<owner/repo>` (case-insensitive substring match against git remote origin or URL), `pr_title:<text>` (case-insensitive PR title substring), `search:<text>` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:<value>` or `search:<value>`."
// @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)."
// @Success 200 {array} codersdk.Chat
// @Router /api/experimental/chats [get]
@@ -381,28 +381,6 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
return
}
// Reject text that tokenizes to nothing; it would silently match no rows.
if searchParams.Search != "" {
isEmpty, err := api.Database.ChatSearchQueryIsEmpty(ctx, searchParams.Search)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate search query.",
Detail: err.Error(),
})
return
}
if isEmpty {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat search query.",
Validations: []codersdk.ValidationError{{
Field: "search",
Detail: "Search query contains no searchable words.",
}},
})
return
}
}
var labelFilter pqtype.NullRawMessage
if labelParams := r.URL.Query()["label"]; len(labelParams) > 0 {
labelMap := make(map[string]string, len(labelParams))
+17 -7
View File
@@ -2125,17 +2125,27 @@ func TestListChats_Search(t *testing.T) {
require.NotContains(t, ids, noMatch.ID)
})
t.Run("NoSearchableWordsReturns400", func(t *testing.T) {
t.Run("NoSearchableWordsReturnsEmpty", func(t *testing.T) {
t.Parallel()
ctx, client, _, _, _ := setup(t)
ctx, client, db, firstUser, modelConfig := setup(t)
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
// "or" is a real lexeme (an operator only between operands), so
// search:"or" matches the control chat; search:"!!!" has no lexemes and
// matches nothing.
control := createChat(t, db, firstUser, modelConfig.ID, "fix this or that")
backfillSearchTsv(ctx, t, db)
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"or"`,
})
require.NoError(t, err)
require.Contains(t, chatIDs(chats), control.ID)
chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"!!!"`,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "search", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, "no searchable words")
require.NoError(t, err)
require.Empty(t, chats)
})
t.Run("ComposesWithRepoFilterAndArchivedDefault", func(t *testing.T) {
+49
View File
@@ -1276,6 +1276,55 @@ func TestSearchTasks(t *testing.T) {
}
}
func TestSearchChatsFrontendEmitted(t *testing.T) {
t.Parallel()
// These query shapes must match the emitters in
// site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.ts
// and site/src/api/queries/chats.ts.
testCases := []struct {
name string
query string
}{
{name: "SearchSingleWord", query: `search:"fix"`},
{name: "SearchMultipleWords", query: `search:"fix auth"`},
{name: "SearchColon", query: `search:"fix:lint"`},
{name: "SearchURL", query: `search:"http://example.com"`},
{name: "SearchUnicode", query: `search:"日本語"`},
{name: "SearchOperators", query: `search:"fix race OR deadlock -timeout"`},
{name: "SearchPunctuationOnly", query: `search:"!!!"`},
{name: "SearchOperatorWord", query: `search:"or"`},
{name: "HasUnread", query: "has_unread:true"},
{name: "Archived", query: "archived:true"},
{name: "PRStatuses", query: "pr_status:open,merged"},
{name: "DiffURL", query: `diff_url:"https://github.com/coder/coder/pull/1"`},
{name: "FilterAndSearch", query: `has_unread:true search:"fix auth"`},
{name: "SidebarDefault", query: "archived:false"},
{name: "SidebarUnread", query: "archived:false has_unread:true"},
{
name: "SidebarFiltered",
query: "archived:false pr_status:draft,closed source:created_by_me,shared_with_me",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
_, errs := searchquery.Chats(testCase.query)
require.Empty(t, errs)
})
}
rejectedQueries := []string{"pr_status:banana", "has_unread:maybe"}
for _, query := range rejectedQueries {
t.Run("Rejects"+query, func(t *testing.T) {
t.Parallel()
_, errs := searchquery.Chats(query)
require.NotEmpty(t, errs)
})
}
}
func TestSearchChats(t *testing.T) {
t.Parallel()