Skip to content

feat(search): search what a side chat added, and open its hit in the parent's panel - #1689

Merged
agegr merged 8 commits into
agegr:mainfrom
fallleave001:feat/side-chat-search
Nov 14, 2026
Merged

agegr merged 8 commits into
agegr:mainfrom
fallleave001:feat/side-chat-search

Conversation

@fallleave001

@fallleave001 fallleave001 commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1638.

A side chat copies its parent's whole history, so searching it as-is would return the parent's words twice. Only the entries after its boundary message (pi-web:side-boundary) are its own, and this searches exactly those: the inherited history stays the parent row's job, and a side chat without a boundary contributes nothing rather than a duplicate of its parent. The route's relation?.kind !== "side" filter is gone, and the title fallback uses the side chat's own name — its firstMessage is the parent's question.

Picking a hit opens the parent and then the side chat's tab, as you described: a side chat is never selected as the main chat. When another chat holds the panel, the tab is queued in the parent's saved panel state, so the existing side-tab restore applies it as the parent arrives; when the parent is already open, the tab opens directly. The row shows the side chat's own title with "Side chat of {parent}" under it, and an untitled side chat is numbered among its parent's side chats like the Agents switcher numbers it.

The queued tab survives a search that read a catalogue newer than the page's: a side chat missing from sessionCatalog is listed before the parent is selected, because a restore keeps only the sessions the page can see, and the next save would have written the shortened state back over the queue. sideChatHit() counts the hit among its parent's side chats even when the caller's list does not list it yet, so the row in the results and the tab its pick opens carry the same number. Re-picking a side chat replaces its saved tab rather than adding a second one, so a renamed side chat keeps its current label instead of the first one saved.

Three invariants the review tightened:

  • A side chat with no boundary also gives no title hit: its name alone is not searchable content.
  • A side chat's placeholder name ("Side chat") is never searched — it is app-generated text, and matching it would change what a query means.
  • A side chat whose parent is no longer listed is skipped: the sidebar folds it into a parent row that no longer exists, so a hit on it could not be opened.

Lines before the boundary are dropped before the length check and the parse, so the inherited prefix costs reads only: a line that never mentions the boundary type is neither parsed nor able to claim a truncation.

Tests:

  • lib/session-search.test.mjs: inherited text matches the parent only; a term split across the boundary matches neither; the boundary boilerplate never matches; a side chat without a boundary contributes nothing, not even its name; an unnamed side chat's placeholder name is not searched; a side chat whose parent is gone is not returned; the title fallback uses its own name. Each of the last four fails when its guard is removed.
  • lib/session-search-hit.test.mjs: the parent, title and numbered label of a side hit; null for an ordinary session and a subagent; a parent missing from the list still yields the parent id.
  • components/AppShell.search-side-hit.test.mjs: picks the parent and queues the tab; opens the tab when the parent already holds the panel; refreshes the list rather than selecting the side chat when the list lags; a side chat the page's list has not seen yet is listed before its tab is queued; an ordinary session is not handled here.
  • components/side-tab-state.test.mjs: picking a side chat again replaces its saved tab instead of adding a second one.

Cost, measured on the 428-session library (13 side chats, 38 MB of inherited text, median of 5 runs, searchSessionContents over the whole catalogue):

query without side chats with
t490 1288 ms 1342 ms
session search 2087 ms 2164 ms

Both stay well inside the 3 s budget and return the same 30 rows. Verified on the real library: picking a side chat's hit moves the URL to the parent's ?session= and opens the side chat's tab. Limits, ordering and the 200-character cap are unchanged.

sunqing78 added 3 commits November 14, 2026 08:41
The search route excluded side chats because a side chat copies its parent's whole text. That also hid everything the side chat said on its own: on two machines, 11 and 13 side chats hold 0.04M and 0.02M characters of their own text that no query can reach.

Include side chats but skip everything up to the side boundary message, so inherited history stays searchable only in the parent. The boundary entry itself is boilerplate and never a hit. The title fallback uses the side chat's own name: its firstMessage is the inherited first question.

Three contract tests: inherited text matches the parent only, split terms match neither row, and a boundary-less side chat contributes nothing.
A search hit for a side chat must not select the side chat as the main
chat: it lives in its parent's right panel (ADR 0007). Picking the hit
now opens the parent and then that side chat's tab, the way the Agents
switcher does, and queues the tab in the parent's saved panel state when
another chat holds the panel, so the side-tab restore applies it as the
parent arrives.

The row names the parent under the side chat's own title; an untitled
side chat is numbered among its parent's side chats. When the search read
a catalogue newer than the page's list, the pick lists again instead of
falling back to selecting the side chat.

sideChatHit() (lib/session-search-hit.ts) holds the rule, tested with the
AppShell handler that applies it.
Review fixes on the side-chat search change:

- A side chat with no boundary contributed nothing through its body but
  still hit on its own name: the title fallback now needs a boundary too,
  and a side chat's placeholder name ("Side chat") is never searched, as
  it is app-generated text rather than something the user wrote.
- A side chat whose parent is no longer listed has no family row in the
  sidebar either, so a hit on it could not be opened: it is skipped.
- Lines before the boundary are dropped before the length check and the
  parse, so the inherited prefix costs I/O only and cannot set
  `truncated`.
