Skip to content

feat(chat): search a pane's conversation over messages and answers (#2018) - #2891

Merged
devswha merged 5 commits into
mainfrom
feat/conversation-search
Nov 5, 2026
Merged

devswha merged 5 commits into
mainfrom
feat/conversation-search

Conversation

@devswha

@devswha devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Fixes #2018. Proposed by @vibewriter-hand, who scoped it to the smaller version the closing notes of #180 describe; thank you for the careful write-up.

Change

Search conversation finds a word in a pane's conversation, older pages included, and lists the matches as snippets; picking one opens the chat at that message.

  • What it searches: the user's messages and the agent's answers as the chat shows them outside a work block (text not marked commentary). Tool input and output, thinking and progress notes are not searched. Markdown marks (bold, code backticks, list and heading marks, link addresses) are dropped before matching, so a query matches the words the chat draws. Case is ignored and any whitespace matches between the query's words.
  • Where it opens: the pane's menu (right-click on a desktop; on a phone, the tab's ⋯ sheet, or the header's More menu for a lone tab) and the command palette. No button is added to the chat, the composer or the phone's screen, and there is no new setting.
  • The list: newest first, one row per message: who wrote it (the user's icon or the agent's mark), a two-line snippet with the matched words in semibold, and the time. Arrow keys and Enter pick from the field; Escape, Back or a swipe down closes. No inline highlight in the chat and no next/previous bar, as the issue asks.
  • A pick: a message already loaded is scrolled to the top of the view. An older one is reached by reading the earlier pages through the chat's own "Earlier messages" path, 50 prompts a page (the largest page size), until its turn is in; then it goes to the top of the view. The sheet stays open with the row spinning meanwhile, and says so if the conversation no longer holds the message (a /new or a rewind replaced the transcript).
  • Hidden for an older bridge: the menu item and the palette action show only when the pane's PC lists the new conversation-search feature in its features frame, so a remote PC on an older bridge bundle offers none; a 404 from such a bridge reads as "This PC's bridge cannot search conversations yet". This PR does not raise REMOTE_BUNDLE_VERSION; remote PCs get it with the next bundle the release publishes.

Design reference

It follows the Codex app's Find in chat in what it reads (user messages and final answers, older pages included, searched on the server, as #180's notes describe) and differs in what it shows: a list of matches rather than a match count with next/previous and highlights in the chat. The DESIGN.md entry says the same.

How the search runs, and the measurement the issue asked for

