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 {t("搜索全部已保存的对话标题与用户、助手正文。英文字母忽略大小写,其他文字按原文匹配。",
- "Search all saved conversation titles and user or assistant text. English letters are case-insensitive; other text matches exactly.")} {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.")} {t("搜索最多支持 256 个字符,请缩短搜索词。", "Searches support up to 256 characters. Shorten your search.")}