diff --git a/docs/evidence/current-chat-search/README.md b/docs/evidence/current-chat-search/README.md new file mode 100644 index 00000000..ad9697d0 --- /dev/null +++ b/docs/evidence/current-chat-search/README.md @@ -0,0 +1,30 @@ +# Current chat search verification + +Captured on Windows with Chrome against the production Go API and `web/dist`, +using `cmd/workbenchjourney` with a disposable workspace and its offline provider. +The two sample chats have different titles and bodies containing `请求超时`. +Screenshots use the real search endpoint and saved message navigation. + +| Screenshot | Build and observed behavior | +| --- | --- | +| [Before](before-global-search.png) | Base `96ba29bf`, 1440 × 1000. Global search shows both chats' titles and messages: four matches. | +| [Current chat](after-current-search.png) | This change, 1440 × 1000. Current scope shows one matching body; selecting it opens that message's history window. | +| [Narrow window](after-current-search-narrow.png) | This change, 390 × 844. Ctrl+F reopens the hidden sidebar, focuses the retained query and keeps the scope controls within the viewport. | + +The baseline UI bundle digest was +`beedd22bc9c451fb4d4a60d914275544d0fbb4a6b134b5eb52bac1b9ff9bc1f4`; +the final bundle digest was +`78d6b88f6c424bb8718cf4779e7a4fc6f3cc24cd7c861580b0471563a14ddea6`. +Ctrl+K returned to all chats and four results. Ctrl+F selected the current chat. +The narrow viewport had no horizontal document overflow. + +Focused checks passed: + +- Go search, full-message lookup and OpenAPI tests in `internal/httpapi` and + `internal/store`, including a real branch with an inherited long assistant reply, + cross-Run compacted history, scoped cursors, lifecycle filters and redaction. +- The affected project-move tests in `internal/application`. +- React search, sidebar, keyboard settings and app tests: 93 tests. The final + drawer-focus adjustment also passed the 19 search tests. +- `npm run check:api`, `npm run build`, `git diff --check`, and + `go run ./cmd/protocolregistry -check -baseline 96ba29bff4ae2a6b92e563fe0e5e5dbf93a2840d`. diff --git a/docs/evidence/current-chat-search/after-current-search-narrow.png b/docs/evidence/current-chat-search/after-current-search-narrow.png new file mode 100644 index 00000000..6f0c386f Binary files /dev/null and b/docs/evidence/current-chat-search/after-current-search-narrow.png differ diff --git a/docs/evidence/current-chat-search/after-current-search.png b/docs/evidence/current-chat-search/after-current-search.png new file mode 100644 index 00000000..44f4e52f Binary files /dev/null and b/docs/evidence/current-chat-search/after-current-search.png differ diff --git a/docs/evidence/current-chat-search/before-global-search.png b/docs/evidence/current-chat-search/before-global-search.png new file mode 100644 index 00000000..68e2d4c6 Binary files /dev/null and b/docs/evidence/current-chat-search/before-global-search.png differ diff --git a/docs/openapi.json b/docs/openapi.json index 72ae4731..55c7d236 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -27639,7 +27639,7 @@ "get": { "operationId": "searchSavedThreadMessages", "summary": "Search saved conversation titles and public message bodies", - "description": "Searches complete saved user and assistant text, including compacted history, before pagination. Uses literal ASCII case-insensitive substrings, with exact matching for other Unicode. Redacts and checks the existing public transcript projection before returning a match. Excludes deleted conversations, tools, peer messages and internal events. Defaults to active conversations; include_archived also includes read-only archived history. Results use creation-time and identity cursors scoped to the normalized query and archive filter.", + "description": "Searches complete saved user and assistant text, including compacted history, public model progress and inherited branch replies, before pagination. With thread_id, searches only message bodies in that exact conversation; otherwise also searches conversation titles. Uses literal ASCII case-insensitive substrings, with exact matching for other Unicode. Redacts and checks the existing public transcript projection before returning a match. Excludes deleted conversations, tools, peer messages and internal events. Defaults to active conversations; include_archived also includes read-only archived history. Results use creation-time and identity cursors scoped to the normalized query, Thread and archive filter.", "tags": [ "Threads" ], @@ -27676,6 +27676,16 @@ "type": "string" } }, + { + "name": "thread_id", + "in": "query", + "description": "Search saved public message bodies in this exact conversation", + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, { "name": "include_archived", "in": "query", diff --git a/docs/usage.md b/docs/usage.md index 81298a5c..ae75b995 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -887,15 +887,18 @@ During an attempt, `run events` may contain at most 32 ordered `model.delta` rec ### Unified Thread transcript / 统一 Thread 主工作面 -侧栏搜索通过 `GET /api/v1/threads?status=active&q=...` 检索全部未归档任务的标题, -无需先加载旧任务;不搜索消息正文,归档任务仍在设置中查看。查询会去掉首尾空白,最多 -256 个 Unicode 字符,ASCII 字母不区分大小写,其他 Unicode 字符精确匹配;`%`、`_` -和反斜杠按普通字符匹配。空查询与不传 `q` 相同。 - -结果按创建时间倒序排列,同一时间按任务 ID 倒序。继续使用响应的 `next_cursor` 获取 -更早匹配项;修改查询或生命周期筛选后须从第一页开始。并发新增较新任务不会移动旧页, +侧栏搜索通过 `GET /api/v1/threads/search?q=...` 检索已保存的标题与用户、助手正文, +包括压缩前及尚未加载的历史、公开进度和分支继承的回复。选择「当前聊天」时附带 `thread_id`,只搜索当前聊天的正文; +「所有聊天」恢复跨聊天搜索。默认不包含已归档聊天,勾选「包含已归档对话」后可只读查找。 +打开聊天后按 `Ctrl/Cmd+F` 进入当前聊天搜索,`Ctrl/Cmd+K` 进入全局搜索;可在快捷键设置中修改。 +查询会去掉首尾空白,最多 256 个 Unicode 字符,ASCII 字母不区分大小写,其他 Unicode 字符 +精确匹配;`%`、`_` 和反斜杠按普通字符匹配。清空查询返回任务列表。 + +结果按创建时间倒序排列,同一时间按记录 ID 倒序。继续使用响应的 `next_cursor` 获取 +更早匹配项;修改查询、聊天范围或归档筛选后须从第一页开始。并发新增较新消息不会移动旧页, 但重命名、归档或删除会改变后续查询的结果集。搜索、翻页与清空搜索不会切换当前任务, -也不会覆盖正在编辑的草稿。 +也不会覆盖正在编辑的草稿。点击消息结果会直接定位到原消息所在的历史窗口,包括较早 Run。 +搜索沿用公开记录检查和脱敏规则,不返回工具结果、内部事件、其他聊天的协作消息或已删除聊天。 列表同时返回 Go 投影的 `execution_state`,区分执行中、停止中、等待审批、暂停、空闲和 终态。输入框可编辑(`composer_state=ready`)不表示空闲。状态读取失败、缺少执行观察源 diff --git a/internal/application/thread_project_move_test.go b/internal/application/thread_project_move_test.go index 34a50ef1..192af38a 100644 --- a/internal/application/thread_project_move_test.go +++ b/internal/application/thread_project_move_test.go @@ -167,7 +167,7 @@ func TestThreadProjectMoveHistoryBranchAndUndoStayAtSource(t *testing.T) { if err != nil || len(page.Records) == 0 { t.Fatalf("history=%+v %v", page, err) } - matches, err := st.SearchThreadMessages(t.Context(), "SOURCE_HISTORY_CANARY", false, time.Time{}, "", 20) + matches, err := st.SearchThreadMessages(t.Context(), "SOURCE_HISTORY_CANARY", "", false, time.Time{}, "", 20) if err != nil || len(matches) != 1 || matches[0].ThreadID != moved.Thread.ID { t.Fatalf("saved search=%+v %v", matches, err) } diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 1fc4802e..4f518fd0 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -1074,10 +1074,11 @@ func openAPIOperationSpecs() []openAPIOperationSpec { }}, {Path: "/api/v1/threads/search", OperationID: "searchSavedThreadMessages", Summary: "Search saved conversation titles and public message bodies", Tag: "Threads", - Description: "Searches complete saved user and assistant text, including compacted history, before pagination. Uses literal ASCII case-insensitive substrings, with exact matching for other Unicode. Redacts and checks the existing public transcript projection before returning a match. Excludes deleted conversations, tools, peer messages and internal events. Defaults to active conversations; include_archived also includes read-only archived history. Results use creation-time and identity cursors scoped to the normalized query and archive filter.", + Description: "Searches complete saved user and assistant text, including compacted history, public model progress and inherited branch replies, before pagination. With thread_id, searches only message bodies in that exact conversation; otherwise also searches conversation titles. Uses literal ASCII case-insensitive substrings, with exact matching for other Unicode. Redacts and checks the existing public transcript projection before returning a match. Excludes deleted conversations, tools, peer messages and internal events. Defaults to active conversations; include_archived also includes read-only archived history. Results use creation-time and identity cursors scoped to the normalized query, Thread and archive filter.", DataType: reflect.TypeOf(ThreadSearchResultView{}), Collection: true, Paginated: true, Parameters: append(paginationParameters(), openAPIParameter{Name: "q", In: "query", Required: true, Schema: map[string]any{"type": "string", "minLength": 1, "maxLength": 256}}, + openAPIParameter{Name: "thread_id", In: "query", Description: "Search saved public message bodies in this exact conversation", Schema: map[string]any{"type": "string", "minLength": 1, "maxLength": 256}}, booleanQueryParameter("include_archived", "Include archived conversations"))}, {Path: "/api/v1/threads/{thread_id}/messages/{message_id}", OperationID: "getSavedThreadMessage", Summary: "Locate a saved public message in its conversation", Tag: "Threads", diff --git a/internal/httpapi/thread_search.go b/internal/httpapi/thread_search.go index 4d34282a..c565bee0 100644 --- a/internal/httpapi/thread_search.go +++ b/internal/httpapi/thread_search.go @@ -15,7 +15,7 @@ import ( ) type threadSearchStore interface { - SearchThreadMessages(context.Context, string, bool, time.Time, string, int) ([]domain.ThreadSearchMatch, error) + SearchThreadMessages(context.Context, string, string, bool, time.Time, string, int) ([]domain.ThreadSearchMatch, error) GetThreadSearchMessage(context.Context, string, string) (domain.ThreadSearchMatch, error) } @@ -39,7 +39,7 @@ type ThreadSavedMessageView struct { func (a *API) searchThreadMessages(request *http.Request) (any, *Page, error) { values := request.URL.Query() - if err := validateSingleQueryValues(values, "q", "include_archived", "limit", "cursor"); err != nil { + if err := validateSingleQueryValues(values, "q", "thread_id", "include_archived", "limit", "cursor"); err != nil { return nil, nil, err } for key, entries := range values { @@ -52,6 +52,12 @@ func (a *API) searchThreadMessages(request *http.Request) (any, *Page, error) { return nil, nil, apperror.New(apperror.CodeInvalidArgument, "Search requires between 1 and 256 characters") } values.Set("q", query) + threadID := values.Get("thread_id") + if _, scoped := values["thread_id"]; scoped { + if err := validateIdentity(threadID, "search Thread identity"); err != nil { + return nil, nil, err + } + } includeArchived := false if raw, exists := singleQueryValue(values, "include_archived"); exists { includeArchived, err = strconv.ParseBool(raw) @@ -68,7 +74,7 @@ func (a *API) searchThreadMessages(request *http.Request) (any, *Page, error) { if !ok { return nil, nil, apperror.New(apperror.CodeNotFound, "Saved message search is unavailable") } - items, err := reader.SearchThreadMessages(request.Context(), query, includeArchived, + items, err := reader.SearchThreadMessages(request.Context(), query, threadID, includeArchived, pageRequest.Anchor.BeforeCreatedAt, pageRequest.Anchor.BeforeID, stableListStoreLimit(pageRequest)) if err != nil { return nil, nil, err diff --git a/internal/httpapi/thread_search_test.go b/internal/httpapi/thread_search_test.go index 9c2efa19..8fb207e2 100644 --- a/internal/httpapi/thread_search_test.go +++ b/internal/httpapi/thread_search_test.go @@ -1,6 +1,7 @@ package httpapi import ( + "encoding/json" "net/http" "net/url" "strings" @@ -8,10 +9,77 @@ import ( "cyberagent-workbench/internal/application" "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/events" "cyberagent-workbench/internal/session" ) +func TestSavedMessageSearchFindsAndLocatesInheritedPublicReply(t *testing.T) { + f := newAPIFixture(t) + ctx := t.Context() + _, run, err := application.NewRunService(f.store).Create(ctx, application.CreateRunRequest{ + Goal: "branch search", WorkspaceID: f.workspace.ID, Profile: "review", Interactive: true}) + if err != nil { + t.Fatal(err) + } + full := strings.Repeat("完整的早期回复。", 1000) + " InheritedSearchNeedle" + for _, message := range []session.Message{ + session.NewMessage(run.SessionID, "user", "preserve the earlier answer"), + session.NewEvidenceMessage(run.SessionID, session.SourceToolResult, "hidden-tool", "HiddenBranchSearchCanary"), + session.NewMessage(run.SessionID, "assistant", full), + } { + if _, err := f.store.SaveSessionMessage(ctx, message); err != nil { + t.Fatal(err) + } + } + recorded, err := f.store.ListRunEvents(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + var anchor string + for _, event := range recorded { + if event.Type == events.SessionMessageEvent { + var payload struct{ Role string } + _ = json.Unmarshal([]byte(event.PayloadJSON), &payload) + if payload.Role == "assistant" { + anchor = event.EventID + } + } + } + branch, err := application.NewThreadBranchService(f.store).Branch(ctx, domain.ThreadBranchRequest{ + Version: domain.ThreadBranchProtocolVersion, ThreadID: domain.InitialThreadID(run.ID), SourceEventID: anchor, + OperationKey: "branch-search-inherited-reply", RequestedBy: "test_operator"}) + if err != nil { + t.Fatal(err) + } + var matches []ThreadSearchResultView + decodeData(t, f.get(t, "/api/v1/threads/search?q=InheritedSearchNeedle&thread_id="+branch.Thread.ID), &matches) + if len(matches) != 1 || matches[0].ThreadID != branch.Thread.ID || matches[0].Role != "assistant" || matches[0].MessageID == anchor { + t.Fatalf("inherited reply was not searchable: %#v", matches) + } + var located ThreadSavedMessageView + decodeData(t, f.get(t, "/api/v1/threads/"+branch.Thread.ID+"/messages/"+matches[0].MessageID), &located) + if located.Content != full { + t.Fatalf("inherited reply body was truncated: got %d want %d bytes", len(located.Content), len(full)) + } + var window []ThreadTranscriptItemView + decodeData(t, f.get(t, "/api/v1/threads/"+branch.Thread.ID+"/transcript?limit=2&cursor="+url.QueryEscape(located.TranscriptCursor)), &window) + if len(window) == 0 || window[len(window)-1].ID != matches[0].MessageID || window[len(window)-1].SourceRef != "branch-assistant:"+anchor { + t.Fatalf("inherited reply cursor did not locate its commentary: %#v", window) + } + for _, scope := range []string{"", "&thread_id=" + branch.Thread.ID} { + decodeData(t, f.get(t, "/api/v1/threads/search?q=HiddenBranchSearchCanary"+scope), &matches) + if len(matches) != 0 { + t.Fatalf("hidden inherited context was searchable: %#v", matches) + } + } +} + func TestSavedMessageSearchFindsFullCompactedHistoryAndLocatesAcrossRuns(t *testing.T) { + t.Run("all conversations", func(t *testing.T) { testSavedMessageSearchHistory(t, false) }) + t.Run("current conversation", func(t *testing.T) { testSavedMessageSearchHistory(t, true) }) +} + +func testSavedMessageSearchHistory(t *testing.T, scoped bool) { f := newAPIFixture(t) ctx := t.Context() full := strings.Repeat("早期背景。", 2400) + " 深处 SavedNeedle 最终要求" @@ -32,6 +100,17 @@ func TestSavedMessageSearchFindsFullCompactedHistoryAndLocatesAcrossRuns(t *test t.Fatal(err) } threadID := domain.InitialThreadID(f.run.ID) + filter := "" + if scoped { + filter = "&thread_id=" + url.QueryEscape(threadID) + _, other, err := runs.Create(ctx, application.CreateRunRequest{Goal: "SavedNeedle in another conversation", Profile: "review"}) + if err != nil { + t.Fatal(err) + } + if _, err := f.store.SaveSessionMessage(ctx, session.NewMessage(other.SessionID, "assistant", "unrelated SavedNeedle answer")); err != nil { + t.Fatal(err) + } + } continued, err := application.NewThreadService(f.store).Submit(ctx, application.SubmitThreadMessageRequest{ Version: domain.ThreadMessageProtocolVersion, ThreadID: threadID, Content: "a new run", OperationKey: "saved-message-search-successor", RequestedBy: "test_operator"}) @@ -42,7 +121,7 @@ func TestSavedMessageSearchFindsFullCompactedHistoryAndLocatesAcrossRuns(t *test t.Fatal(err) } var first []ThreadSearchResultView - envelope := decodeData(t, f.get(t, "/api/v1/threads/search?q=SavedNeedle&limit=1"), &first) + envelope := decodeData(t, f.get(t, "/api/v1/threads/search?q=SavedNeedle&limit=1"+filter), &first) if len(first) != 1 || first[0].Role != "assistant" || envelope.Page.NextCursor == "" { t.Fatalf("first search page: %#v %#v", first, envelope.Page) } @@ -50,7 +129,7 @@ func TestSavedMessageSearchFindsFullCompactedHistoryAndLocatesAcrossRuns(t *test t.Fatal(err) } var next []ThreadSearchResultView - decodeData(t, f.get(t, "/api/v1/threads/search?q=savedneedle&limit=1&cursor="+url.QueryEscape(envelope.Page.NextCursor)), &next) + decodeData(t, f.get(t, "/api/v1/threads/search?q=savedneedle&limit=1"+filter+"&cursor="+url.QueryEscape(envelope.Page.NextCursor)), &next) if len(next) != 1 || next[0].Role != "user" || next[0].ThreadID != threadID || !strings.Contains(next[0].Snippet, "SavedNeedle") { t.Fatalf("old full-body match missing: %#v", next) } @@ -72,13 +151,25 @@ func TestSavedMessageSearchFindsFullCompactedHistoryAndLocatesAcrossRuns(t *test t.Fatalf("cursor did not locate exact cross-Run target: %#v", window) } assertAPIError(t, f.get(t, "/api/v1/threads/"+threadID+"-other/messages/"+next[0].MessageID), http.StatusNotFound, "NOT_FOUND") - assertAPIError(t, f.get(t, "/api/v1/threads/search?q=other&cursor="+url.QueryEscape(envelope.Page.NextCursor)), http.StatusBadRequest, "INVALID_ARGUMENT") - assertAPIError(t, f.get(t, "/api/v1/threads/search?q=savedneedle&include_archived=true&cursor="+url.QueryEscape(envelope.Page.NextCursor)), http.StatusBadRequest, "INVALID_ARGUMENT") + for _, changed := range []string{ + "q=other" + filter, + "q=savedneedle&include_archived=true" + filter, + "q=savedneedle&thread_id=" + threadID + "-other", + } { + assertAPIError(t, f.get(t, "/api/v1/threads/search?"+changed+"&cursor="+url.QueryEscape(envelope.Page.NextCursor)), http.StatusBadRequest, "INVALID_ARGUMENT") + } + // Neither direction may carry a cursor between global and current-chat search. + changedScope := "&thread_id=" + threadID + if scoped { + changedScope = "" + } + assertAPIError(t, f.get(t, "/api/v1/threads/search?q=savedneedle"+changedScope+"&cursor="+url.QueryEscape(envelope.Page.NextCursor)), http.StatusBadRequest, "INVALID_ARGUMENT") } func TestSavedMessageSearchPrivacyLifecycleTitlesAndValidation(t *testing.T) { f := newAPIFixture(t) ctx := t.Context() + filter := "&thread_id=" + domain.InitialThreadID(f.run.ID) for _, message := range []session.Message{ session.NewMessage(f.run.SessionID, "assistant", "public body Literal%_ 中文Ä"), session.NewMessage(f.run.SessionID, "system", "HiddenSearchCanary"), @@ -91,10 +182,12 @@ func TestSavedMessageSearchPrivacyLifecycleTitlesAndValidation(t *testing.T) { } } for _, query := range []string{"HiddenSearchCanary", "SecretSearchCanary"} { - var matches []ThreadSearchResultView - decodeData(t, f.get(t, "/api/v1/threads/search?q="+query), &matches) - if len(matches) != 0 { - t.Fatalf("private text produced a match for %s: %#v", query, matches) + for _, scope := range []string{"", filter} { + var matches []ThreadSearchResultView + decodeData(t, f.get(t, "/api/v1/threads/search?q="+query+scope), &matches) + if len(matches) != 0 { + t.Fatalf("private text produced a match for %s: %#v", query, matches) + } } } var matches []ThreadSearchResultView @@ -108,7 +201,16 @@ func TestSavedMessageSearchPrivacyLifecycleTitlesAndValidation(t *testing.T) { if len(matches) != 1 || matches[0].Role != "title" || matches[0].MessageID != "" { t.Fatalf("title matching was lost: %#v", matches) } - for _, path := range []string{"?q=", "?q=" + strings.Repeat("x", 257), "?q=a&q=b", "?q=a&limit=101", "?q=a&include_archived=invalid", "?q=a&include_archived=true&include_archived=false", "?q=a&unknown=true"} { + decodeData(t, f.get(t, "/api/v1/threads/search?q="+url.QueryEscape("inspect durable agent state")+filter), &matches) + if len(matches) != 0 { + t.Fatalf("current-chat search returned a title instead of a message: %#v", matches) + } + decodeData(t, f.get(t, "/api/v1/threads/search?q=public&thread_id=unknown-thread"), &matches) + if len(matches) != 0 { + t.Fatalf("unknown Thread matched another conversation: %#v", matches) + } + for _, path := range []string{"?q=", "?q=" + strings.Repeat("x", 257), "?q=a&q=b", "?q=a&limit=101", "?q=a&include_archived=invalid", "?q=a&include_archived=true&include_archived=false", "?q=a&unknown=true", + "?q=a&thread_id=", "?q=a&thread_id=one&thread_id=two", "?q=a&thread_id=%20one", "?q=a&thread_id=one%2Ftwo", "?q=a&thread_id=one%00", "?q=a&thread_id=" + strings.Repeat("x", 257)} { assertAPIError(t, f.get(t, "/api/v1/threads/search"+path), http.StatusBadRequest, "INVALID_ARGUMENT") } assertAPIError(t, f.request(t, http.MethodGet, "/api/v1/threads/search?q=public", "", "127.0.0.1:8765", "127.0.0.1:45000", nil), http.StatusUnauthorized, "POLICY_DENIED") @@ -130,13 +232,15 @@ func TestSavedMessageSearchPrivacyLifecycleTitlesAndValidation(t *testing.T) { if err != nil { t.Fatal(err) } - decodeData(t, f.get(t, "/api/v1/threads/search?q=public"), &matches) - if len(matches) != 0 { - t.Fatalf("archived thread included by default: %#v", matches) - } - decodeData(t, f.get(t, "/api/v1/threads/search?q=public&include_archived=true"), &matches) - if len(matches) != 1 || matches[0].ThreadStatus != "archived" { - t.Fatalf("archived search: %#v", matches) + for _, scope := range []string{"", filter} { + decodeData(t, f.get(t, "/api/v1/threads/search?q=public"+scope), &matches) + if len(matches) != 0 { + t.Fatalf("archived thread included by default: %#v", matches) + } + decodeData(t, f.get(t, "/api/v1/threads/search?q=public&include_archived=true"+scope), &matches) + if len(matches) != 1 || matches[0].ThreadStatus != "archived" { + t.Fatalf("archived search: %#v", matches) + } } var located ThreadSavedMessageView decodeData(t, f.get(t, "/api/v1/threads/"+thread.ID+"/messages/"+messageID), &located) @@ -147,9 +251,11 @@ func TestSavedMessageSearchPrivacyLifecycleTitlesAndValidation(t *testing.T) { if _, err := application.NewThreadService(f.store).Transition(ctx, thread.ID, domain.ThreadDelete, after.Version, "test_operator", "delete-search-fixture"); err != nil { t.Fatal(err) } - decodeData(t, f.get(t, "/api/v1/threads/search?q=public&include_archived=true"), &matches) - if len(matches) != 0 { - t.Fatalf("deleted history was searchable: %#v", matches) + for _, scope := range []string{"", filter} { + decodeData(t, f.get(t, "/api/v1/threads/search?q=public&include_archived=true"+scope), &matches) + if len(matches) != 0 { + t.Fatalf("deleted history was searchable: %#v", matches) + } } assertAPIError(t, f.get(t, "/api/v1/threads/"+thread.ID+"/messages/"+messageID), http.StatusNotFound, "NOT_FOUND") } diff --git a/internal/store/thread_search.go b/internal/store/thread_search.go index 0367815d..95c1c484 100644 --- a/internal/store/thread_search.go +++ b/internal/store/thread_search.go @@ -2,7 +2,6 @@ package store import ( "context" - "database/sql" "errors" "regexp" "strings" @@ -10,7 +9,6 @@ import ( "cyberagent-workbench/internal/apperror" "cyberagent-workbench/internal/domain" - "cyberagent-workbench/internal/events" "cyberagent-workbench/internal/redact" "cyberagent-workbench/internal/threadtranscript" ) @@ -39,10 +37,17 @@ const threadSearchCandidates = `WITH candidates AS ( (json_extract(event.payload_json,'$.role')='user' AND json_extract(event.payload_json,'$.source_kind')='operator_message') OR (json_extract(event.payload_json,'$.role')='assistant' AND json_extract(event.payload_json,'$.source_kind')='model_response'))) OR (event.type='operator.steering_queued' AND steering.status IN ('pending','cancelled','committed')) + UNION ALL + SELECT event.event_id, thread.id, thread.title, thread.status, + thread.workspace_id, event.event_id, 'assistant', + COALESCE(json_extract(event.payload_json,'$.text'),''), event.created_at, binding.ordinal, event.sequence + FROM threads thread JOIN thread_runs binding ON binding.thread_id=thread.id + JOIN run_events event ON event.run_id=binding.run_id + WHERE event.type='model.public_commentary' ) SELECT id, thread_id, title, status, workspace_id, message_id, role, content, created_at, ordinal, sequence FROM candidates WHERE status IN ('active'` -func (s *SQLiteStore) SearchThreadMessages(ctx context.Context, query string, includeArchived bool, +func (s *SQLiteStore) SearchThreadMessages(ctx context.Context, query, threadID string, includeArchived bool, before time.Time, beforeID string, limit int, ) ([]domain.ThreadSearchMatch, error) { if err := validateStoreCreationPage(before, beforeID, limit); err != nil { @@ -57,7 +62,7 @@ func (s *SQLiteStore) SearchThreadMessages(ctx context.Context, query string, in } items := make([]domain.ThreadSearchMatch, 0, limit) for len(items) < limit { - batch, err := s.threadSearchCandidates(ctx, query, includeArchived, before, beforeID, "", "", 100) + batch, err := s.threadSearchCandidates(ctx, query, includeArchived, before, beforeID, threadID, "", 100) if err != nil { return nil, err } @@ -117,8 +122,12 @@ func (s *SQLiteStore) threadSearchCandidates(ctx context.Context, query string, args = append(args, query) } if threadID != "" { - sql += ` AND thread_id=? AND message_id=?` - args = append(args, threadID, messageID) + sql += ` AND thread_id=? AND message_id<>''` + args = append(args, threadID) + } + if messageID != "" { + sql += ` AND message_id=?` + args = append(args, messageID) } if !before.IsZero() { key := creationTimestampKeySQL("created_at") @@ -180,27 +189,6 @@ func (s *SQLiteStore) GetThreadSearchMessage(ctx context.Context, threadID, mess if err != nil { return domain.ThreadSearchMatch{}, err } - // Public progress and replies inherited by a branch are commentary events, - // not session messages. Resolve their full body through the same public - // projection and redaction below without broadening search candidates. - if len(items) == 0 { - item := domain.ThreadSearchMatch{ID: messageID, MessageID: messageID, ThreadID: threadID, Role: "assistant"} - var created string - err := s.db.QueryRowContext(ctx, `SELECT thread.title, thread.status, thread.workspace_id, - COALESCE(json_extract(event.payload_json,'$.text'),''), event.created_at, binding.ordinal, event.sequence - FROM threads thread JOIN thread_runs binding ON binding.thread_id=thread.id - JOIN run_events event ON event.run_id=binding.run_id - WHERE thread.id=? AND thread.status IN ('active','archived') AND event.event_id=? AND event.type=?`, - threadID, messageID, events.ModelPublicCommentaryEvent).Scan(&item.ThreadTitle, &item.ThreadStatus, - &item.WorkspaceID, &item.Content, &created, &item.RunOrdinal, &item.Sequence) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return domain.ThreadSearchMatch{}, err - } - if err == nil { - item.CreatedAt = parseTS(created) - items = append(items, item) - } - } if len(items) > 0 { item := items[0] visible, err := s.publicSearchMessage(ctx, item) diff --git a/internal/store/thread_search_test.go b/internal/store/thread_search_test.go index dc21e385..8718f5f8 100644 --- a/internal/store/thread_search_test.go +++ b/internal/store/thread_search_test.go @@ -46,6 +46,12 @@ func TestSavedPublicCommentaryRetainsFullBodyAndVisibility(t *testing.T) { if err != nil || got.Content != full || got.Role != "assistant" { t.Fatalf("full public reply: %+v %v", got, err) } + for _, scope := range []string{"", threadID} { + matches, err := st.SearchThreadMessages(t.Context(), "commentary needle", scope, false, time.Time{}, "", 10) + if err != nil || len(matches) != 1 || matches[0].ID != eventID || matches[0].Content != full || matches[0].Role != "assistant" { + t.Fatalf("full %s commentary search: %+v %v", source, matches, err) + } + } if _, err := st.GetThreadSearchMessage(t.Context(), threadID+"-other", eventID); err == nil { t.Fatal("cross-thread commentary was readable") } @@ -57,12 +63,22 @@ func TestSavedPublicCommentaryRetainsFullBodyAndVisibility(t *testing.T) { if err != nil || strings.Contains(got.Content, "CommentarySecret") { t.Fatalf("commentary redaction: %+v %v", got, err) } - if _, err := st.db.Exec(`UPDATE run_events SET type='model.started' WHERE event_id=?`, eventID); err != nil { + for _, scope := range []string{"", threadID} { + matches, err := st.SearchThreadMessages(t.Context(), "CommentarySecret", scope, false, time.Time{}, "", 10) + if err != nil || len(matches) != 0 { + t.Fatalf("secret commentary search: %+v %v", matches, err) + } + } + if _, err := st.db.Exec(`UPDATE run_events SET type='model.started',payload_json='{"text":"internal commentary needle"}' WHERE event_id=?`, eventID); err != nil { t.Fatal(err) } if _, err := st.GetThreadSearchMessage(t.Context(), threadID, eventID); err == nil { t.Fatal("internal event was readable as a public message") } + matches, err := st.SearchThreadMessages(t.Context(), "commentary needle", threadID, false, time.Time{}, "", 10) + if err != nil || len(matches) != 0 { + t.Fatalf("internal commentary was searchable: %+v %v", matches, err) + } } } @@ -103,11 +119,15 @@ func TestThreadSearchRedactsHistoricalCandidatesBeforeMatchingAndSurvivesRestart t.Fatal(err) } t.Cleanup(func() { _ = st.Close() }) - items, err := st.SearchThreadMessages(t.Context(), "Needle", false, time.Time{}, "", 2) + items, err := st.SearchThreadMessages(t.Context(), "Needle", "", false, time.Time{}, "", 2) if err != nil || len(items) != 1 || items[0].Content != "visible Needle answer" { t.Fatalf("redacted candidate affected matches: %#v %v", items, err) } - more, err := st.SearchThreadMessages(t.Context(), "Needle", false, items[0].CreatedAt, items[0].ID, 2) + scoped, err := st.SearchThreadMessages(t.Context(), "Needle", domain.InitialThreadID(run.ID), false, time.Time{}, "", 2) + if err != nil || len(scoped) != 1 || scoped[0].ID != items[0].ID { + t.Fatalf("current-chat search bypassed historical redaction: %#v %v", scoped, err) + } + more, err := st.SearchThreadMessages(t.Context(), "Needle", "", false, items[0].CreatedAt, items[0].ID, 2) if err != nil || len(more) != 0 { t.Fatalf("filtered result affected pagination: %#v %v", more, err) } @@ -149,7 +169,7 @@ func TestThreadSearchUserMockTextAndExactTimestampPagination(t *testing.T) { var before time.Time beforeID := "" for _, expected := range []string{ids[2], ids[1], ids[0]} { - items, err := st.SearchThreadMessages(t.Context(), "checkout", false, before, beforeID, 1) + items, err := st.SearchThreadMessages(t.Context(), "checkout", "", false, before, beforeID, 1) if err != nil || len(items) != 1 || items[0].ID != expected { t.Fatalf("timestamp page: %#v %v; want %s", items, err, expected) } @@ -161,7 +181,7 @@ func TestThreadSearchUserMockTextAndExactTimestampPagination(t *testing.T) { } before, beforeID = items[0].CreatedAt, items[0].ID } - items, err := st.SearchThreadMessages(t.Context(), "checkout", false, before, beforeID, 1) + items, err := st.SearchThreadMessages(t.Context(), "checkout", "", false, before, beforeID, 1) if err != nil || len(items) != 0 { t.Fatalf("unexpected extra matches: %#v %v", items, err) } diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index c9de4739..0d48cb4b 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -4721,7 +4721,7 @@ export interface paths { }; /** * Search saved conversation titles and public message bodies - * @description Searches complete saved user and assistant text, including compacted history, before pagination. Uses literal ASCII case-insensitive substrings, with exact matching for other Unicode. Redacts and checks the existing public transcript projection before returning a match. Excludes deleted conversations, tools, peer messages and internal events. Defaults to active conversations; include_archived also includes read-only archived history. Results use creation-time and identity cursors scoped to the normalized query and archive filter. + * @description Searches complete saved user and assistant text, including compacted history, public model progress and inherited branch replies, before pagination. With thread_id, searches only message bodies in that exact conversation; otherwise also searches conversation titles. Uses literal ASCII case-insensitive substrings, with exact matching for other Unicode. Redacts and checks the existing public transcript projection before returning a match. Excludes deleted conversations, tools, peer messages and internal events. Defaults to active conversations; include_archived also includes read-only archived history. Results use creation-time and identity cursors scoped to the normalized query, Thread and archive filter. */ get: operations["searchSavedThreadMessages"]; put?: never; @@ -28269,6 +28269,8 @@ export interface operations { /** @description Opaque cursor bound to this route and exact filter set */ cursor?: string; q: string; + /** @description Search saved public message bodies in this exact conversation */ + thread_id?: string; /** @description Include archived conversations */ include_archived?: boolean; }; diff --git a/web/src/v2/app.tsx b/web/src/v2/app.tsx index 6f3aae23..f54a66dd 100644 --- a/web/src/v2/app.tsx +++ b/web/src/v2/app.tsx @@ -309,9 +309,23 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { return () => media.removeListener?.(onChange); }, []); const [searchOpen, setSearchOpen] = useState(false); + const [searchFocusRequest, setSearchFocusRequest] = useState(0); + useEffect(() => { + if (!searchFocusRequest || !searchOpen || !sidebarVisible) return; + // The drawer's visibility transition starts after the DOM commit. + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + const input = sidebarSlot.current?.querySelector(".v2-sidebar-search input"); + input?.focus(); input?.select(); + }); + }); + return () => cancelAnimationFrame(frame); + }, [searchFocusRequest, searchOpen, sidebarVisible]); const [threadSearch, setThreadSearch] = useState(""); const [appliedThreadSearch, setAppliedThreadSearch] = useState(""); const [includeArchivedSearch, setIncludeArchivedSearch] = useState(false); + const [searchCurrentThread, setSearchCurrentThread] = useState(false); + const searchThreadID = searchCurrentThread ? selectedThreadID : ""; const [searchComposing, setSearchComposing] = useState(false); const normalizedThreadSearch = threadSearch.trim(); const searchTooLong = Array.from(normalizedThreadSearch).length > 256; @@ -399,9 +413,10 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { (query.state.data?.pages.length ?? 1) === 1 ? 15_000 : false, }); const messageSearchQuery = useInfiniteQuery({ - queryKey: ["v2", "threads", "active", "messages", { q: appliedThreadSearch, include_archived: includeArchivedSearch }], + queryKey: ["v2", "threads", "active", "messages", { q: appliedThreadSearch, include_archived: includeArchivedSearch, thread_id: searchThreadID }], queryFn: ({ signal, pageParam }) => client.getPage("/threads/search", - { q: appliedThreadSearch, include_archived: includeArchivedSearch, limit: 50 }, pageParam, signal), + { q: appliedThreadSearch, include_archived: includeArchivedSearch, limit: 50, + ...(searchThreadID ? { thread_id: searchThreadID } : {}) }, pageParam, signal), initialPageParam: "", getNextPageParam: (last) => last.page.next_cursor || undefined, enabled: Boolean(appliedThreadSearch) && !searchPending && !searchTooLong, refetchInterval: (query) => sidebarVisible && surface === "conversation" && !searchPending && @@ -594,9 +609,18 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { closeNavigationSidebar(); }; + const openSearch = (currentThread: boolean) => { + if (currentThread && (!selectedThreadID || surface !== "conversation" || route.tool || view !== "conversation")) return false; + if (surface === "settings") returnFromSettings(); + setSearchCurrentThread(currentThread); + setSidebarVisible(true); + setSearchOpen(true); + setSearchFocusRequest((request) => request + 1); + }; useChatShortcuts({ newChat: () => startNew(), - search: () => { if (surface === "settings") returnFromSettings(); setSidebarVisible(true); setSearchOpen(true); }, + search: () => openSearch(false), + findInChat: () => openSearch(true), settings: openSettings, sidebar: () => setSidebarVisible(!sidebarVisible), composer: () => { @@ -631,11 +655,13 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { inspectorActive={view === "inspector"} onSearchOpen={(open) => { setSearchOpen(open); + setSearchCurrentThread(false); if (!open) { setThreadSearch(""); setSearchComposing(false); } }} onSelectThread={openConversation} searchOpen={searchOpen} search={threadSearch} appliedSearch={appliedThreadSearch} searchPending={searchPending} searchTooLong={searchTooLong} searchResults={searchResults} includeArchived={includeArchivedSearch} onIncludeArchivedChange={setIncludeArchivedSearch} + searchCurrentThread={Boolean(searchThreadID)} onSearchCurrentThreadChange={setSearchCurrentThread} onSelectSearchResult={(result) => { navigate({ kind: "thread", threadID: result.thread_id, ...(result.message_id ? { messageID: result.message_id } : {}) }); closeNavigationSidebar(); diff --git a/web/src/v2/chat-preferences.ts b/web/src/v2/chat-preferences.ts index a7055d68..183041f4 100644 --- a/web/src/v2/chat-preferences.ts +++ b/web/src/v2/chat-preferences.ts @@ -6,6 +6,7 @@ export type SendPreference = "enter" | "multiline" | "modifier"; export const shortcutCommands = [ { id: "newChat", zh: "新对话", en: "New chat", binding: "Mod+Shift+O" }, { id: "search", zh: "搜索聊天", en: "Search chats", binding: "Mod+K" }, + { id: "findInChat", zh: "在当前聊天中查找", en: "Find in current chat", binding: "Mod+F" }, { id: "settings", zh: "打开设置", en: "Open settings", binding: "Mod+," }, { id: "sidebar", zh: "展开/收起侧栏", en: "Toggle sidebar", binding: "Mod+B" }, { id: "composer", zh: "聚焦输入框", en: "Focus composer", binding: "Mod+Shift+I" }, @@ -26,7 +27,7 @@ function parse(raw: string | null): ChatPreferences { const shortcuts: ChatPreferences["shortcuts"] = {}; for (const { id } of shortcutCommands) { const binding = value.shortcuts?.[id]; - if (binding === "" || (typeof binding === "string" && validShortcut(binding))) shortcuts[id] = binding; + if (binding === "" || (typeof binding === "string" && validShortcut(binding, id))) shortcuts[id] = binding; } return { send: ["enter", "multiline", "modifier"].includes(value.send) ? value.send : "enter", shortcuts }; } catch { return defaults; } @@ -57,7 +58,8 @@ export function eventShortcut(event: Pick void>) { +export function useChatShortcuts(actions: Record void | boolean>) { const preferences = useChatPreferences(); const latest = useRef({ actions, preferences }); latest.current = { actions, preferences }; @@ -78,8 +80,9 @@ export function useChatShortcuts(actions: Record void>) { if (!binding) return; const command = shortcutCommands.find(({ id }) => shortcutBinding(latest.current.preferences, id) === binding); if (!command) return; - event.preventDefault(); - latest.current.actions[command.id](); + if (command.id === "findInChat" && event.target instanceof Element && event.target.closest(".monaco-editor, .xterm")) return; + // A contextual action may leave the native shortcut available outside its surface. + if (latest.current.actions[command.id]() !== false) event.preventDefault(); }; window.addEventListener("keydown", keydown); return () => window.removeEventListener("keydown", keydown); diff --git a/web/src/v2/components/keyboard-settings.test.tsx b/web/src/v2/components/keyboard-settings.test.tsx index 9d573383..3e705a2d 100644 --- a/web/src/v2/components/keyboard-settings.test.tsx +++ b/web/src/v2/components/keyboard-settings.test.tsx @@ -4,8 +4,8 @@ import { V2KeyboardSettings } from "./keyboard-settings"; import { chatPreferencesKey, saveChatPreferences, useChatShortcuts } from "../chat-preferences"; afterEach(() => localStorage.removeItem(chatPreferencesKey)); -function Commands({ search }: { search: () => void }) { - useChatShortcuts({ search, newChat: vi.fn(), settings: vi.fn(), sidebar: vi.fn(), composer: vi.fn() }); +function Commands({ search, findInChat = vi.fn() }: { search: () => void; findInChat?: () => void | boolean }) { + useChatShortcuts({ search, findInChat, newChat: vi.fn(), settings: vi.fn(), sidebar: vi.fn(), composer: vi.fn() }); return ; } @@ -56,3 +56,30 @@ it("rejects editing and duplicate bindings, and ignores IME, repeats and modal d fireEvent.keyDown(window, { key: "j", code: "KeyJ", metaKey: true }); expect(search).toHaveBeenCalledOnce(); }); + +it("persists the current-chat find binding and yields to native find outside its context or in editors", () => { + const findInChat = vi.fn<() => boolean>().mockReturnValue(false); + const view = render(); + const unavailable = new KeyboardEvent("keydown", { key: "f", code: "KeyF", ctrlKey: true, cancelable: true }); + fireEvent(window, unavailable); + expect(unavailable.defaultPrevented).toBe(false); + findInChat.mockReturnValue(true); + const available = new KeyboardEvent("keydown", { key: "f", code: "KeyF", metaKey: true, cancelable: true }); + fireEvent(window, available); + expect(available.defaultPrevented).toBe(true); + for (const className of ["monaco-editor", "xterm"]) { + const editor = document.createElement("input"); editor.className = className; document.body.append(editor); + fireEvent.keyDown(editor, { key: "f", code: "KeyF", ctrlKey: true }); + editor.remove(); + } + expect(findInChat).toHaveBeenCalledTimes(2); + const record = screen.getByRole("button", { name: "Change shortcut for Find in current chat" }); + fireEvent.click(record); + fireEvent.keyDown(record, { key: "f", code: "KeyF", ctrlKey: true }); + expect(localStorage.getItem(chatPreferencesKey)).toContain('"findInChat":"Mod+F"'); + view.unmount(); + render(); + expect(screen.getByRole("button", { name: "Change shortcut for Find in current chat" })).toHaveTextContent("Ctrl+F"); + fireEvent.keyDown(window, { key: "f", code: "KeyF", ctrlKey: true }); + expect(findInChat).toHaveBeenCalledTimes(3); +}); diff --git a/web/src/v2/components/keyboard-settings.tsx b/web/src/v2/components/keyboard-settings.tsx index c831753f..2027c469 100644 --- a/web/src/v2/components/keyboard-settings.tsx +++ b/web/src/v2/components/keyboard-settings.tsx @@ -53,7 +53,7 @@ export function V2KeyboardSettings() { if (event.key === "Backspace" || event.key === "Delete") { save({ ...preferences.shortcuts, [command.id]: "" }); return; } const next = eventShortcut(event.nativeEvent); if (!next) return; - if (!validShortcut(next)) { setError(t("此组合键保留给系统、浏览器或文本编辑,请换一个。", "This shortcut is reserved for the system, browser or text editing. Choose another.")); return; } + if (!validShortcut(next, command.id)) { setError(t("此组合键保留给系统、浏览器或文本编辑,请换一个。", "This shortcut is reserved for the system, browser or text editing. Choose another.")); return; } const conflict = shortcutCommands.find(({ id }) => id !== command.id && shortcutBinding(preferences, id) === next); if (conflict) { setError(t(`已用于「${conflict.zh}」,请先修改或清除该快捷键。`, `Already used by “${conflict.en}”. Change or clear that shortcut first.`)); return; } save({ ...preferences.shortcuts, [command.id]: next }); diff --git a/web/src/v2/components/sidebar.tsx b/web/src/v2/components/sidebar.tsx index 6973343d..cc7a807f 100644 --- a/web/src/v2/components/sidebar.tsx +++ b/web/src/v2/components/sidebar.tsx @@ -36,6 +36,7 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o onOpenInspector, inspectorActive = false, onOpenConnections, search = "", appliedSearch = "", searchPending = false, searchTooLong = false, onSearchChange, onSearchCompositionChange, searchResults = [], includeArchived = false, onIncludeArchivedChange, onSelectSearchResult, + searchCurrentThread = false, onSearchCurrentThreadChange, projectAction, onNewInProject, onEditProject, client, onMoveThread, movingThreadID = "", moveError = "", executionFilter = "", onExecutionFilterChange, visible = true, @@ -61,6 +62,8 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o includeArchived?: boolean; onIncludeArchivedChange?: (value: boolean) => void; onSelectSearchResult?: (result: ThreadSearchResultView) => void; + searchCurrentThread?: boolean; + onSearchCurrentThreadChange?: (value: boolean) => void; projectAction?: ReactNode; onNewInProject?: (projectID: string) => void; onEditProject?: (workspace: WorkspaceView) => void; @@ -172,11 +175,19 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o onCompositionEnd={(event) => { onSearchChange?.(event.currentTarget.value); onSearchCompositionChange?.(false); - }} placeholder={t("搜索标题与消息正文…", "Search titles and message text…")} type="search" value={search} /> + }} placeholder={searchCurrentThread ? t("搜索当前聊天的消息…", "Search messages in this chat…") + : t("搜索标题与消息正文…", "Search titles and message text…")} type="search" value={search} /> {search && }} - {searchOpen && <>