The bridge walks the same parsed pages the chat reads (paneConversation, so every agent's transcript is searched exactly as its chat shows it), 50 prompts a page, newest first. I measured a whole-history walk on synthetic Claude sessions before deciding how much to search (5 tool calls, 1.2 KB of thinking and a 1.5 KB answer per prompt, tool output at the 4,000-character page cut), in-process on this desktop:

prompts file pages of 50 server time, whole walk slowest page
250 6.3 MB 5 0.17 s 22 ms
1,000 25 MB 20 0.80 s 54 ms
2,000 50 MB 40 2.3 s 110 ms

The same walk from the browser would move every page's full JSON (24 MB for the 1,000-prompt session), which is why the search runs on the PC and answers with snippets only. One answer reads at most 20 pages, or 1 s, or until it has 50 matches, then names a cursor; the sheet goes on from it by itself while it is open, so the whole conversation is searched, up to the newest 200 matches. A changed query or closing the sheet aborts the read in flight.

Contract (additive only)

  • New GET /api/pane/conversation/search?pane_id=&q=[&before=&history_id=] → ConversationSearchResponse { history_id?, matches[], cursor } (shared/conversation-search.ts, documented in shared/protocol.ts). q is required, at most 200 characters. A changed history answers 409 history_changed, a pending Copilot read 503 conversation_read_pending, a pane with no readable store no matches. A watching device may use it, as it may read the conversation.
  • New ServerFeature "conversation-search" in the features frame and snapshot.
  • No existing route, frame or field changes. The browser demo answers the new route (site/demo/transport.ts).

Screenshots (demo fixtures, 1280 and 390)

The pane's menu, before → after:

The palette's actions for >search, before → after:

The search (new), and the chat after picking the last match:

Docs: a Features row in docs/guide.md, a Conversation search entry in DESIGN.md, the two new browser scripts in docs/development.md, and changelog.d/chat-conversation-search.md.

Validation

  • Failing first: server/conversation-search.test.ts and shared/conversation-search.test.ts fail on origin/main (Cannot find module ../shared/conversation-search.ts, 0 pass / 2 fail) and pass here (11 tests). They cover: user messages and answers found on every page while a tool's output and thinking are not; the turn identity a match carries equals the one the chat gives its rows at another page size; the page, match and time budgets and the cursor that goes on; a page from another history refused; the route's 400 / 409 / 503 / no-store-no-match answers; whitespace and case, every regex character literal, Markdown marks dropped, snippet cuts at words and never inside a surrogate pair.
  • Contract, on the isolated herdr: bun run check run bun test --timeout 15000 ./server/api.contract.test.ts ./server/ping.contract.test.ts -t "conversation/search|agent-only feature": 2 pass. A real Claude pane's 120-prompt transcript is searched across its three pages through the cursor (the user's words on the first page, the answer on the last, the tool output that holds the words left out), another history's cursor answers 409, a blank query 400; and a remote PC's bridge lists conversation-search to the browser through the relay.
  • bun scripts/conversation-search-browser-qa.ts (real ChatView over controlled HTTP, 200 prompts, at 1280 and 390): passes. Matches listed newest first with the tool output left out; a pick four pages of 50 back is read (exactly four before reads at prompts=50) and held at the top of the view; Enter opens an already loaded match without a read; no match; a match the conversation no longer holds; Escape.
  • bun scripts/conversation-search-demo-regression.ts (the app on demo fixtures, at 1280 and 390): passes. The pane's menu and the palette open it, a pick opens the chat at its message, and a PC whose features leave out conversation-search offers it in neither place.
  • Both scripts are added to the browser lane (scripts/ci-browser.sh) and tsconfig.tools.json.
  • bun run check fast: passed (workflow syntax, generated types and themes, typecheck, build, 11544 unit tests pass, 0 fail).
  • Skipped: bun run check full locally (the browser lane runs both new scripts in CI); no real herdr session or remote PC was used.

🤖 Generated with Claude Code

…2018)

Search conversation, from the pane's menu and the command palette, finds
a word in the user's messages and the agent's answers, earlier pages
included, and lists the matches newest first as snippets; a pick opens
the chat at that message, reading the earlier pages up to it.

The bridge searches the same parsed pages the chat reads, a few pages per
answer with a cursor to go on, so only snippets cross the network. A new
additive route GET /api/pane/conversation/search and ServerFeature
"conversation-search"; a PC whose bridge does not list it offers no search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Deploying ovis with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9b3db91
Status: ✅  Deploy successful!
Preview URL: https://76727f25.ovis.pages.dev
Branch Preview URL: https://feat-conversation-search.ovis.pages.dev

View logs

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Merge of origin/main and review of the conflict resolution

origin/main (1dd4f3f) is merged into this branch as one merge commit. Four conflicts, each resolved by keeping both sides:

Codex (gpt-6.1-terra, xhigh, adversarial) reviewed the resolution. It found no HIGH issues and nothing in the App.tsx or feature-list resolutions. It raised two MEDIUM findings. Both concern how the search interacts with main's new Copilot recovery (#2724). Neither was reproduced, and both are left as follow-ups:

  • MEDIUM: the search does not send its history_id to paneConversation (server/conversation-search.ts). While a Copilot pane's ownership is briefly unprovable, the first search page reads as ConversationUnavailable, which the route answers as "no matches". A later page, read with before but without history_id, is refused as HistoryChanged, which answers 409 "conversation replaced". With history_id passed on each page read, paneConversation could tell this wait apart, and the route could answer a retryable 503 instead. This affects only Copilot panes during a recovery wait.
  • MEDIUM: a multi-page pick keeps the loadOlder closure it started with (ChatView.tsx revealTurn). If the chat becomes paused while a pick is paging back, the following pages still read. A paged Copilot read during the wait answers 409, and dropOlder() then drops the earlier pages already shown instead of waiting. Checking the current paused state through a ref, both inside the queued read and between pages, would close this. main's own Earlier-messages read has the same window for one page. A pick makes the window wider.

Validation at the merge head d1c1e84: bun run check fast passed (workflow syntax, generated types and themes, typecheck, build, unit 11671 pass / 0 fail). No behaviour change beyond the resolutions above.

