feat(web,hub): add opt-in search for session message content (#1598)

Adds an opt-in content search over session messages, alongside the
existing metadata search. An FTS5 index covers visible user and assistant
prose; the sidebar gains a scope toggle, and selecting a result opens the
matching message rather than the session tail.

Search scope is a two-state toggle rather than a dropdown: a 48x48 target
matching the clear endcap, distinct glyphs for session details vs message
text, tooltips naming the real scopes, and the active mode kept on the
collapsed chip (tiann/hapi#2081).

Dogfooded on a live 1178-session fleet. The scope toggle and reading
forward from a search result were both click-tested on that install —
the latter walking six bounded pages through a 1,507-message session and
stopping correctly at the session head.

The per-commit history does not survive this squash; the review trail —
including which commit closed which finding — is on the PR threads.

Fixes #1554
Fixes #2081
This commit is contained in:
Ananovo
2026-11-02 15:20:45 +00:00
committed by GitHub
parent 1f4518df20
commit 82c5c00a38
87 changed files with 9501 additions and 412 deletions
@@ -2465,7 +2465,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
]);
});
it('uses native IDs for text snapshots and does not forward unclassified v1 ACP text', async () => {
it('uses native IDs for text snapshots, preserves live forwarding, and does not forward unclassified v1 ACP text', async () => {
const addMessage = vi.spyOn(MessageBuffer.prototype, 'addMessage');
const { session, agentMessages } = createSessionStub([{ message: 'first', mode: createMode() }]);
const launcherPromise = opencodeRemoteLauncher(session as never);
@@ -2475,7 +2475,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
event('message.updated', { info: { id: 'answer', role: 'assistant', sessionID: 'acp-session-1' } });
event('message.part.updated', { part: { id: 'stream-1', messageID: 'answer', sessionID: 'acp-session-1', type: 'text', text: 'partial' } });
event('message.part.updated', { part: { id: 'stream-1', messageID: 'answer', sessionID: 'acp-session-1', type: 'text', text: 'partial settled', time: { end: 1 } } });
expect(agentMessages).toContainEqual({ type: 'message', message: 'partial', id: 'stream-1', streamSnapshot: true });
expect(agentMessages).toContainEqual({ type: 'message', message: 'partial', id: 'stream-1', streamSnapshot: true, live: true });
expect(agentMessages).toContainEqual({ type: 'message', message: 'partial settled', id: 'stream-1', streamSnapshot: true });
expect(agentMessages.some((message: unknown) => JSON.stringify(message).includes('unclassified ACP summary'))).toBe(false);
expect(addMessage).not.toHaveBeenCalledWith('partial', 'assistant');