{t("搜索全部已保存的对话标题与用户、助手正文。英文字母忽略大小写,其他文字按原文匹配。", - "Search all saved conversation titles and user or assistant text. English letters are case-insensitive; other text matches exactly.")}

+ {searchOpen && <>{onSearchCurrentThreadChange &&
+ + +
}

{searchCurrentThread + ? t("搜索当前聊天全部已保存的用户、助手正文,包括尚未加载的历史。英文字母忽略大小写,其他文字按原文匹配。", + "Search all saved user or assistant text in this chat, including history not loaded yet. English letters are case-insensitive; other text matches exactly.") + : t("搜索全部已保存的对话标题与用户、助手正文。英文字母忽略大小写,其他文字按原文匹配。", + "Search all saved conversation titles and user or assistant text. English letters are case-insensitive; other text matches exactly.")}

} {searchTooLong && } @@ -189,7 +200,8 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o ? searching ? t("结果达到读取上限,请缩小搜索范围。", "The result limit was reached. Narrow your search.") : t("结果达到读取上限,请用搜索查找更早记录。", "The result limit was reached. Use search to find earlier records.") : hasMore ? searching ? t("还有更多匹配结果。", "More matches are available.") : t("还有更早记录。", "Earlier records are available.") : searching ? t("全部匹配结果已加载。", "All matches are loaded.") : t("当前列表已加载完毕。", "The current list is fully loaded.")}`}

{!searchPending && !loading && !loadFailed && !truncated && resultCount === 0 && (searching || workspaces.length === 0 && !projectAction) &&
} + {searching ? searchCurrentThread ? t("当前聊天没有匹配的消息", "No matching messages in this chat") + : t("没有匹配的标题或消息", "No matching titles or messages") : t("暂无对话", "No conversations yet")}} {!searchPending && searching &&
{searchResults.map((result) =>