devswha and others added 2 commits November 5, 2026 19:31
# Conflicts:
#	src/components/ChatView.tsx
#	src/components/PaneCanvas.tsx
A search whose conversation became unreadable after a page of it was
searched (in the same answer, or on a `before` request) answered no
matches and no cursor, so the dialog said it had searched the whole
conversation and kept only the matches before it. Such a read is now
HistoryChanged (409 history_changed), as for a replaced transcript; a
pane with no conversation at all still answers no matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Merge of origin/main, a review of the feature at the new head, and one fix round

origin/main (58432f8) is merged into this branch as one merge commit, 74bdd45. The only textual conflicts were imports, resolved by keeping both sides: src/components/ChatView.tsx (main's sessionStats beside this PR's HISTORY_PROMPTS) and src/components/PaneCanvas.tsx (main's Minimize2 beside this PR's TextSearch).

CodeRabbit did not review this PR (review limit). Codex (gpt-6.1-terra, xhigh, adversarial) reviewed the whole feature at the merge head, covering the merge's auto-merged hunks and the search itself. It found no HIGH issues, three MEDIUM and one LOW. Here is how each was resolved:

  • MEDIUM, fixed in 8490724: a search cut short said it was complete (server/pane-routes.ts, server/conversation-search.ts). When the conversation became unreadable (ConversationUnavailable) after a page of it had been searched, in the same answer or on a before request, the route answered 200 with no matches and no cursor. The dialog then said "Searched the whole conversation." searchConversationPages now turns that read into HistoryChanged, which answers 409 history_changed, as for a replaced transcript, and the dialog offers Try again. A pane with no conversation at all still answers no matches. Two new tests in server/conversation-search.test.ts (one in the same answer, one on a before request through the route) fail without the fix and pass with it.
  • MEDIUM, reproduced, not fixed here: a pick misses a turn that changed after it was searched. An answer that streamed on with its first part under 80 characters, or a tool-first answer whose output arrived, gets another conversationTurnAnchor, so the pick reads every earlier page and says the message is gone. A fallback that matched a name that begins the other was tried in this round and reverted: a second Codex pass reproduced it opening the wrong answer, because Codex transcripts give two answers the same time when the user steers mid-task. That would be worse than "missing". Follow-up: Chat search: a pick misses a turn that changed after it was searched #2958.
  • MEDIUM, not reproduced: a dismissed pick can close a newly opened search (src/components/ConversationSearch.tsx:119). Closing the dialog while a pick is paging back, then reopening it before the pick ends, leaves the old pick's .then able to call onClose and close the new dialog. Bumping pickSerial on unmount would close the gap. It was traced, not reproduced, and needs a DOM-level test, so it is left as a follow-up candidate.
  • LOW, not reproduced: a cancelled send that the chat hides can be listed. A send cancelled back into the input box can stay in the transcript while shownTurns leaves it out. The search lists it, and its pick says it is missing.

The two MEDIUM findings from the earlier merge review (history_id not passed during a Copilot recovery wait, and a multi-page pick keeping its first loadOlder) are unchanged.

Earlier unresolved review threads: none.

Validation at 8490724: bun run check fast passed (workflow syntax, generated types and themes, typecheck, build, unit tests 11927 pass / 0 fail). Not run: the browser regressions (scripts/conversation-search-demo-regression.ts runs in CI).

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Another merge of origin/main (9b3db91)

origin/main (2da018f) is merged into this branch as one merge commit. The one conflict was the pane menu's item list (paneItemsFor in src/components/PaneCanvas.tsx). It keeps this PR's Search conversation item and main's rule that a locked pane offers no Close pane (paneLocked). No other change.

Validation at 9b3db91: bun run check fast passed (typecheck, build, unit tests 12052 pass / 0 fail).

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Local merge gate (owner-approved): origin/main merged, bun run check fast passed at this head, threads resolved, merges cleanly. Reviewed on old-desktop (omo + Codex, no reproduced HIGH); findings in the PR comments, follow-up #2958. CodeRabbit was rate-limited, so the omo + Codex reviews stand in. Merged without waiting for GitHub CI.