- The hit never falls back to selecting the side chat: `onSelectSideChat`
  is required, `sessions` is required, and the click branches instead of
  choosing between two callbacks. `sideChatHit()` now also supplies the
  parent title, so the row looks it up once.

Scan cost on the 428-session library (13 side chats, median of 5):
`t490` 1288 ms -> 1342 ms, `session search` 2087 ms -> 2164 ms; both stay
out of the 3 s budget and return the same 30 rows.
The search reads the server's catalogue, which can be newer than the
page's sessionCatalog: a side chat it finds may not be listed yet.
Queuing that tab and then selecting the parent dropped it instead,
because a restore keeps only the sessions the page can see, and the
next save wrote the shortened state back over the queue.

List the hit before the parent is selected. sideChatHit now counts the
hit among its parent's side chats even when the caller's list does not
have it yet, so the row in the results and the tab its pick opens carry
the same number. A saved tab of the same side chat is replaced rather
than appended: a restore keeps the first entry it reads, so a renamed
side chat kept its old label forever.

Also drop a stray BOM that had touched lib/session-search.ts's first
import line, and say in the comment and docs what the boundary prefilter
actually promises: inherited lines are read, not parsed, unless they
mention the boundary type.

The three new tests fail with their guard removed.
agegr and others added 4 commits November 14, 2026 21:02
A search pick and a `/side` fork now share openSideChatTab(): the tab opens
in the panel while the main chat shows that family, else it is saved with the
parent, and only the pick then selects the parent.

- A re-picked side chat keeps its tab's place. appendSideTab() goes back to
  what it was: the restore already keeps one tab per side chat, and a tab's
  label is read from the list, so replacing the saved entry only moved the
  tab to the end each time its hit was picked from another session.
- A side chat an Agents row already put in the main chat is a hit like any
  other. Its pick opened and at once closed a tab of the session on screen,
  which brought a hidden right panel back on a file tab.
- A list older than the search needs one line, not two branches: the hit is
  listed before its tab is saved, and the running poll brings the rest up to
  date, so the two extra list reloads are gone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
side-sessions.md and ADR 0007 still said search skips side chats. The file
map gets lib/session-search.ts and the new lib/session-search-hit.ts, and
the sessions note names them among its files.

A test pins the unparsed-prefix shortcut: an inherited line that only
mentions the boundary type, in a message or in another extension's message,
is not taken for the boundary. The comment on the literals says why they
are literals: the module's test loads it with Node's type stripping.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

@agegr agegr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved with changes I pushed to this branch (76a662a6, 0bdbcf4f, plus merges of main, no conflicts). Thanks, this does what #1638 asked for, the pick included.

Search. Checked against the live API with sessions built by createSideSession(): a side chat matches on its own text only, inherited text matches the parent only, a query with one inherited and one own term returns nothing, and the boundary text and the placeholder name never match. A user message that merely contains pi-web:side-boundary in the inherited part is not taken for the boundary (I added a test for that). Limits, ordering and the 200-character cap are untouched.

Picking a hit, in headless Chrome:

case result
another session open URL moves to the parent's ?session=, the side chat's tab opens selected
parent already open URL unchanged, the tab opens
tab already open focused, not duplicated
390px phone parent opens, drawer closes, the panel shows the side tab, as the switcher's tab button does
same hit twice, then reload one tab, restored after the reload
row own title over "Side chat of {parent}"; untitled ones are "Side chat 2" / "Side chat 3", as in the switcher

What I changed:

  1. A re-picked hit moved its tab to the end. With tabs Cache sizing, Side chat 3, Side chat 2, picking the Cache sizing hit from another session gave Side chat 3, Side chat 2, Cache sizing, again at every pick. The "replace on re-pick" filter in appendSideTab() caused it and was not needed: the restore already keeps one tab per side chat, and a tab's label is read from the list, not from the saved entry. appendSideTab() is back to main's.
  2. Picking the hit of the side chat already shown in the main chat brought a hidden right panel back (on a file tab): the handler opened a tab that the one-session-one-chat effect closed at once. Such a hit is now picked like any other.
  3. One helper for both callers. The queued-tab block was a copy of /side's late-landing path; handleSelectSideChat and handleSideSession now share openSideChatTab().
  4. Less list-lag handling. I kept the one line that matters (the hit is listed before its tab is saved, since a restore keeps only listed sessions) and dropped the listed branch and both extra list reloads: the running poll closes that window within about 2.5 s, and missing it only means clicking again. A forced-lag run still opens the tab.
  5. Docs: docs/agents/side-sessions.md and ADR 0007 still said search skips side chats; AGENTS.md gets file-map lines for lib/session-search.ts and lib/session-search-hit.ts.

On a merge with current main: tsc, lint and the full npm test are clean (3542 tests); node e2e/run.mjs passed (51 checks) before the last merge of main and was not re-run after it.

Two things left as they are, both the same as the switcher's tab button: the tab opens at its end, not scrolled to the matched message (only the main chat takes a search target), and when the main chat shows a sibling side chat the tab opens beside it without moving the main chat to the parent.

@agegr
agegr merged commit 4cc85eb into agegr:main Nov 14, 2026
2 checks passed
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.

Session search skips side chats entirely; index only what follows the side boundary

2 participants