@devswha
devswha merged commit 2d41b30 into main Nov 5, 2026
17 of 19 checks passed
@devswha
devswha deleted the feat/conversation-search branch November 5, 2026 12:43
devswha added a commit that referenced this pull request Nov 5, 2026
…arched (#2988)

Fixes #2958

## What changes
A search pick looked for the row named exactly as its turn was when
searched (`conversationTurnAnchor`). An answer that went on, or whose
first tool's output arrived after the search, has another name by then,
so the pick read every earlier page and said "This message is no longer
in the conversation".

Now the pick reads earlier pages until a turn with that name is in, or
back to the beginning, as before, and then settles on the rows shown at
that moment (a refresh may have changed them meanwhile): it opens the
first turn with the match's name, as main does, unless a turn of its
time before it changed into the match; otherwise the **one** turn that
changed since (turns without a time go by name only) (`pickedTurn` and
`searchedTurnChanged` in `shared/conversation-search.ts`). A changed
turn has the match's role and time, goes on from how the searched turn
began (its first text grew, or its first tool is the same call up to its
output), and still holds the match after the same words (a Markdown mark
that streamed in and closed, such as a link, is ignored). When two turns
qualify (Codex dates answers the user steered between by one time; an
answer that grew while a later one took its old name), none opens and
the line says the message is missing: never the wrong answer, which is
why #2891's prefix fallback was reverted.

Design reference: the Codex and Claude apps jump to a search hit by the
message's own id; our transcripts have none for every agent, so this
keeps the content name and adds a strict, unambiguous fallback instead
of a per-agent id. The pick reads no more pages than before.

## Review round
Codex (Terra, xhigh) on the first head: a refresh during the page reads
could make the counted candidate differ from the row opened (HIGH), an
exact name could name a later answer after the searched one grew (HIGH,
pre-existing), and a closed Markdown link changed the words before the
match (MEDIUM). All three are fixed here: the decision moved to the rows
shown at the end, an exact name no longer wins over a second qualifying
turn, and the comparison ignores link and emphasis marks. omo: CLEAR on
the first head.

Second round (omo + Codex on dfc5be6): two answers of one time that
both begin with the match counted each other, so neither opened (fixed:
a changed turn must go on from the searched turn's opening; test added);
a pick whose named row was renamed before it was drawn could stay
loading (fixed: the pick settles on the first draw after its pages are
in, since `loadOlder` lays a page before it answers). Codex also
reproduced a pre-existing case (main does the same): when the grown
answer sits on an older page not loaded yet and a later answer of its
time took its old name, the named one opens. Tracked in #2993.

Third round (omo on 0a24b61): turns sharing a name (an untimed prompt
sent twice) both counted and neither opened, where main opened the first
(fixed: the first named turn opens unless a turn changed since also
qualifies; test added). Also fixed: a changed turn's words after the
match must go on as they did (a stale view with two same-opening tool
answers no longer opens the other), and a pick with no list to show it
in fails instead of waiting. Left as LOW: an answer that itself begins
with "…" is read as a cut snippet and, once grown, is reported missing.
Fourth round (omo on 70523f8): an unchanged answer followed by a longer
answer of its time that begins with it was reported missing (fixed: only
a changed turn before the named one blocks; test added); an image link's
`!` is ignored like a link's. Fifth round (omo on b747b5f): answers
after the named turn (a task steered twice) and untimed turns that begin
with the match blocked an unchanged message main opens (fixed: the named
turn decides; only a changed turn of its time before it blocks; a
missing time tells no turn apart); a link mark after the match is
ignored too. Codex's last MEDIUM (a Codex image prompt searched between
its inline and event records reads as missing, as on main) is noted on
#2993.

## Validation
- `bun test shared/conversation-search.test.ts
server/conversation-search.test.ts`: 22 pass (with the steered-twice and
untimed cases). New tests use the Codex transcript parser (tool output
arriving after the search; two steered answers of one time), a grown
answer, a closed link, and `pickedTurn`'s none/one/several cases.
- `bun scripts/conversation-search-demo-regression.ts` (1280 and 390):
pass. New case: the demo serves every match under its old (changed)
name, and the pick still opens the answer. Without the fix this case
times out with the "no longer in the conversation" line.
- `bun run check fast` on the final head with origin/main merged
(6193eec, unit 12150 pass, 0 fail): pass (typecheck, build, unit tests,
generated files ok).
- No visual change, so no screenshots. No changelog fragment: the search
itself is unreleased (`changelog.d/chat-conversation-search.md`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat: a small conversation search over messages and answers

1 participant