From 82c5c00a383854e6227545c79e5584802d795b73 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Mon, 2 Nov 2026 23:20:45 +0800 Subject: [PATCH] feat(web,hub): add opt-in search for session message content (#1598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../companion/feature/chat/ChatViewModel.kt | 1 - .../app/hapi/protocol/wire/ApiResponses.kt | 1 - bun.lock | 4 + cli/src/agent/messageConverter.test.ts | 1 + cli/src/agent/messageConverter.ts | 3 +- .../opencode/opencodeRemoteLauncher.test.ts | 4 +- cli/src/pi/loopPublishOrder.test.ts | 11 +- cli/src/pi/piMessageAccumulator.test.ts | 25 +- cli/src/pi/piMessageAccumulator.ts | 27 +- hub/scripts/cleanup-sessions.test.ts | 59 ++ hub/scripts/cleanup-sessions.ts | 26 +- hub/src/store/index.ts | 192 ++-- hub/src/store/messageContentSearch.test.ts | 683 ++++++++++++ hub/src/store/messageContentSearch.ts | 989 ++++++++++++++++++ hub/src/store/messageStore.ts | 38 + hub/src/store/messages.test.ts | 59 ++ hub/src/store/messages.ts | 325 ++++-- hub/src/store/migration-v12.test.ts | 13 +- hub/src/store/migration-v13.test.ts | 6 +- hub/src/store/migration-v15.test.ts | 6 +- hub/src/store/migration-v18.test.ts | 2 +- hub/src/store/migration-v19.test.ts | 2 +- hub/src/store/migration-v20.test.ts | 2 +- hub/src/store/migration-v23.test.ts | 231 +++- hub/src/store/migration-v24.test.ts | 6 +- hub/src/store/migration-v25.test.ts | 2 +- hub/src/store/migration-v26.test.ts | 2 +- hub/src/store/migration-v28.test.ts | 2 +- hub/src/store/migration-v30.test.ts | 67 +- hub/src/store/sessions.ts | 38 +- hub/src/sync/messageService.test.ts | 64 ++ hub/src/sync/messageService.ts | 56 +- hub/src/sync/syncEngine.ts | 36 +- hub/src/web/routes/messages.test.ts | 43 + hub/src/web/routes/messages.ts | 14 + hub/src/web/routes/sessions.test.ts | 232 ++++ hub/src/web/routes/sessions.ts | 180 ++++ package.json | 1 + shared/package.json | 4 + shared/src/apiTypes.ts | 36 + shared/src/messages.test.ts | 405 +++++++ shared/src/messages.ts | 463 ++++++++ web/e2e/session-content-search-jump.spec.ts | 559 ++++++++++ web/playwright.content-search.live.config.ts | 25 + web/src/api/client.test.ts | 94 ++ web/src/api/client.ts | 122 ++- web/src/chat/normalizeAgent.ts | 19 +- web/src/chat/outline.test.ts | 102 +- web/src/chat/outline.ts | 104 ++ web/src/chat/reconcile.ts | 2 + web/src/chat/reducer.test.ts | 25 +- web/src/chat/reducerTimeline.test.ts | 3 +- web/src/chat/reducerTimeline.ts | 25 +- web/src/chat/types.ts | 4 + .../HappyThread.forward-scroll.test.ts | 63 ++ .../HappyThread.mobile-scroll.test.tsx | 570 +++++++++- .../AssistantChat/HappyThread.test.tsx | 19 + .../components/AssistantChat/HappyThread.tsx | 835 ++++++++++++++- .../messages/AssistantMessage.tsx | 5 + .../messages/NotifySummaryText.test.tsx | 28 +- .../messages/NotifySummaryText.tsx | 47 +- .../AssistantChat/messages/SystemMessage.tsx | 10 +- .../AssistantChat/messages/UserMessage.tsx | 6 + web/src/components/SessionChat.tsx | 18 + .../SessionList.directory-action.test.tsx | 436 +++++++- web/src/components/SessionList.test.ts | 10 + web/src/components/SessionList.tsx | 317 +++++- web/src/components/SessionListSearch.test.tsx | 95 ++ web/src/components/SessionRowSummary.test.tsx | 15 + web/src/components/SessionRowSummary.tsx | 27 +- web/src/hooks/queries/useMessages.test.tsx | 47 + web/src/hooks/queries/useMessages.ts | 98 +- web/src/index.css | 26 + web/src/lib/assistant-runtime.test.ts | 16 + web/src/lib/assistant-runtime.ts | 152 ++- web/src/lib/locales/en.ts | 19 + web/src/lib/locales/zh-CN.ts | 19 + web/src/lib/message-window-store.test.ts | 877 +++++++++++++++- web/src/lib/message-window-store.ts | 451 +++++++- web/src/lib/messageSearchTarget.test.ts | 17 + web/src/lib/messageSearchTarget.ts | 19 + web/src/lib/query-keys.ts | 1 + web/src/lib/useConsumedMessageTarget.test.tsx | 42 + web/src/lib/useConsumedMessageTarget.ts | 59 ++ web/src/router.hooks.test.ts | 33 + web/src/router.tsx | 87 +- web/src/types/api.ts | 4 + 87 files changed, 9501 insertions(+), 412 deletions(-) create mode 100644 hub/scripts/cleanup-sessions.test.ts create mode 100644 hub/src/store/messageContentSearch.test.ts create mode 100644 hub/src/store/messageContentSearch.ts create mode 100644 web/e2e/session-content-search-jump.spec.ts create mode 100644 web/playwright.content-search.live.config.ts create mode 100644 web/src/components/AssistantChat/HappyThread.forward-scroll.test.ts create mode 100644 web/src/components/SessionListSearch.test.tsx create mode 100644 web/src/hooks/queries/useMessages.test.tsx create mode 100644 web/src/lib/messageSearchTarget.test.ts create mode 100644 web/src/lib/messageSearchTarget.ts create mode 100644 web/src/lib/useConsumedMessageTarget.test.tsx create mode 100644 web/src/lib/useConsumedMessageTarget.ts create mode 100644 web/src/router.hooks.test.ts diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt index 05c745de..8a0df50b 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -138,7 +138,6 @@ enum class PermissionRowOverride { /** The hub said the request is no longer pending (404/409) — benign. */ AlreadyHandled, } - /** What [ChatScreen] renders. */ data class ChatUiState( val sessionId: String, diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt index 2f807dfe..6fda4243 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt @@ -26,7 +26,6 @@ data class MessagesPage( val hasMore: Boolean, val readThroughRevision: Long? = null, ) - /** `GET /api/sessions/:id/messages` — messages in ascending display order. */ @Serializable data class MessagesResponse( diff --git a/bun.lock b/bun.lock index 7ce9db26..5aac0153 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,10 @@ "name": "@hapi/protocol", "version": "0.1.0", "dependencies": { + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-gfm": "^3.1.0", + "mdast-util-to-string": "^4.0.0", + "micromark-extension-gfm": "^3.0.0", "proper-lockfile": "^4.1.2", "zod": "^4.2.1", }, diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index ed77f81a..c07a3e2a 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -15,6 +15,7 @@ describe('convertAgentMessage', () => { type: 'message', message: 'partial response', id: 'text-stream-1', + live: true, streamSnapshot: true }); }); diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 4b53f155..549dc62f 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -4,7 +4,7 @@ import type { AgentMessage, PlanItem } from './types'; import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; export type CodexMessage = - | { type: 'message'; message: string; id?: string; streamSnapshot?: boolean } + | { type: 'message'; message: string; id?: string; live?: boolean; streamSnapshot?: boolean } | { type: 'reasoning'; message: string; id: string; live?: boolean } | { type: 'token_count'; @@ -58,6 +58,7 @@ export function convertAgentMessage(message: AgentMessage, model?: string | null type: 'message', message: message.text, ...(message.id !== undefined ? { id: message.id } : {}), + ...(message.live === true ? { live: true } : {}), ...(message.streamSnapshot === true ? { streamSnapshot: true } : {}) }; case 'reasoning': diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index e1d670ea..c5868ab3 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -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'); diff --git a/cli/src/pi/loopPublishOrder.test.ts b/cli/src/pi/loopPublishOrder.test.ts index 67a9b38b..6b040815 100644 --- a/cli/src/pi/loopPublishOrder.test.ts +++ b/cli/src/pi/loopPublishOrder.test.ts @@ -131,8 +131,17 @@ describe('Pi publish ordering (real converters)', () => { text('after'); h.emit({ type: 'message_end' }); await h.flush(); + // Each text segment publishes its live snapshot and then a terminal + // one. The terminal copy is deliberate: the hub skips live snapshots + // when building the content-search index, so without it a reply whose + // final text already arrived live would never be indexed (tiann/hapi#1554). + // Downstream both collapse into one block — the hub dedupes by render + // key and the web reducer merges blocks sharing a stream id. + // + // What this test guards is the ordering: assistant text on both sides + // of the inline image result, never reordered across it. expect(h.messages.map((message) => message.type === 'message' ? message.message : message.type)) - .toEqual(['before', 'tool-call-result', 'generated-image', 'after']); + .toEqual(['before', 'before', 'tool-call-result', 'generated-image', 'after', 'after']); }); it('retains queued assistant deltas when the prompt is rejected in the same chunk', async () => { diff --git a/cli/src/pi/piMessageAccumulator.test.ts b/cli/src/pi/piMessageAccumulator.test.ts index 71cecc47..e93213b4 100644 --- a/cli/src/pi/piMessageAccumulator.test.ts +++ b/cli/src/pi/piMessageAccumulator.test.ts @@ -60,6 +60,22 @@ describe('PiMessageAccumulator', () => { expect(accumulator.flush()).toEqual([]); }); + it('emits a terminal text snapshot when the live snapshot already has the final text', () => { + const accumulator = new PiMessageAccumulator({ now: () => 0, streamNonceFactory: () => 'nonce' }); + accumulator.handleEvent(event('message_start')); + expect(accumulator.handleEvent(event('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'complete already' }, + }))).toEqual([{ + type: 'text', text: 'complete already', id: 'pi-nonce-turn-0-message-1-text-0', + streamSnapshot: true, live: true, + }]); + + expect(accumulator.handleEvent(event('message_end'))).toEqual([{ + type: 'text', text: 'complete already', id: 'pi-nonce-turn-0-message-1-text-0', + streamSnapshot: true, + }]); + }); + it('keeps multiple content indexes separate instead of concatenating blocks', () => { let now = 0; const accumulator = new PiMessageAccumulator({ now: () => now, streamNonceFactory: () => 'nonce' }); @@ -90,7 +106,7 @@ describe('PiMessageAccumulator', () => { }))).toEqual([]); }); - it('appends the error after flushed partial text', () => { + it('appends the error after a terminal snapshot of the last partial text', () => { let now = 0; const accumulator = new PiMessageAccumulator({ now: () => now, streamNonceFactory: () => 'nonce' }); accumulator.handleEvent(event('turn_start')); @@ -102,7 +118,12 @@ describe('PiMessageAccumulator', () => { }]); expect(accumulator.handleEvent(event('message_end', { message: { stopReason: 'error', errorMessage: 'stream dropped' }, - }))).toEqual([{ type: 'error', message: 'stream dropped' }]); + }))).toEqual([ + { + type: 'text', text: 'partial answer', id: 'pi-nonce-turn-1-message-1-text-0', streamSnapshot: true, + }, + { type: 'error', message: 'stream dropped' }, + ]); }); it('falls back to turn_end when Pi skips message_end on stream failure', () => { diff --git a/cli/src/pi/piMessageAccumulator.ts b/cli/src/pi/piMessageAccumulator.ts index 8b115350..cccad712 100644 --- a/cli/src/pi/piMessageAccumulator.ts +++ b/cli/src/pi/piMessageAccumulator.ts @@ -88,17 +88,17 @@ export class PiMessageAccumulator { if (event.type === 'message_end' || event.type === 'turn_end') { const wasActive = this.active; + const errorMessage = 'message' in event ? extractPiTurnError(event.message) : null; + // Error-finalized turns still need a non-live terminal text row so + // the last visible partial response remains searchable. const messages = this.flush(); // Pi reports the failure on both message_end and turn_end; emit the // error once (first boundary wins) so the web shows a failed turn // instead of silent partial text. turn_end doubles as the safety // net for Pi builds that skip message_end on stream failure. - if (wasActive && !this.errorEmitted && 'message' in event) { - const errorMessage = extractPiTurnError(event.message); - if (errorMessage) { - messages.push({ type: 'error', message: errorMessage }); - this.errorEmitted = true; - } + if (wasActive && !this.errorEmitted && errorMessage) { + messages.push({ type: 'error', message: errorMessage }); + this.errorEmitted = true; } return messages; } @@ -111,9 +111,9 @@ export class PiMessageAccumulator { } /** Explicit transport-close/error safety net. Idempotent after a flush. */ - flush(): AgentMessage[] { + flush(emitUnchangedTerminalText = true): AgentMessage[] { if (!this.active) return []; - const snapshots = this.createSnapshots(false); + const snapshots = this.createSnapshots(false, emitUnchangedTerminalText); this.resetMessage(); return snapshots; } @@ -142,10 +142,10 @@ export class PiMessageAccumulator { return this.createSnapshots(true); } - private createSnapshots(live: boolean): AgentMessage[] { + private createSnapshots(live: boolean, emitUnchangedTerminalText = true): AgentMessage[] { const messages: AgentMessage[] = []; - this.addSnapshots(messages, 'reasoning', this.reasoningSegments, live); - this.addSnapshots(messages, 'text', this.textSegments, live); + this.addSnapshots(messages, 'reasoning', this.reasoningSegments, live, emitUnchangedTerminalText); + this.addSnapshots(messages, 'text', this.textSegments, live, emitUnchangedTerminalText); return messages; } @@ -154,9 +154,12 @@ export class PiMessageAccumulator { kind: 'reasoning' | 'text', segments: Map, live: boolean, + emitUnchangedTerminalText: boolean, ): void { for (const [index, segment] of Array.from(segments.entries()).sort(([left], [right]) => left - right)) { - if (!segment.text.trim() || segment.text === segment.lastSnapshot) continue; + const unchanged = segment.text === segment.lastSnapshot; + const isUnchangedTerminalText = !live && kind === 'text' && emitUnchangedTerminalText; + if (!segment.text.trim() || (unchanged && !isUnchangedTerminalText)) continue; segment.lastSnapshot = segment.text; const id = `pi-${this.streamNonce}-turn-${this.turnSequence}-message-${this.messageSequence}-${kind}-${index}`; output.push({ diff --git a/hub/scripts/cleanup-sessions.test.ts b/hub/scripts/cleanup-sessions.test.ts new file mode 100644 index 00000000..0e0eea5e --- /dev/null +++ b/hub/scripts/cleanup-sessions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test' +import type { Database } from 'bun:sqlite' +import { Store } from '../src/store' +import { deleteSessions } from './cleanup-sessions' + +function makeSession(store: Store, tag: string) { + return store.sessions.getOrCreateSession(tag, { path: `/tmp/${tag}` }, null, 'default') +} + +function failSessionDelete(store: Store): void { + const db = (store as unknown as { db: Database }).db + db.exec(` + CREATE TRIGGER fail_session_delete + BEFORE DELETE ON sessions + BEGIN + SELECT RAISE(ABORT, 'forced session delete failure'); + END + `) +} + +describe('cleanup session bulk deletion', () => { + it('removes derived content rows with the deleted sessions', () => { + const store = new Store(':memory:') + const first = makeSession(store, 'cleanup-script-first') + const second = makeSession(store, 'cleanup-script-second') + for (const session of [first, second]) { + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'cleanup script phrase' } + }) + } + + const db = (store as unknown as { db: Database }).db + expect(deleteSessions(db, [first.id, second.id])).toBe(2) + expect(store.messages.searchContent('cleanup script phrase', 'default')).toEqual([]) + expect(store.sessions.getSession(first.id)).toBeNull() + expect(store.sessions.getSession(second.id)).toBeNull() + }) + + it('rolls back derived cleanup when canonical session deletion fails', () => { + const store = new Store(':memory:') + const first = makeSession(store, 'cleanup-script-rollback-first') + const second = makeSession(store, 'cleanup-script-rollback-second') + for (const session of [first, second]) { + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'cleanup rollback phrase' } + }) + } + failSessionDelete(store) + + const db = (store as unknown as { db: Database }).db + expect(() => deleteSessions(db, [first.id, second.id])) + .toThrow('forced session delete failure') + expect(store.messages.searchContent('cleanup rollback phrase', 'default')).toHaveLength(2) + expect(store.sessions.getSession(first.id)).not.toBeNull() + expect(store.sessions.getSession(second.id)).not.toBeNull() + }) +}) diff --git a/hub/scripts/cleanup-sessions.ts b/hub/scripts/cleanup-sessions.ts index fc2a7a11..78ec9988 100755 --- a/hub/scripts/cleanup-sessions.ts +++ b/hub/scripts/cleanup-sessions.ts @@ -34,6 +34,7 @@ import { join } from 'node:path' import { existsSync } from 'node:fs' import { decodeMessageContent } from '../src/store/contentCodec' +import { removeMessageContentSearchForSessions } from '../src/store/messageContentSearch' // Format timestamp as human-readable date function formatDate(timestamp: number): string { @@ -303,12 +304,19 @@ async function confirm(message: string): Promise { } // Delete sessions by IDs -function deleteSessions(db: Database, ids: string[]): number { +export function deleteSessions(db: Database, ids: string[]): number { if (ids.length === 0) return 0 - const placeholders = ids.map(() => '?').join(', ') - db.run(`DELETE FROM sessions WHERE id IN (${placeholders})`, ids) - return ids.length + return db.transaction(() => { + const placeholders = ids.map(() => '?').join(', ') + // This script intentionally deletes in bulk instead of going through + // the Store wrapper. Keep derived message-content rows and canonical + // session deletion in one transaction so a failed delete cannot leave + // searchable content out of sync. + removeMessageContentSearchForSessions(db, ids) + db.run(`DELETE FROM sessions WHERE id IN (${placeholders})`, ids) + return ids.length + })() } // Main function @@ -410,7 +418,9 @@ Examples: } } -main().catch(err => { - console.error('Error:', err.message) - process.exit(1) -}) +if (import.meta.main) { + main().catch(err => { + console.error('Error:', err.message) + process.exit(1) + }) +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 9a48f633..10f2d4b9 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -16,6 +16,12 @@ import { UserStore } from './userStore' import { TurnStore } from './turnStore' import { UsageStore } from './usageStore' import { WorkGraphStore } from './workGraphStore' +import { + backfillMessageContentSearchLookup, + backfillMessageContentSearchShortIndex, + createMessageContentSearchTable, + rebuildMessageContentSearch +} from './messageContentSearch' export type { NativeDevicePlatform, @@ -47,11 +53,14 @@ export { WorkGraphValidationError } from './workGraph' -const SCHEMA_VERSION: number = 30 +const SCHEMA_VERSION: number = 31 const REQUIRED_TABLES = [ 'sessions', 'machines', 'messages', + 'message_content_search', + 'message_content_search_lookup', + 'message_content_search_short', 'message_epochs', 'users', 'push_subscriptions', @@ -365,6 +374,7 @@ export class Store { 27: () => this.migrateFromV27ToV28(), 28: () => this.migrateFromV28ToV29(), 29: () => this.migrateFromV29ToV30(), + 30: () => this.migrateFromV30ToV31(), }) if (currentVersion === 0) { @@ -393,6 +403,18 @@ export class Store { } const stepMigrations = buildStepMigrations(false) + // A v26 database predates all three derived-index refinements. Create + // the final schema and rebuild once instead of running the v26→v27 + // rebuild, v28→v29 short-index backfill, and v29→v30 rebuild in turn. + // Keep the individual steps below for databases that resume from an + // intermediate schema version after an interrupted upgrade. + if (currentVersion === 26) { + this.migrateFromV26ToV30() + this.migrateFromV29ToV30() + this.migrateFromV30ToV31() + this.setUserVersion(SCHEMA_VERSION) + return + } if (currentVersion < SCHEMA_VERSION && stepMigrations[currentVersion]) { for (let v = currentVersion; v < SCHEMA_VERSION; v++) { const step = stepMigrations[v] @@ -647,6 +669,7 @@ export class Store { ON session_turns(session_id) WHERE ended_at IS NULL; `) + createMessageContentSearchTable(this.db) } private migrateLegacySchemaIfNeeded(): void { @@ -1044,75 +1067,6 @@ export class Store { `) } - /** - * v26→v27: make the session-list future-scheduled lookups indexed. - * GET /api/sessions runs countFutureScheduledBySessionIds and - * minFutureScheduledAtBySessionIds over every session id in the namespace. - * Neither the (session_id, seq) indexes nor idx_messages_scheduled_pending - * match the full predicate set, so SQLite falls back to probing - * idx_messages_local_id — one pass over every local message per call - * (O(all local messages), twice per session-list refresh) even though the - * matching set is almost always empty. This partial index covers every - * constant predicate and exposes (session_id, scheduled_at), so both - * queries become a near-empty index range scan. - */ - private migrateFromV26ToV27(): void { - this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_messages_future_scheduled_queued - ON messages(session_id, scheduled_at) - WHERE invoked_at IS NULL - AND local_id IS NOT NULL - AND scheduled_at IS NOT NULL - AND delivery_state = 'queued'; - `) - } - - /** v28→v29: persist a stable, user-input-only list ordering clock. */ - private migrateFromV28ToV29(): void { - const columns = this.getSessionColumnNames() - if (columns.size === 0 || columns.has('last_user_message_at')) return - this.db.transaction(() => { - this.db.exec('ALTER TABLE sessions ADD COLUMN last_user_message_at INTEGER') - if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get()) { - backfillLastUserMessageAt(this.db) - } - })() - } - - /** - * v27→v28: persist when agents were working. `thinking` used to live only - * in the session cache, so a hub restart forgot it and nothing could say - * afterwards how long an agent worked. Each false→true→false stretch is - * now a row; at most one per session is open (ended_at IS NULL). - * recover_until marks a turn a restart interrupted, which the agent can - * still continue until then. - */ - private migrateFromV27ToV28(): void { - // In the legacy branch sessions may not exist yet; createSchema adds - // the table once it does. - if (this.getSessionColumnNames().size === 0) return - this.db.exec(` - CREATE TABLE IF NOT EXISTS session_turns ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - namespace TEXT NOT NULL, - started_at INTEGER NOT NULL, - ended_at INTEGER, - end_reason TEXT, - started_by TEXT NOT NULL, - recover_until INTEGER, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_session_turns_namespace_started - ON session_turns(namespace, started_at); - CREATE INDEX IF NOT EXISTS idx_session_turns_session_started - ON session_turns(session_id, started_at); - CREATE UNIQUE INDEX IF NOT EXISTS idx_session_turns_one_open - ON session_turns(session_id) - WHERE ended_at IS NULL; - `) - } - /** * A2A Layer 1 / P1 (#1374) + P3 substrate: hub work-graph ledger tables. * Namespace + principal_json required on every events row. @@ -1171,6 +1125,92 @@ export class Store { `) } + /** + * v26→v27: keep the session-list future-scheduled lookup indexed and + * initialize the derived message-content search tables. + */ + private migrateFromV26ToV27(): void { + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_messages_future_scheduled_queued + ON messages(session_id, scheduled_at) + WHERE invoked_at IS NULL + AND local_id IS NOT NULL + AND scheduled_at IS NOT NULL + AND delivery_state = 'queued'; + `) + createMessageContentSearchTable(this.db) + if (this.getMessageColumnNames().size === 0) return + rebuildMessageContentSearch(this.db) + } + + private migrateFromV26ToV30(): void { + this.migrateFromV26ToV27() + this.migrateFromV27ToV28() + this.migrateFromV28ToV29() + } + + /** Complete the derived message-content index lookup added after v27. */ + private migrateFromV27ToV28(): void { + createMessageContentSearchTable(this.db) + backfillMessageContentSearchLookup(this.db) + if (this.getSessionColumnNames().size === 0) return + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + namespace TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER, + end_reason TEXT, + started_by TEXT NOT NULL, + recover_until INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_turns_namespace_started + ON session_turns(namespace, started_at); + CREATE INDEX IF NOT EXISTS idx_session_turns_session_started + ON session_turns(session_id, started_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_turns_one_open + ON session_turns(session_id) + WHERE ended_at IS NULL; + `) + } + + /** Add the indexed short-query n-gram table to the message-content index. */ + private migrateFromV28ToV29(): void { + createMessageContentSearchTable(this.db) + backfillMessageContentSearchShortIndex(this.db) + const columns = this.getSessionColumnNames() + if (columns.size === 0 || columns.has('last_user_message_at')) return + this.db.transaction(() => { + this.db.exec('ALTER TABLE sessions ADD COLUMN last_user_message_at INTEGER') + if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get()) { + backfillLastUserMessageAt(this.db) + } + })() + } + + /** v29→v30: add read-state revisions. */ + private migrateFromV29ToV30(): void { + const columns = this.getSessionColumnNames() + for (const name of ['content_revision', 'read_revision', 'read_state_version', 'manual_unread']) { + if (columns.size > 0 && !columns.has(name)) { + this.db.exec(`ALTER TABLE sessions ADD COLUMN ${name} INTEGER NOT NULL DEFAULT 0`) + } + } + + const messageColumns = this.getMessageColumnNames() + if (messageColumns.size > 0 && !messageColumns.has('content_revision')) { + this.db.exec('ALTER TABLE messages ADD COLUMN content_revision INTEGER NOT NULL DEFAULT 0') + } + } + + /** v30→v31: create and backfill the message-content search index. */ + private migrateFromV30ToV31(): void { + createMessageContentSearchTable(this.db) + if (this.getMessageColumnNames().size > 0) rebuildMessageContentSearch(this.db) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) @@ -1186,18 +1226,6 @@ export class Store { return new Set(rows.map((row) => row.name)) } - /** Historical rows start at zero: upgrading never marks the backlog unread. */ - private migrateFromV29ToV30(): void { - const columns = this.getSessionColumnNames() - for (const name of ['content_revision', 'read_revision', 'read_state_version', 'manual_unread']) { - if (columns.size > 0 && !columns.has(name)) this.db.exec(`ALTER TABLE sessions ADD COLUMN ${name} INTEGER NOT NULL DEFAULT 0`) - } - const messageColumns = this.getMessageColumnNames() - if (messageColumns.size > 0 && !messageColumns.has('content_revision')) { - this.db.exec('ALTER TABLE messages ADD COLUMN content_revision INTEGER NOT NULL DEFAULT 0') - } - } - private getUserVersion(): number { const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number } | undefined return row?.user_version ?? 0 diff --git a/hub/src/store/messageContentSearch.test.ts b/hub/src/store/messageContentSearch.test.ts new file mode 100644 index 00000000..e92c4942 --- /dev/null +++ b/hub/src/store/messageContentSearch.test.ts @@ -0,0 +1,683 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { Store } from './index' +import { + createMessageContentSearchTable, + MAX_INDEXED_MESSAGE_CHARACTERS, + MAX_SHORT_SEARCH_GRAMS_PER_MESSAGE, + removeMessageContentSearchForSessions +} from './messageContentSearch' + +function makeSession(store: Store, tag: string, namespace = 'default') { + return store.sessions.getOrCreateSession(tag, { path: `/tmp/${tag}` }, null, namespace) +} + +function failSessionDelete(store: Store): void { + const db = (store as unknown as { db: Database }).db + db.exec(` + CREATE TRIGGER fail_session_delete + BEFORE DELETE ON sessions + BEGIN + SELECT RAISE(ABORT, 'forced session delete failure'); + END + `) +} + +describe('message content search', () => { + it('adds truncation metadata to an existing lookup schema', () => { + const db = new Database(':memory:') + try { + db.exec(` + CREATE VIRTUAL TABLE message_content_search USING fts5( + searchable_text, + message_id UNINDEXED, + session_id UNINDEXED, + seq UNINDEXED, + created_at UNINDEXED, + role UNINDEXED, + tokenize = 'trigram' + ); + CREATE TABLE message_content_search_lookup ( + search_rowid INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + target_message_id TEXT NOT NULL + ); + `) + + createMessageContentSearchTable(db) + + const columns = db.prepare( + 'PRAGMA table_info(message_content_search_lookup)' + ).all() as Array<{ name: string }> + expect(columns.map((column) => column.name)).toEqual(expect.arrayContaining([ + 'is_truncated', + 'short_index_truncated' + ])) + } finally { + db.close() + } + }) + + it('indexes visible user and assistant prose, including compressed messages', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'content-search') + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'How do I rotate the cache key?' } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'Use the key rotation command.' } } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { type: 'codex', data: { type: 'tool-call', input: { text: 'cache key' } } } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', message: `long answer ${'cache rotation '.repeat(40)}` } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + isMeta: true, + message: { content: [{ type: 'text', text: 'hidden cache rotation metadata' }] } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + isSidechain: true, + message: { content: [{ type: 'text', text: 'hidden sidechain prose' }] } + } + } + }) + + expect(store.messages.searchContent('rotate the cache', 'default').map((result) => result.sessionId)) + .toEqual([session.id]) + expect(store.messages.searchContent('rotation command', 'default')[0]?.role).toBe('assistant') + expect(store.messages.searchContent('cache key', 'default')[0]?.role).toBe('user') + expect(store.messages.searchContent('cache key', 'default')[0]?.snippet).toContain('cache') + expect(store.messages.searchContent('hidden cache rotation', 'default')).toEqual([]) + expect(store.messages.searchContent('hidden sidechain prose', 'default')).toEqual([]) + }) + + it('indexes Claude assistant prose sent as a plain string content', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'content-search-string-content') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { role: 'assistant', content: 'Rotate the signing certificate yearly.' } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + isSidechain: true, + message: { role: 'assistant', content: 'hidden sidechain string prose' } + } + } + }) + + const hits = store.messages.searchContent('signing certificate', 'default') + expect(hits.map((result) => result.sessionId)).toEqual([session.id]) + expect(hits[0]?.role).toBe('assistant') + expect(store.messages.searchContent('hidden sidechain string', 'default')).toEqual([]) + }) + + it('indexes only visible AGY planner prose', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'agy-content-search') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'agy_message', + content: 'Visible planner answer\n[Message] timestamp=2026-07-08T06:04:31Z content=Hidden background task payload' + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'agy_message', + content: 'Inside the task-246 log...\n[Message] timestamp=2026-07-08T06:04:31Z content=Another hidden task payload' + } + } + }) + + expect(store.messages.searchContent('Visible planner', 'default')) + .toMatchObject([{ sessionId: session.id }]) + expect(store.messages.searchContent('Hidden background', 'default')).toEqual([]) + expect(store.messages.searchContent('Another hidden', 'default')).toEqual([]) + }) + + it('excludes Claude Task prompts that render only in the tool card', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'task-prompt-search') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Inspect the repository' }, + { type: 'tool_use', name: 'Task', input: { prompt: 'Inspect the repository' } }, + { type: 'text', text: 'The repository is ready.' }, + ] + } + } + } + }) + + expect(store.messages.searchContent('Inspect the repository', 'default')).toEqual([]) + expect(store.messages.searchContent('repository is ready', 'default')) + .toMatchObject([{ sessionId: session.id }]) + }) + + it('excludes injected Claude sentinel replies but keeps normal ones searchable', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'sentinel-search') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'injected-turn', + message: { content: '...' } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'injected-turn', + message: { content: [{ type: 'text', text: 'No response requested.' }] } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'normal-turn', + message: { content: [{ type: 'text', text: 'No response requested.' }] } + } + } + }) + + const matches = store.messages.searchContent('No response requested', 'default') + expect(matches).toHaveLength(1) + }) + + it('updates the injected-turn cache when a later system-injected turn arrives', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'sentinel-cache') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'normal-turn', + message: { content: [{ type: 'text', text: 'No response requested.' }] } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'later-injected-turn', + message: { content: '...' } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'later-injected-turn', + message: { content: [{ type: 'text', text: 'No response requested.' }] } + } + } + }) + + expect(store.messages.searchContent('No response requested', 'default')) + .toHaveLength(1) + }) + + it('does not return an incremental-only injected Claude sentinel hit', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'sentinel-incremental-only') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'incremental-injected-turn', + message: { content: '...' } + } + } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'incremental-injected-turn', + message: { content: [{ type: 'text', text: 'No response requested.' }] } + } + } + }) + + expect(store.messages.searchContentInSession( + 'No response requested', + 'default', + session.id + )).toEqual({ matches: [], total: 0, hasPotentiallyIncompleteResults: false }) + }) + + it('uses the indexed short-query path for CJK queries and isolates namespaces', () => { + const store = new Store(':memory:') + const defaultSession = makeSession(store, 'cjk-default') + const otherSession = makeSession(store, 'cjk-other', 'other') + store.messages.addMessage(defaultSession.id, { + role: 'user', + content: { type: 'text', text: '中文缓存搜索测试' } + }) + store.messages.addMessage(otherSession.id, { + role: 'user', + content: { type: 'text', text: '中文缓存搜索测试' } + }) + + expect(store.messages.searchContent('搜索', 'default').map((result) => result.sessionId)) + .toEqual([defaultSession.id]) + expect(store.messages.searchContent('搜索', 'other').map((result) => result.sessionId)) + .toEqual([otherSession.id]) + expect(store.messages.searchContent('搜', 'default')).toEqual([]) + expect(store.messages.searchContentInSession('搜', 'default', defaultSession.id)) + .toEqual({ matches: [], total: 0, hasPotentiallyIncompleteResults: false }) + }) + + it('bounds per-message short-index work for long high-entropy text', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'bounded-short-index') + const text = `${Array.from({ length: MAX_INDEXED_MESSAGE_CHARACTERS + 1024 }, (_, index) => + String.fromCodePoint(0x1000 + index) + ).join('')} tail-search-needle` + + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text } + }) + + const db = (store as unknown as { db: Database }).db + const row = db.prepare(` + SELECT rowid AS search_rowid + FROM message_content_search + LIMIT 1 + `).get() as { search_rowid: number } + const indexedText = db.prepare(` + SELECT searchable_text + FROM message_content_search + WHERE rowid = ? + `).get(row.search_rowid) as { searchable_text: string } + const count = db.prepare(` + SELECT COUNT(*) AS count + FROM message_content_search_short + WHERE search_rowid = ? + `).get(row.search_rowid) as { count: number | string } + + expect(indexedText.searchable_text.length).toBeLessThanOrEqual(MAX_INDEXED_MESSAGE_CHARACTERS) + expect(indexedText.searchable_text).toContain('tail-search-needle') + expect(Number(count.count)).toBeLessThanOrEqual(MAX_SHORT_SEARCH_GRAMS_PER_MESSAGE) + expect(store.messages.searchContent('tail-search-needle', 'default')) + .toMatchObject([{ sessionId: session.id, truncated: true }]) + expect(store.messages.hasPotentiallyIncompleteContent('default', 'missing-middle')).toBe(true) + expect(store.messages.searchContentInSession('missing-middle', 'default', session.id)) + .toMatchObject({ hasPotentiallyIncompleteResults: true }) + }) + + it('reports incomplete coverage for two-character searches outside the short index', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'short-index-coverage') + const middleOnly = `${'head '.repeat(700)}middle-only-qzx ${'tail '.repeat(700)}` + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: middleOnly } + }) + + // Three-character queries use the complete bounded FTS text, while + // the two-character path only sees the short index's head and tail. + expect(store.messages.searchContent('qzx', 'default')) + .toMatchObject([{ sessionId: session.id, truncated: false }]) + expect(store.messages.searchContentInSession('qzx', 'default', session.id)) + .toMatchObject({ hasPotentiallyIncompleteResults: false }) + expect(store.messages.searchContent('qz', 'default')).toEqual([]) + expect(store.messages.searchContentInSession('qz', 'default', session.id)) + .toMatchObject({ matches: [], hasPotentiallyIncompleteResults: true }) + + const tailMatch = `${'head '.repeat(700)}middle-only-rty ${'tail '.repeat(700)}zz` + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: tailMatch } + }) + expect(store.messages.searchContent('zz', 'default')) + .toMatchObject([{ sessionId: session.id, truncated: true }]) + }) + + it('defers live stream snapshots until the explicit terminal snapshot', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'live-stream-index') + const db = (store as unknown as { db: Database }).db + const liveContent = (message: string) => ({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + id: 'pi-stream-1', + message, + streamSnapshot: true, + live: true + } + } + }) + + store.messages.addMessage(session.id, liveContent('partial response 1')) + store.messages.addMessage(session.id, liveContent('partial response 2')) + expect(Number((db.prepare('SELECT COUNT(*) AS count FROM message_content_search').get() as { count: number | string }).count)) + .toBe(0) + + const terminal = store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + id: 'pi-stream-1', + // This terminal row is intentionally identical to the + // latest live snapshot, as Pi emits it after an error. + message: 'partial response 2', + streamSnapshot: true + } + } + }) + + expect(Number((db.prepare('SELECT COUNT(*) AS count FROM message_content_search').get() as { count: number | string }).count)) + .toBe(1) + expect(store.messages.searchContent('partial response 2', 'default')) + .toMatchObject([{ sessionId: session.id, messageId: terminal.id }]) + }) + + it('matches visible Markdown text rather than source delimiters', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'markdown-content-search') + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'Use **KV Cache** for this path.' } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', message: 'Use **KV Cache** for this path.' } + } + }) + + expect(store.messages.searchContent('KV Cache', 'default')) + .toMatchObject([{ sessionId: session.id }]) + expect(store.messages.searchContent('Use KV Cache', 'default')) + .toMatchObject([{ sessionId: session.id }]) + }) + + it('centers long-result snippets on a match near the indexed tail', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'snippet-near-tail') + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: `${'unrelated prefix '.repeat(2_000)}needle near tail` } + }) + + const [result] = store.messages.searchContent('needle near tail', 'default') + + expect(result?.snippet).toContain('needle near tail') + expect(result?.snippet.length).toBeLessThan(200) + }) + + it('indexes visible non-sidechain Claude user records', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'claude-user-content-search') + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + isSidechain: false, + message: { content: [{ type: 'text', text: 'Find this Claude prompt.' }] } + } + } + }) + + expect(store.messages.searchContent('Claude prompt', 'default')) + .toMatchObject([{ sessionId: session.id }]) + }) + + it('applies the result limit after deduplicating matching sessions', () => { + const store = new Store(':memory:') + const otherSession = makeSession(store, 'content-search-other') + const busySession = makeSession(store, 'content-search-busy') + + store.messages.addMessage(otherSession.id, { + role: 'user', + content: { type: 'text', text: 'needle in another session' } + }) + for (let index = 0; index < 201; index += 1) { + store.messages.addMessage(busySession.id, { + role: 'user', + content: { type: 'text', text: `needle in frequent result ${index}` } + }) + } + store.sessions.touchSessionUpdatedAt(busySession.id, Date.now() + 1_000, 'default') + + expect(store.messages.searchContent('needle', 'default', 2).map((result) => result.sessionId)) + .toEqual([busySession.id, otherSession.id]) + }) + + it('restricts global content search to requested sessions before applying the limit', () => { + const store = new Store(':memory:') + const first = makeSession(store, 'content-search-scope-first') + const second = makeSession(store, 'content-search-scope-second') + for (const session of [first, second]) { + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'scoped needle' } + }) + } + + expect(store.messages.searchContent('scoped needle', 'default', 1, [second.id]) + .map((result) => result.sessionId)) + .toEqual([second.id]) + }) + + it('supports scoped content search beyond SQLite variable limits', () => { + const store = new Store(':memory:') + const sessions = Array.from({ length: 1001 }, (_, index) => + makeSession(store, `large-content-scope-${index}`) + ) + const target = sessions.at(-1)! + const message = store.messages.addMessage(target.id, { + role: 'user', + content: { type: 'text', text: 'large scoped needle' } + }) + + expect(store.messages.searchContent('large scoped needle', 'default', 1, sessions.map((session) => session.id))) + .toMatchObject([{ sessionId: target.id, messageId: message.id }]) + }) + + it('returns message-level matches and the full count for one session', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'message-level-search') + const older = store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'needle in the older message' } + }) + const newer = store.messages.addMessage(session.id, { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'needle in the newer message' } } + }) + store.messages.addMessage(session.id, { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'unrelated' } } + }) + + const result = store.messages.searchContentInSession('needle', 'default', session.id) + expect(result.total).toBe(2) + expect(result.matches.map((match) => match.messageId)).toEqual([newer.id, older.id]) + expect(result.matches.map((match) => match.sessionId)).toEqual([session.id, session.id]) + }) + + it('collapses streamed assistant snapshots to the latest rendered message', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'streamed-content-search') + const firstSnapshot = store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', id: 'stream-1', message: 'old streamed needle' } + } + }) + const latestSnapshot = store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', id: 'stream-1', message: 'latest streamed answer' } + } + }) + + expect(store.messages.searchContent('old streamed', 'default')).toEqual([]) + expect(store.messages.searchContent('latest streamed', 'default')[0]?.messageId) + .toBe(latestSnapshot.id) + expect(store.messages.searchContentInSession('streamed', 'default', session.id)) + .toMatchObject({ total: 1, matches: [{ messageId: latestSnapshot.id }] }) + + const emptySnapshot = store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', id: 'stream-2', message: 'temporary streamed phrase' } + } + }) + expect(store.messages.searchContent('temporary streamed', 'default')[0]?.messageId) + .toBe(emptySnapshot.id) + store.messages.addMessage(session.id, { + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', id: 'stream-2', message: '' } + } + }) + expect(store.messages.searchContent('temporary streamed', 'default')).toEqual([]) + expect(firstSnapshot.id).not.toBe(latestSnapshot.id) + }) + + it('does not expose queued messages until they are invoked', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'queued-content') + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'queued secret phrase' } + }, 'queued-1') + + expect(store.messages.searchContent('secret phrase', 'default')).toEqual([]) + store.messages.markMessagesInvoked(session.id, ['queued-1'], 123) + expect(store.messages.searchContent('secret phrase', 'default')[0]?.sessionId).toBe(session.id) + }) + + it('keeps the derived index in sync with deletion and session merge', () => { + const store = new Store(':memory:') + const from = makeSession(store, 'merge-from') + const to = makeSession(store, 'merge-to') + const message = store.messages.addMessage(from.id, { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'mergeable result' } } + }) + + expect(store.messages.searchContent('mergeable', 'default')[0]?.messageId).toBe(message.id) + store.messages.mergeSessionMessages(from.id, to.id) + expect(store.messages.searchContent('mergeable', 'default')[0]?.sessionId).toBe(to.id) + store.sessions.deleteSession(to.id, 'default') + expect(store.messages.searchContent('mergeable', 'default')).toEqual([]) + }) + + it('keeps searchable content when session deletion fails after index cleanup starts', () => { + const store = new Store(':memory:') + const session = makeSession(store, 'delete-index-rollback') + const message = store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'delete rollback phrase' } + }) + failSessionDelete(store) + + expect(() => store.sessions.deleteSession(session.id, 'default')) + .toThrow('forced session delete failure') + expect(store.sessions.getSession(session.id)).not.toBeNull() + expect(store.messages.searchContent('delete rollback', 'default')[0]?.messageId) + .toBe(message.id) + }) + + it('cleans derived rows before a bulk session deletion', () => { + const store = new Store(':memory:') + const first = makeSession(store, 'bulk-delete-first') + const second = makeSession(store, 'bulk-delete-second') + for (const session of [first, second]) { + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'bulk cleanup phrase' } + }) + } + + const db = (store as unknown as { db: Database }).db + removeMessageContentSearchForSessions(db, [first.id, second.id]) + db.prepare('DELETE FROM sessions WHERE id IN (?, ?)').run(first.id, second.id) + + expect(store.messages.searchContent('bulk cleanup phrase', 'default')).toEqual([]) + }) +}) diff --git a/hub/src/store/messageContentSearch.ts b/hub/src/store/messageContentSearch.ts new file mode 100644 index 00000000..fc7ece49 --- /dev/null +++ b/hub/src/store/messageContentSearch.ts @@ -0,0 +1,989 @@ +import type { Database } from 'bun:sqlite' + +import { + extractAssistantParentUuid, + extractInjectedTurnUuid, + extractMessageRenderKey, + extractSearchableMessageText, + isLiveStreamSnapshot, + unwrapRoleWrappedRecordEnvelope +} from '@hapi/protocol/messages' +import { decodeMessageContent } from './contentCodec' + +export const MESSAGE_CONTENT_SEARCH_TABLE = 'message_content_search' +// Bind scoped session IDs as one JSON value instead of expanding them into +// one SQLite variable per ID. Keep a byte bound for defensive request sizing. +export const MAX_CONTENT_SEARCH_SESSION_SCOPE_BYTES = 256 * 1024 +const MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE = 'message_content_search_lookup' +const MESSAGE_CONTENT_SEARCH_SHORT_TABLE = 'message_content_search_short' +const initializedDatabases = new WeakSet() +const injectedTurnUuidCaches = new WeakMap>>() + +export type MessageContentSearchMatch = { + sessionId: string + messageId: string + role: 'user' | 'assistant' + seq: number + createdAt: number + snippet: string + truncated: boolean +} + +export type SessionMessageContentSearchResult = { + matches: MessageContentSearchMatch[] + total: number + hasPotentiallyIncompleteResults: boolean +} + +type IndexableMessage = { + id: string + sessionId: string + content: unknown + seq: number + createdAt: number + invokedAt: number | null +} + +type DbMessageRow = { + row_id: number + id: string + session_id: string + content: string | Uint8Array + created_at: number + seq: number + invoked_at: number | null +} + +const SEARCH_REBUILD_BATCH_SIZE = 500 +const SEARCH_LOOKUP_BACKFILL_BATCH_SIZE = 500 +const MIN_INDEXED_QUERY_LENGTH = 2 +export const MAX_INDEXED_MESSAGE_CHARACTERS = 32_768 +export const MAX_SHORT_SEARCH_TEXT_CHARACTERS = 2_048 +export const MAX_SHORT_SEARCH_GRAMS_PER_MESSAGE = MAX_SHORT_SEARCH_TEXT_CHARACTERS - 1 +const INDEXED_TEXT_SEPARATOR = ' ' +const INDEXED_TEXT_HEAD_CHARACTERS = Math.floor( + (MAX_INDEXED_MESSAGE_CHARACTERS - INDEXED_TEXT_SEPARATOR.length) / 2 +) +const INDEXED_TEXT_TAIL_CHARACTERS = MAX_INDEXED_MESSAGE_CHARACTERS + - INDEXED_TEXT_SEPARATOR.length + - INDEXED_TEXT_HEAD_CHARACTERS + +type DbSearchRow = { + search_rowid?: number + message_id: string + session_id: string + role: 'user' | 'assistant' + seq: number | string + created_at: number | string + snippet?: string | null + searchable_text?: string + is_truncated?: number | string | boolean + short_index_truncated?: number | string | boolean +} + +type DbSearchLookupRow = { + search_rowid: number +} + +const NO_RESPONSE_REQUESTED_TEXTS = new Set(['No response requested.', 'No response requested']) + +function getInjectedTurnUuidCache(db: Database): Map> { + let cache = injectedTurnUuidCaches.get(db) + if (!cache) { + cache = new Map() + injectedTurnUuidCaches.set(db, cache) + } + return cache +} + +function scanInjectedTurnUuidsForSessions( + db: Database, + sessionIds?: readonly string[] +): Map> { + const bySession = new Map>() + if (sessionIds?.length === 0) return bySession + + const scope = sessionIds === undefined + ? '' + : `AND session_id IN (${sessionIds.map(() => '?').join(', ')})` + const select = db.prepare(` + SELECT rowid AS row_id, session_id, content + FROM messages + WHERE rowid > ? ${scope} + ORDER BY rowid ASC + LIMIT ? + `) + let afterRowId = 0 + while (true) { + const rows = (sessionIds === undefined + ? select.all(afterRowId, SEARCH_REBUILD_BATCH_SIZE) + : select.all(afterRowId, ...sessionIds, SEARCH_REBUILD_BATCH_SIZE)) as Array<{ + row_id: number + session_id: string + content: string | Uint8Array + }> + if (rows.length === 0) break + for (const row of rows) { + const uuid = extractInjectedTurnUuid(decodeMessageContent(row.content)) + if (!uuid) continue + const sessionUuids = bySession.get(row.session_id) ?? new Set() + sessionUuids.add(uuid) + bySession.set(row.session_id, sessionUuids) + } + afterRowId = rows[rows.length - 1]!.row_id + } + return bySession +} + +function getInjectedTurnUuidsForSessions(db: Database, sessionIds?: readonly string[]): ReadonlySet { + const scanned = scanInjectedTurnUuidsForSessions(db, sessionIds) + const cache = getInjectedTurnUuidCache(db) + if (sessionIds === undefined) { + cache.clear() + for (const [sessionId, uuids] of scanned) cache.set(sessionId, uuids) + } else { + for (const sessionId of sessionIds) { + cache.set(sessionId, scanned.get(sessionId) ?? new Set()) + } + } + + const injectedTurnUuids = new Set() + if (sessionIds === undefined) { + for (const uuids of scanned.values()) { + for (const uuid of uuids) injectedTurnUuids.add(uuid) + } + } else { + for (const sessionId of sessionIds) { + for (const uuid of scanned.get(sessionId) ?? []) injectedTurnUuids.add(uuid) + } + } + return injectedTurnUuids +} + +function getCachedInjectedTurnUuids(db: Database, sessionId: string): ReadonlySet { + const cache = getInjectedTurnUuidCache(db) + const cached = cache.get(sessionId) + if (cached) return cached + + const scanned = scanInjectedTurnUuidsForSessions(db, [sessionId]) + const uuids = scanned.get(sessionId) ?? new Set() + cache.set(sessionId, uuids) + return uuids +} + +function rememberInjectedTurnUuid(db: Database, sessionId: string, content: unknown): void { + const uuid = extractInjectedTurnUuid(content) + if (!uuid) return + getInjectedTurnUuidCache(db).get(sessionId)?.add(uuid) +} + +function invalidateInjectedTurnUuidCache(db: Database, sessionIds?: readonly string[]): void { + const cache = injectedTurnUuidCaches.get(db) + if (!cache) return + if (sessionIds === undefined) { + cache.clear() + return + } + for (const sessionId of sessionIds) cache.delete(sessionId) +} + +export function invalidateMessageContentSearchInjectedTurnCache( + db: Database, + sessionIds: readonly string[] +): void { + invalidateInjectedTurnUuidCache(db, sessionIds) +} + +export function serializeContentSearchSessionIds(sessionIds: readonly string[]): string | null { + const serialized = JSON.stringify(sessionIds) + return new TextEncoder().encode(serialized).byteLength <= MAX_CONTENT_SEARCH_SESSION_SCOPE_BYTES + ? serialized + : null +} + +export function hasPotentiallyIncompleteMessageContent( + db: Database, + namespace: string, + query: string, + sessionIds?: readonly string[] +): boolean { + ensureMessageContentSearchTable(db) + const normalizedQuery = normalizeSearchQuery(query) + const queryLength = [...normalizedQuery].length + if (queryLength < MIN_INDEXED_QUERY_LENGTH) return false + const scopedSessionIds = sessionIds === undefined + ? undefined + : [...new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))] + if (scopedSessionIds?.length === 0) return false + const serializedSessionIds = scopedSessionIds === undefined + ? undefined + : serializeContentSearchSessionIds(scopedSessionIds) + if (serializedSessionIds === null) return false + const sessionScope = scopedSessionIds === undefined + ? '' + : ' AND f.session_id IN (SELECT value FROM json_each(?))' + const sessionScopeParams: string[] = serializedSessionIds === undefined ? [] : [serializedSessionIds] + const row = db.prepare(` + SELECT 1 AS found + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} AS lookup + INNER JOIN ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + ON f.rowid = lookup.search_rowid + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE ( + lookup.is_truncated = 1 + OR (? = 2 AND lookup.short_index_truncated = 1) + )${sessionScope} + LIMIT 1 + `).get(namespace, queryLength, ...sessionScopeParams) as { found?: number } | undefined + return row?.found === 1 +} + +export function backfillMessageContentSearchLookup(db: Database): void { + ensureMessageContentSearchTable(db) + const select = db.prepare(` + SELECT rowid AS search_rowid, message_id + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} + WHERE rowid > ? + ORDER BY rowid ASC + LIMIT ? + `) + const insert = db.prepare(` + INSERT OR IGNORE INTO ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ( + search_rowid, message_id, target_message_id + ) VALUES (?, ?, ?) + `) + + let afterRowId = 0 + while (true) { + const rows = select.all(afterRowId, SEARCH_LOOKUP_BACKFILL_BATCH_SIZE) as Array<{ + search_rowid: number + message_id: string + }> + if (rows.length === 0) break + + for (const row of rows) { + insert.run(row.search_rowid, row.message_id, row.message_id) + } + afterRowId = rows[rows.length - 1]!.search_rowid + } +} + +function getShortSearchGrams(text: string): { grams: string[]; truncated: boolean } { + const characters = Array.from(text.toLocaleLowerCase()) + const grams = new Set() + let truncated = false + + for (let index = 0; index + 1 < characters.length; index += 1) { + if (grams.size >= MAX_SHORT_SEARCH_GRAMS_PER_MESSAGE) { + truncated = true + break + } + grams.add(`${characters[index]!}${characters[index + 1]!}`) + } + + return { grams: [...grams], truncated } +} + +function boundSearchableText(text: string, maxCharacters = MAX_INDEXED_MESSAGE_CHARACTERS): string { + if (text.length <= maxCharacters) return text + + // Slice before converting to code points so a very large message cannot + // allocate an Array for its entire contents just to index its head and + // tail. The cap is intentionally expressed in UTF-16 code units to keep + // this work bounded even for messages containing only astral characters. + const headCharacters = Math.floor((maxCharacters - INDEXED_TEXT_SEPARATOR.length) / 2) + const tailCharacters = maxCharacters - INDEXED_TEXT_SEPARATOR.length - headCharacters + let head = text.slice(0, headCharacters) + let tail = text.slice(text.length - tailCharacters) + const headLastCodeUnit = head.charCodeAt(head.length - 1) + if (headLastCodeUnit >= 0xd800 && headLastCodeUnit <= 0xdbff) { + head = head.slice(0, -1) + } + const tailFirstCodeUnit = tail.charCodeAt(0) + if (tailFirstCodeUnit >= 0xdc00 && tailFirstCodeUnit <= 0xdfff) { + tail = tail.slice(1) + } + return `${head}${INDEXED_TEXT_SEPARATOR}${tail}` +} + +function boundSearchableTextWithMetadata( + text: string, + maxCharacters = MAX_INDEXED_MESSAGE_CHARACTERS +): { text: string; truncated: boolean } { + const bounded = boundSearchableText(text, maxCharacters) + return { text: bounded, truncated: bounded !== text } +} + +function getShortSearchIndexData(text: string): { grams: string[]; truncated: boolean } { + const bounded = boundSearchableTextWithMetadata(text, MAX_SHORT_SEARCH_TEXT_CHARACTERS) + const grams = getShortSearchGrams(bounded.text) + return { + grams: grams.grams, + truncated: bounded.truncated || grams.truncated + } +} + +export function backfillMessageContentSearchShortIndex(db: Database): void { + db.transaction(() => { + ensureMessageContentSearchTable(db) + // A failed pre-v28 startup may have left a partially populated short + // index behind. Remove obsolete unigram rows, then rebuild each row's + // grams so a retry cannot retain entries outside the current cap. + db.exec(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} WHERE length(gram) < 2`) + const select = db.prepare(` + SELECT rowid AS search_rowid, + CASE WHEN length(searchable_text) <= ? + THEN searchable_text + ELSE substr(searchable_text, 1, ?) || ? || substr(searchable_text, -?) + END AS searchable_text, + length(searchable_text) AS searchable_text_length + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} + WHERE rowid > ? + ORDER BY rowid ASC + LIMIT ? + `) + const insertShortGrams = db.prepare(` + INSERT OR IGNORE INTO ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} (gram, search_rowid) + SELECT value, ? FROM json_each(?) + `) + const clearRow = db.prepare(` + DELETE FROM ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} + WHERE search_rowid = ? + `) + const updateSearchableText = db.prepare(` + UPDATE ${MESSAGE_CONTENT_SEARCH_TABLE} + SET searchable_text = ? + WHERE rowid = ? + `) + const updateShortIndexMetadata = db.prepare(` + UPDATE ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + SET short_index_truncated = ? + WHERE search_rowid = ? + `) + + let afterRowId = 0 + while (true) { + const rows = select.all( + MAX_INDEXED_MESSAGE_CHARACTERS, + INDEXED_TEXT_HEAD_CHARACTERS, + INDEXED_TEXT_SEPARATOR, + INDEXED_TEXT_TAIL_CHARACTERS, + afterRowId, + SEARCH_LOOKUP_BACKFILL_BATCH_SIZE + ) as Array<{ + search_rowid: number + searchable_text: string + searchable_text_length: number + }> + if (rows.length === 0) break + + for (const row of rows) { + clearRow.run(row.search_rowid) + const searchableText = boundSearchableText(row.searchable_text) + if (row.searchable_text_length > MAX_INDEXED_MESSAGE_CHARACTERS + || searchableText !== row.searchable_text) { + updateSearchableText.run(searchableText, row.search_rowid) + } + const shortIndex = getShortSearchIndexData(searchableText) + updateShortIndexMetadata.run(shortIndex.truncated ? 1 : 0, row.search_rowid) + insertShortGrams.run( + row.search_rowid, + JSON.stringify(shortIndex.grams) + ) + } + afterRowId = rows[rows.length - 1]!.search_rowid + } + })() +} + +export function createMessageContentSearchTable(db: Database): void { + if (initializedDatabases.has(db)) return + + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS ${MESSAGE_CONTENT_SEARCH_TABLE} USING fts5( + searchable_text, + message_id UNINDEXED, + session_id UNINDEXED, + seq UNINDEXED, + created_at UNINDEXED, + role UNINDEXED, + tokenize = 'trigram' + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ( + search_rowid INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + target_message_id TEXT NOT NULL, + is_truncated INTEGER NOT NULL DEFAULT 0, + short_index_truncated INTEGER NOT NULL DEFAULT 0 + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} ( + gram TEXT NOT NULL, + search_rowid INTEGER NOT NULL, + PRIMARY KEY (gram, search_rowid) + ) WITHOUT ROWID + `) + db.exec(` + CREATE INDEX IF NOT EXISTS idx_message_content_search_short_rowid + ON ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} (search_rowid) + `) + const lookupColumns = db.prepare(`PRAGMA table_info(${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE})`).all() as Array<{ name: string }> + if (!lookupColumns.some((column) => column.name === 'target_message_id')) { + // V26/V27 databases created the lookup without a separate target ID. + // Keep schema v28 compatible by upgrading the derived table in place. + db.exec(`ALTER TABLE ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ADD COLUMN target_message_id TEXT`) + } + if (!lookupColumns.some((column) => column.name === 'is_truncated')) { + db.exec(`ALTER TABLE ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ADD COLUMN is_truncated INTEGER NOT NULL DEFAULT 0`) + } + if (!lookupColumns.some((column) => column.name === 'short_index_truncated')) { + db.exec(`ALTER TABLE ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ADD COLUMN short_index_truncated INTEGER NOT NULL DEFAULT 0`) + } + db.exec(` + UPDATE ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + SET target_message_id = message_id + WHERE target_message_id IS NULL + `) + db.exec(` + CREATE INDEX IF NOT EXISTS idx_message_content_search_lookup_target + ON ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} (target_message_id) + `) + db.exec(` + CREATE INDEX IF NOT EXISTS idx_message_content_search_lookup_truncated + ON ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} (is_truncated, search_rowid) + WHERE is_truncated = 1 + `) + db.exec(` + CREATE INDEX IF NOT EXISTS idx_message_content_search_lookup_short_truncated + ON ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} (short_index_truncated, search_rowid) + WHERE short_index_truncated = 1 + `) + // FTS5 UNINDEXED columns are intentionally not searchable, but SQLite + // still has to scan the virtual table when deleting by one of them. Keep + // an ordinary indexed message-id lookup so the write path stays bounded. + // Existing rows are backfilled explicitly by schema migrations. Do not + // scan the FTS table on every database reopen or first search. + initializedDatabases.add(db) +} + +function ensureMessageContentSearchTable(db: Database): void { + if (!initializedDatabases.has(db)) { + createMessageContentSearchTable(db) + } +} + +export function removeMessageContentSearchIndex(db: Database, messageId: string): void { + ensureMessageContentSearchTable(db) + const lookups = db.prepare(` + SELECT search_rowid + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE target_message_id = ? OR message_id = ? + `).all(messageId, messageId) as DbSearchLookupRow[] + for (const lookup of lookups) { + removeMessageContentSearchRow(db, lookup.search_rowid) + } +} + +function removeMessageContentSearchRow(db: Database, searchRowId: number): void { + db.prepare(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} WHERE search_rowid = ?`).run(searchRowId) + db.prepare(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_TABLE} WHERE rowid = ?`).run(searchRowId) + db.prepare(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} WHERE search_rowid = ?`).run(searchRowId) +} + +function removeMessageContentSearchIndexByKey(db: Database, searchKey: string): void { + const lookup = db.prepare(` + SELECT search_rowid + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE message_id = ? + `).get(searchKey) as DbSearchLookupRow | undefined + if (lookup) removeMessageContentSearchRow(db, lookup.search_rowid) +} + +function insertMessageContentSearchIndex( + db: Database, + message: { + id: string + targetMessageId: string + sessionId: string + text: string + role: 'user' | 'assistant' + seq: number + createdAt: number + truncated?: boolean + } +): void { + const bounded = boundSearchableTextWithMetadata(message.text) + const shortIndex = getShortSearchIndexData(bounded.text) + db.prepare(` + INSERT INTO ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ( + message_id, target_message_id, is_truncated, short_index_truncated + ) VALUES (?, ?, ?, ?) + `).run( + message.id, + message.targetMessageId, + message.truncated === true || bounded.truncated ? 1 : 0, + shortIndex.truncated ? 1 : 0 + ) + const lookup = db.prepare(` + SELECT search_rowid + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE message_id = ? + `).get(message.id) as DbSearchLookupRow | undefined + if (!lookup) throw new Error('Failed to create message content search lookup') + db.prepare(` + INSERT INTO ${MESSAGE_CONTENT_SEARCH_TABLE} ( + rowid, searchable_text, message_id, session_id, seq, created_at, role + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + lookup.search_rowid, + bounded.text, + message.targetMessageId, + message.sessionId, + message.seq, + message.createdAt, + message.role + ) + const insertShortGrams = db.prepare(` + INSERT OR IGNORE INTO ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} (gram, search_rowid) + SELECT value, ? FROM json_each(?) + `) + insertShortGrams.run( + lookup.search_rowid, + JSON.stringify(shortIndex.grams) + ) +} + +export function indexMessageContent(db: Database, message: IndexableMessage): void { + // Pi emits cumulative live snapshots every 250 ms. They are deliberately + // not indexed; the explicit terminal snapshot carries the same render key + // and is indexed once when the stream finishes. + if (isLiveStreamSnapshot(message.content)) return + rememberInjectedTurnUuid(db, message.sessionId, message.content) + ensureMessageContentSearchTable(db) + const renderKey = extractMessageRenderKey(message.content) + const searchKey = renderKey ? `${message.sessionId}:${renderKey}` : message.id + removeMessageContentSearchIndex(db, message.id) + if (searchKey !== message.id) removeMessageContentSearchIndexByKey(db, searchKey) + if (message.invokedAt === null) return + + const maxSourceCharacters = MAX_INDEXED_MESSAGE_CHARACTERS + const initialSearchable = extractSearchableMessageText(message.content, { maxSourceCharacters }) + const record = unwrapRoleWrappedRecordEnvelope(message.content) + const parentUuid = extractAssistantParentUuid(record?.content) + const searchable = parentUuid + && initialSearchable?.role === 'assistant' + && NO_RESPONSE_REQUESTED_TEXTS.has(initialSearchable.text.trim()) + ? extractSearchableMessageText(message.content, { + maxSourceCharacters, + injectedTurnUuids: getCachedInjectedTurnUuids(db, message.sessionId) + }) + : initialSearchable + if (!searchable) return + + insertMessageContentSearchIndex(db, { + id: searchKey, + targetMessageId: message.id, + sessionId: message.sessionId, + text: searchable.text, + role: searchable.role, + seq: message.seq, + createdAt: message.createdAt, + truncated: searchable.truncated === true + }) +} + +export function rebuildMessageContentSearch(db: Database): void { + db.transaction(() => { + rebuildMessageContentSearchInternal(db) + })() +} + +function removeMessageContentSearchRowsForSessions(db: Database, sessionIds: string[]): void { + if (sessionIds.length === 0) return + const placeholders = sessionIds.map(() => '?').join(', ') + const messageIds = `SELECT id FROM messages WHERE session_id IN (${placeholders})` + db.prepare(` + DELETE FROM ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} + WHERE search_rowid IN ( + SELECT search_rowid + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE target_message_id IN (${messageIds}) + ) + `).run(...sessionIds) + db.prepare(` + DELETE FROM ${MESSAGE_CONTENT_SEARCH_TABLE} + WHERE rowid IN ( + SELECT search_rowid + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE target_message_id IN (${messageIds}) + ) + `).run(...sessionIds) + db.prepare(` + DELETE FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE target_message_id IN (${messageIds}) + `).run(...sessionIds) +} + +function rebuildMessageContentSearchInternal(db: Database, sessionIds?: string[]): void { + createMessageContentSearchTable(db) + + const insertLookup = db.prepare(` + INSERT INTO ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} ( + message_id, target_message_id, is_truncated, short_index_truncated + ) VALUES (?, ?, ?, ?) + `) + const getLookup = db.prepare(` + SELECT search_rowid + FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} + WHERE message_id = ? + `) + const insert = db.prepare(` + INSERT INTO ${MESSAGE_CONTENT_SEARCH_TABLE} ( + rowid, searchable_text, message_id, session_id, seq, created_at, role + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + const insertShortGrams = db.prepare(` + INSERT OR IGNORE INTO ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} (gram, search_rowid) + SELECT value, ? FROM json_each(?) + `) + + let selectBatch: ReturnType + if (sessionIds) { + const placeholders = sessionIds.map(() => '?').join(', ') + removeMessageContentSearchRowsForSessions(db, sessionIds) + selectBatch = db.prepare(` + SELECT rowid AS row_id, id, session_id, content, created_at, seq, invoked_at + FROM messages + WHERE session_id IN (${placeholders}) + AND invoked_at IS NOT NULL + AND rowid > ? + ORDER BY rowid ASC + LIMIT ? + `) + } else { + db.exec(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_TABLE}`) + db.exec(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE}`) + db.exec(`DELETE FROM ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE}`) + selectBatch = db.prepare(` + SELECT rowid AS row_id, id, session_id, content, created_at, seq, invoked_at + FROM messages + WHERE invoked_at IS NOT NULL + AND rowid > ? + ORDER BY rowid ASC + LIMIT ? + `) + } + + invalidateInjectedTurnUuidCache(db, sessionIds) + + // Build the renderer-equivalent system-injected UUID set once per rebuild; + // doing this inside the batch loop would turn a full rebuild into O(N²). + const injectedTurnUuids = getInjectedTurnUuidsForSessions(db, sessionIds) + let afterRowId = 0 + while (true) { + const rows = (sessionIds + ? selectBatch.all(...sessionIds, afterRowId, SEARCH_REBUILD_BATCH_SIZE) + : selectBatch.all(afterRowId, SEARCH_REBUILD_BATCH_SIZE)) as DbMessageRow[] + if (rows.length === 0) break + + for (const row of rows) { + const decodedContent = decodeMessageContent(row.content) + if (isLiveStreamSnapshot(decodedContent)) continue + const renderKey = extractMessageRenderKey(decodedContent) + const searchKey = renderKey ? `${row.session_id}:${renderKey}` : row.id + removeMessageContentSearchIndex(db, row.id) + if (searchKey !== row.id) removeMessageContentSearchIndexByKey(db, searchKey) + const searchable = extractSearchableMessageText(decodedContent, { + injectedTurnUuids, + maxSourceCharacters: MAX_INDEXED_MESSAGE_CHARACTERS + }) + if (!searchable) continue + const bounded = boundSearchableTextWithMetadata(searchable.text) + const shortIndex = getShortSearchIndexData(bounded.text) + insertLookup.run( + searchKey, + row.id, + searchable.truncated === true || bounded.truncated ? 1 : 0, + shortIndex.truncated ? 1 : 0 + ) + const lookup = getLookup.get(searchKey) as DbSearchLookupRow | undefined + if (!lookup) throw new Error('Failed to create message content search lookup') + insert.run( + lookup.search_rowid, + bounded.text, + row.id, + row.session_id, + row.seq, + row.created_at, + searchable.role + ) + insertShortGrams.run( + lookup.search_rowid, + JSON.stringify(shortIndex.grams) + ) + } + + afterRowId = rows[rows.length - 1]!.row_id + } +} + +export function rebuildMessageContentSearchForSessions( + db: Database, + sessionIds: string[], + alreadyInTransaction = false +): void { + if (sessionIds.length === 0) return + const rebuild = () => rebuildMessageContentSearchInternal(db, sessionIds) + if (alreadyInTransaction) { + rebuild() + } else { + db.transaction(rebuild)() + } +} + +export function removeMessageContentSearchForSessions(db: Database, sessionIds: string[]): void { + ensureMessageContentSearchTable(db) + invalidateInjectedTurnUuidCache(db, sessionIds) + removeMessageContentSearchRowsForSessions(db, sessionIds) +} + +export function removeMessageContentSearchForSession(db: Database, sessionId: string): void { + removeMessageContentSearchForSessions(db, [sessionId]) +} + +function normalizeSearchQuery(query: string): string { + return query.trim().replace(/\s+/g, ' ') +} + +function escapeFtsPhrase(query: string): string { + return `"${query.replaceAll('"', '""')}"` +} + +function makeLikeSnippet(text: string, query: string): string { + const lowerText = text.toLocaleLowerCase() + const lowerQuery = query.toLocaleLowerCase() + const matchAt = lowerText.indexOf(lowerQuery) + const radius = 90 + const start = matchAt < 0 ? 0 : Math.max(0, matchAt - radius) + const end = Math.min(text.length, (matchAt < 0 ? 0 : matchAt) + query.length + radius) + const prefix = start > 0 ? '…' : '' + const suffix = end < text.length ? '…' : '' + return `${prefix}${text.slice(start, end)}${suffix}`.replace(/\s+/g, ' ').trim() +} + +function isSqlTrue(value: unknown): boolean { + return value === true || value === 1 || value === '1' +} + +export function searchMessageContent( + db: Database, + query: string, + namespace: string, + limit: number = 50, + sessionIds?: readonly string[] +): MessageContentSearchMatch[] { + ensureMessageContentSearchTable(db) + const normalizedQuery = normalizeSearchQuery(query) + if (!normalizedQuery) return [] + + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.floor(limit))) : 50 + const queryLength = [...normalizedQuery].length + if (queryLength < MIN_INDEXED_QUERY_LENGTH) return [] + const scopedSessionIds = sessionIds === undefined + ? undefined + : [...new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))] + if (scopedSessionIds?.length === 0) return [] + const serializedSessionIds = scopedSessionIds === undefined + ? undefined + : serializeContentSearchSessionIds(scopedSessionIds) + if (serializedSessionIds === null) return [] + const sessionScope = scopedSessionIds === undefined + ? '' + : ' AND f.session_id IN (SELECT value FROM json_each(?))' + const sessionScopeParams: string[] = serializedSessionIds === undefined ? [] : [serializedSessionIds] + const useShortIndex = queryLength === MIN_INDEXED_QUERY_LENGTH + const rows = useShortIndex + ? db.prepare(` + WITH ranked_matches AS ( + SELECT + f.rowid AS search_rowid, + f.message_id, + f.session_id, + f.role, + f.seq, + f.created_at, + f.searchable_text, + s.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY f.session_id + ORDER BY CAST(f.seq AS INTEGER) DESC, + CAST(f.created_at AS INTEGER) DESC, + f.message_id DESC + ) AS session_rank + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + INNER JOIN ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} AS short + ON short.search_rowid = f.rowid AND short.gram = ? + INNER JOIN ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} AS lookup + ON lookup.search_rowid = f.rowid + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE 1 = 1${sessionScope} + ) + SELECT ranked.message_id, ranked.session_id, ranked.role, ranked.seq, + ranked.created_at, ranked.searchable_text, lookup.is_truncated, + lookup.short_index_truncated + FROM ranked_matches AS ranked + INNER JOIN ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} AS lookup + ON lookup.search_rowid = ranked.search_rowid + WHERE session_rank = 1 + ORDER BY updated_at DESC, CAST(seq AS INTEGER) DESC + LIMIT ? + `).all(normalizedQuery.toLocaleLowerCase(), namespace, ...sessionScopeParams, safeLimit) as DbSearchRow[] + : db.prepare(` + WITH ranked_matches AS ( + SELECT + f.rowid AS search_rowid, + f.message_id, + f.session_id, + f.role, + f.seq, + f.created_at, + s.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY f.session_id + ORDER BY CAST(f.seq AS INTEGER) DESC, + CAST(f.created_at AS INTEGER) DESC, + f.message_id DESC + ) AS session_rank + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE ${MESSAGE_CONTENT_SEARCH_TABLE} MATCH ?${sessionScope} + ) + SELECT ranked.message_id, ranked.session_id, ranked.role, ranked.seq, ranked.created_at, + snippet(${MESSAGE_CONTENT_SEARCH_TABLE}, 0, '', '', '…', 24) AS snippet, + lookup.is_truncated, lookup.short_index_truncated + FROM ranked_matches AS ranked + INNER JOIN ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + ON f.rowid = ranked.search_rowid + INNER JOIN ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} AS lookup + ON lookup.search_rowid = ranked.search_rowid + WHERE ranked.session_rank = 1 + AND ${MESSAGE_CONTENT_SEARCH_TABLE} MATCH ? + ORDER BY ranked.updated_at DESC, CAST(ranked.seq AS INTEGER) DESC + LIMIT ? + `).all(namespace, escapeFtsPhrase(normalizedQuery), ...sessionScopeParams, escapeFtsPhrase(normalizedQuery), safeLimit) as DbSearchRow[] + + return rows.map((row) => ({ + sessionId: row.session_id, + messageId: row.message_id, + role: row.role, + seq: Number(row.seq), + createdAt: Number(row.created_at), + truncated: isSqlTrue(row.is_truncated) + || (useShortIndex && isSqlTrue(row.short_index_truncated)), + snippet: useShortIndex + ? makeLikeSnippet(row.searchable_text ?? '', normalizedQuery) + : String(row.snippet ?? '').replace(/\s+/g, ' ').trim() + })) +} + +/** + * Return every matching message in one session for in-chat navigation. + * + * The sidebar deliberately deduplicates to one result per session. Once a + * result is opened, the chat needs the message-level result set so the user + * can move between older and newer matches without loading the whole session. + */ +export function searchMessageContentInSession( + db: Database, + query: string, + namespace: string, + sessionId: string, + limit: number = 500 +): SessionMessageContentSearchResult { + ensureMessageContentSearchTable(db) + const normalizedQuery = normalizeSearchQuery(query) + if (!normalizedQuery) return { matches: [], total: 0, hasPotentiallyIncompleteResults: false } + + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(1000, Math.floor(limit))) : 500 + const queryLength = [...normalizedQuery].length + if (queryLength < MIN_INDEXED_QUERY_LENGTH) { + return { matches: [], total: 0, hasPotentiallyIncompleteResults: false } + } + const useShortIndex = queryLength === MIN_INDEXED_QUERY_LENGTH + const countRow = useShortIndex + ? db.prepare(` + SELECT COUNT(*) AS count + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + INNER JOIN ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} AS short + ON short.search_rowid = f.rowid AND short.gram = ? + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE f.session_id = ? + `).get(normalizedQuery.toLocaleLowerCase(), namespace, sessionId) as { count: number | string } + : db.prepare(` + SELECT COUNT(*) AS count + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE f.session_id = ? + AND ${MESSAGE_CONTENT_SEARCH_TABLE} MATCH ? + `).get(namespace, sessionId, escapeFtsPhrase(normalizedQuery)) as { count: number | string } + + const rows = useShortIndex + ? db.prepare(` + SELECT f.message_id, f.session_id, f.role, f.seq, f.created_at, f.searchable_text, + lookup.is_truncated, lookup.short_index_truncated + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + INNER JOIN ${MESSAGE_CONTENT_SEARCH_SHORT_TABLE} AS short + ON short.search_rowid = f.rowid AND short.gram = ? + INNER JOIN ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} AS lookup + ON lookup.search_rowid = f.rowid + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE f.session_id = ? + ORDER BY CAST(f.seq AS INTEGER) DESC + LIMIT ? + `).all(normalizedQuery.toLocaleLowerCase(), namespace, sessionId, safeLimit) as DbSearchRow[] + : db.prepare(` + SELECT f.message_id, f.session_id, f.role, f.seq, f.created_at, + snippet(${MESSAGE_CONTENT_SEARCH_TABLE}, 0, '', '', '…', 24) AS snippet, + lookup.is_truncated, lookup.short_index_truncated + FROM ${MESSAGE_CONTENT_SEARCH_TABLE} AS f + INNER JOIN ${MESSAGE_CONTENT_SEARCH_LOOKUP_TABLE} AS lookup + ON lookup.search_rowid = f.rowid + INNER JOIN sessions AS s + ON s.id = f.session_id AND s.namespace = ? + WHERE f.session_id = ? + AND ${MESSAGE_CONTENT_SEARCH_TABLE} MATCH ? + ORDER BY CAST(f.seq AS INTEGER) DESC + LIMIT ? + `).all(namespace, sessionId, escapeFtsPhrase(normalizedQuery), safeLimit) as DbSearchRow[] + + return { + matches: rows.map((row) => ({ + sessionId: row.session_id, + messageId: row.message_id, + role: row.role, + seq: Number(row.seq), + createdAt: Number(row.created_at), + truncated: isSqlTrue(row.is_truncated) + || (useShortIndex && isSqlTrue(row.short_index_truncated)), + snippet: useShortIndex + ? makeLikeSnippet(row.searchable_text ?? '', normalizedQuery) + : String(row.snippet ?? '').replace(/\s+/g, ' ').trim() + })), + total: Number(countRow?.count ?? 0), + hasPotentiallyIncompleteResults: hasPotentiallyIncompleteMessageContent( + db, + namespace, + normalizedQuery, + [sessionId] + ) + } +} diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 39c09858..9bc6ca3c 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -42,12 +42,20 @@ import { getMessagesAfterSeq, getMinSeq, getMessageSeqById, + getMessageById, truncateMessagesFromLocalId, type CancelQueuedMessageResult, type LookupQueuedMessageResult, type LocalMessageState, type MessagePosition, } from './messages' +import { + hasPotentiallyIncompleteMessageContent, + searchMessageContent, + searchMessageContentInSession, + type MessageContentSearchMatch, + type SessionMessageContentSearchResult +} from './messageContentSearch' export class MessageStore { private readonly db: Database @@ -103,6 +111,10 @@ export class MessageStore { return getMessageSeqById(this.db, sessionId, messageId) } + getMessageById(sessionId: string, messageId: string): StoredMessage | null { + return getMessageById(this.db, sessionId, messageId) + } + getMessages(sessionId: string, limit: number = 200): StoredMessage[] { return getMessages(this.db, sessionId, limit) } @@ -187,6 +199,32 @@ export class MessageStore { return countMessages(this.db, sessionId) } + searchContent( + query: string, + namespace: string, + limit: number = 50, + sessionIds?: readonly string[] + ): MessageContentSearchMatch[] { + return searchMessageContent(this.db, query, namespace, limit, sessionIds) + } + + searchContentInSession( + query: string, + namespace: string, + sessionId: string, + limit: number = 500 + ): SessionMessageContentSearchResult { + return searchMessageContentInSession(this.db, query, namespace, sessionId, limit) + } + + hasPotentiallyIncompleteContent( + namespace: string, + query: string, + sessionIds?: readonly string[] + ): boolean { + return hasPotentiallyIncompleteMessageContent(this.db, namespace, query, sessionIds) + } + cancelQueuedMessage(sessionId: string, messageId: string): CancelQueuedMessageResult { return cancelQueuedMessage(this.db, sessionId, messageId) } diff --git a/hub/src/store/messages.test.ts b/hub/src/store/messages.test.ts index 45065bb1..da471aa4 100644 --- a/hub/src/store/messages.test.ts +++ b/hub/src/store/messages.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import type { Database } from 'bun:sqlite' import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' import { getReasoningStreamId } from '@hapi/protocol/messages' import { Store } from './index' @@ -11,6 +12,17 @@ function makeSession(store: Store, tag: string) { return store.sessions.getOrCreateSession(tag, { path: `/tmp/${tag}` }, null, 'default') } +function failMessageContentSearchIndex(store: Store): void { + const db = (store as unknown as { db: Database }).db + db.exec(` + CREATE TRIGGER fail_message_content_search_index + BEFORE INSERT ON message_content_search_lookup + BEGIN + SELECT RAISE(ABORT, 'forced message content search index failure'); + END + `) +} + describe('cancelQueuedMessage', () => { it('happy path: deletes queued message, returns status=cancelled with localId', () => { const store = makeStore() @@ -192,6 +204,53 @@ describe('recordMessagesConsumed', () => { }) }) +describe('message content search index transaction boundaries', () => { + it('rolls back copied messages when indexing fails', () => { + const store = makeStore() + const session = makeSession(store, 'copy-index-rollback') + failMessageContentSearchIndex(store) + + expect(() => store.messages.copyMessageToSession(session.id, { + content: { role: 'user', content: { type: 'text', text: 'copy rollback phrase' } }, + createdAt: 1, + localId: null, + invokedAt: 1, + scheduledAt: null + })).toThrow('forced message content search index failure') + expect(store.messages.getAllMessages(session.id)).toEqual([]) + }) + + it('rolls back invocation state when indexing fails', () => { + const store = makeStore() + const session = makeSession(store, 'invoke-index-rollback') + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'invoke rollback phrase' } + }, 'invoke-rollback') + failMessageContentSearchIndex(store) + + expect(() => store.messages.markMessagesInvoked(session.id, ['invoke-rollback'], 123)) + .toThrow('forced message content search index failure') + expect(store.messages.getLocalMessageStates(session.id, ['invoke-rollback'])) + .toEqual([{ localId: 'invoke-rollback', invokedAt: null }]) + }) + + it('rolls back immediate settlement when indexing fails', () => { + const store = makeStore() + const session = makeSession(store, 'immediate-index-rollback') + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'immediate rollback phrase' } + }, 'immediate-rollback') + failMessageContentSearchIndex(store) + + expect(() => store.messages.markUninvokedImmediateMessages(session.id, 456)) + .toThrow('forced message content search index failure') + expect(store.messages.getLocalMessageStates(session.id, ['immediate-rollback'])) + .toEqual([{ localId: 'immediate-rollback', invokedAt: null }]) + }) +}) + describe('position pagination and structural epochs', () => { it('returns rows strictly after a cursor and respects an inclusive snapshot head', () => { const store = makeStore() diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index e2304ba7..d8ae7e8d 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -9,6 +9,12 @@ import { hasUnreadMessageContent, getLiveReasoningStreamId } from '@hapi/protoco import type { StoredMessage } from './types' import { decodeMessageContent, encodeMessageContent, truncateOversizedMessageContent } from './contentCodec' +import { + indexMessageContent, + invalidateMessageContentSearchInjectedTurnCache, + rebuildMessageContentSearchForSessions, + removeMessageContentSearchIndex +} from './messageContentSearch' type DbMessageRow = { content_revision: number @@ -24,6 +30,13 @@ type DbMessageRow = { delivery_state: string | null } +// Message-store methods can be called directly or composed inside a Store-level +// transaction. Reuse an existing transaction so canonical rows and derived +// content-search rows always commit or roll back together. +function runInTransaction(db: Database, operation: () => T): T { + return db.inTransaction ? operation() : db.transaction(operation)() +} + export type MessagePosition = { at: number seq: number @@ -45,7 +58,7 @@ export function addImportedMessage( localId: string, createdAt: number ): { message: StoredMessage; inserted: boolean } { - const existing = prepareCached(db, + const existing = prepareCached(db, 'SELECT * FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1' ).get(sessionId, localId) as DbMessageRow | undefined if (existing) { @@ -59,10 +72,11 @@ export function addImportedMessage( const stampedAt = Number.isFinite(createdAt) ? Math.min(createdAt, now) : now return db.transaction(() => { const previousHead = getNewestMessagePosition(db, sessionId) - const msgSeqRow = prepareCached(db, + const msgSeqRow = prepareCached(db, 'SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM messages WHERE session_id = ?' ).get(sessionId) as { nextSeq: number } const id = randomUUID() + const canonicalContent = truncateOversizedMessageContent(content) prepareCached(db, ` INSERT INTO messages ( id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at @@ -72,12 +86,20 @@ export function addImportedMessage( `).run({ id, session_id: sessionId, - content: encodeMessageContent(truncateOversizedMessageContent(content)), + content: encodeMessageContent(canonicalContent), created_at: stampedAt, seq: msgSeqRow.nextSeq, local_id: localId, invoked_at: stampedAt }) + indexMessageContent(db, { + id, + sessionId, + content: canonicalContent, + seq: msgSeqRow.nextSeq, + createdAt: stampedAt, + invokedAt: stampedAt + }) recordUserMessage(db, sessionId, content, stampedAt) if (previousHead && stampedAt < previousHead.at) bumpMessageEpoch(db, sessionId) const row = prepareCached(db, 'SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined @@ -85,7 +107,6 @@ export function addImportedMessage( return { message: toStoredMessage(row), inserted: true } })() } - function toStoredMessage(row: DbMessageRow): StoredMessage { return { id: row.id, @@ -132,7 +153,7 @@ export function addMessage( } if (localId) { - const existing = prepareCached(db, + const existing = prepareCached(db, 'SELECT * FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1' ).get(sessionId, localId) as DbMessageRow | undefined if (existing) { @@ -140,13 +161,14 @@ export function addMessage( } } - const msgSeqRow = prepareCached(db, + const msgSeqRow = prepareCached(db, 'SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM messages WHERE session_id = ?' ).get(sessionId) as { nextSeq: number } const msgSeq = msgSeqRow.nextSeq const id = randomUUID() - const encoded = encodeMessageContent(truncateOversizedMessageContent(content)) + const canonicalContent = truncateOversizedMessageContent(content) + const encoded = encodeMessageContent(canonicalContent) // Messages without a localId have no ack path (markMessagesInvoked matches by localId). // Treat them as already-invoked at insert time so they land in the thread normally instead @@ -174,6 +196,14 @@ export function addMessage( invoked_at: invokedAt, scheduled_at: scheduledAt ?? null }) + indexMessageContent(db, { + id, + sessionId, + content: canonicalContent, + seq: msgSeq, + createdAt: stampedAt, + invokedAt + }) if (hasUnreadMessageContent(content)) { prepareCached(db, 'UPDATE sessions SET content_revision = content_revision + 1 WHERE id = ?').run(sessionId) @@ -209,60 +239,69 @@ export function copyMessageToSession( sessionId: string, message: CopyStoredMessageInput ): StoredMessage { - const createdAt = Number.isFinite(message.createdAt) ? message.createdAt : Date.now() - const nextSeq = getMaxSeq(db, sessionId) + 1 + return runInTransaction(db, () => { + const createdAt = Number.isFinite(message.createdAt) ? message.createdAt : Date.now() + const nextSeq = getMaxSeq(db, sessionId) + 1 - let localId = message.localId - if (localId) { - const collision = prepareCached(db, - 'SELECT 1 FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1' - ).get(sessionId, localId) as { 1: number } | undefined - if (collision) { - // 中文注释:重复会话合并时如果 localId 撞车,给复制进目标会话的消息生成一个新 localId,避免误判成同一条已存在消息。 - localId = `${localId}:merged:${randomUUID().slice(0, 8)}` + let localId = message.localId + if (localId) { + const collision = prepareCached(db, + 'SELECT 1 FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1' + ).get(sessionId, localId) as { 1: number } | undefined + if (collision) { + // 中文注释:重复会话合并时如果 localId 撞车,给复制进目标会话的消息生成一个新 localId,避免误判成同一条已存在消息。 + localId = `${localId}:merged:${randomUUID().slice(0, 8)}` + } } - } - if (message.scheduledAt != null && !localId && message.invokedAt === null) { - // 中文注释:未来计划消息仍需要 ack 路径;异常情况下若源数据缺少 localId,这里补一个稳定可写的新值以保留调度语义。 - localId = `merged-scheduled:${randomUUID()}` - } + if (message.scheduledAt != null && !localId && message.invokedAt === null) { + // 中文注释:未来计划消息仍需要 ack 路径;异常情况下若源数据缺少 localId,这里补一个稳定可写的新值以保留调度语义。 + localId = `merged-scheduled:${randomUUID()}` + } - const invokedAt = localId ? message.invokedAt : (message.invokedAt ?? createdAt) - const id = randomUUID() - prepareCached(db, ` - INSERT INTO messages ( - id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at, delivery_state - ) VALUES ( - @id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at, @scheduled_at, @delivery_state - ) - `).run({ - id, - session_id: sessionId, - // Lossless re-encode only — copies move existing history between - // sessions, so no truncation here even for pre-codec oversized rows. - content: encodeMessageContent(message.content), - created_at: createdAt, - seq: nextSeq, - local_id: localId ?? null, - invoked_at: invokedAt ?? null, - scheduled_at: message.scheduledAt ?? null, - delivery_state: message.deliveryState ?? 'queued' + const invokedAt = localId ? message.invokedAt : (message.invokedAt ?? createdAt) + const id = randomUUID() + prepareCached(db, ` + INSERT INTO messages ( + id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at, delivery_state + ) VALUES ( + @id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at, @scheduled_at, @delivery_state + ) + `).run({ + id, + session_id: sessionId, + // Lossless re-encode only — copies move existing history between + // sessions, so no truncation here even for pre-codec oversized rows. + content: encodeMessageContent(message.content), + created_at: createdAt, + seq: nextSeq, + local_id: localId ?? null, + invoked_at: invokedAt ?? null, + scheduled_at: message.scheduledAt ?? null, + delivery_state: message.deliveryState ?? 'queued' + }) + indexMessageContent(db, { + id, + sessionId, + content: message.content, + seq: nextSeq, + createdAt, + invokedAt: invokedAt ?? null + }) + + const row = prepareCached(db, 'SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined + if (!row) { + throw new Error('Failed to copy message into target session') + } + + // Copies preserve the source display timestamp, so a new high-seq row can + // still land behind a Web client's cached composite tail cursor. Mark the + // target history as structurally changed so incremental readers reset. + recordUserMessage(db, sessionId, message.content, createdAt) + bumpMessageEpoch(db, sessionId) + return toStoredMessage(row) }) - - const row = prepareCached(db, 'SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined - if (!row) { - throw new Error('Failed to copy message into target session') - } - recordUserMessage(db, sessionId, message.content, createdAt) - - // Copies preserve the source display timestamp, so a new high-seq row can - // still land behind a Web client's cached composite tail cursor. Mark the - // target history as structurally changed so incremental readers reset. - bumpMessageEpoch(db, sessionId) - return toStoredMessage(row) } - /** * Batch-hydrate a fork child: one transaction, contiguous seq allocation, * single epoch bump. Avoids O(n) max-seq lookups and epoch writes. @@ -283,7 +322,7 @@ export function copyMessagesToSession( @id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at, @scheduled_at, @delivery_state ) `) - const collisionCheck = prepareCached(db, + const collisionCheck = prepareCached(db, 'SELECT 1 FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1' ) @@ -300,8 +339,9 @@ export function copyMessagesToSession( localId = `merged-scheduled:${randomUUID()}` } const invokedAt = localId ? message.invokedAt : (message.invokedAt ?? createdAt) + const id = randomUUID() insert.run({ - id: randomUUID(), + id, session_id: sessionId, content: encodeMessageContent(message.content), created_at: createdAt, @@ -311,6 +351,14 @@ export function copyMessagesToSession( scheduled_at: message.scheduledAt ?? null, delivery_state: message.deliveryState ?? 'queued' }) + indexMessageContent(db, { + id, + sessionId, + content: message.content, + seq: nextSeq, + createdAt, + invokedAt: invokedAt ?? null + }) recordUserMessage(db, sessionId, message.content, createdAt) nextSeq += 1 } @@ -327,7 +375,7 @@ export function getMessages( ): StoredMessage[] { const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, limit)) : 200 - const rows = prepareCached(db, + const rows = prepareCached(db, 'SELECT * FROM messages WHERE session_id = ? ORDER BY seq DESC LIMIT ?' ).all(sessionId, safeLimit) as DbMessageRow[] @@ -338,7 +386,7 @@ export function getAllMessages( db: Database, sessionId: string ): StoredMessage[] { - const rows = prepareCached(db, + const rows = prepareCached(db, 'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC' ).all(sessionId) as DbMessageRow[] @@ -350,7 +398,7 @@ export function getMessagesAfterSeq( sessionId: string, afterSeq: number ): StoredMessage[] { - const rows = prepareCached(db, + const rows = prepareCached(db, 'SELECT * FROM messages WHERE session_id = ? AND seq > ? ORDER BY seq ASC' ).all(sessionId, afterSeq) as DbMessageRow[] @@ -363,12 +411,23 @@ export function getMessageSeqById( sessionId: string, messageId: string ): number | null { - const row = prepareCached(db, + const row = prepareCached(db, 'SELECT seq FROM messages WHERE id = ? AND session_id = ?' ).get(messageId, sessionId) as { seq: number } | undefined return row ? row.seq : null } +export function getMessageById( + db: Database, + sessionId: string, + messageId: string +): StoredMessage | null { + const row = db.prepare( + 'SELECT * FROM messages WHERE id = ? AND session_id = ?' + ).get(messageId, sessionId) as DbMessageRow | undefined + return row ? toStoredMessage(row) : null +} + export function getFirstMessages( db: Database, sessionId: string, @@ -376,7 +435,7 @@ export function getFirstMessages( ): StoredMessage[] { const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, limit)) : 50 - const rows = prepareCached(db, + const rows = prepareCached(db, 'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC LIMIT ?' ).all(sessionId, safeLimit) as DbMessageRow[] @@ -451,7 +510,7 @@ export function deleteLiveReasoningSnapshots( if (staleIds.length === 0) return 0 const placeholders = staleIds.map(() => '?').join(', ') - const result = prepareCached(db, + const result = prepareCached(db, `DELETE FROM messages WHERE session_id = ? AND id IN (${placeholders})` ).run(sessionId, ...staleIds) return Number(result.changes) @@ -537,7 +596,7 @@ export function getNewestMessagePosition(db: Database, sessionId: string): Messa } export function getMessageEpoch(db: Database, sessionId: string): number { - const row = prepareCached(db, + const row = prepareCached(db, 'SELECT epoch FROM message_epochs WHERE session_id = ?' ).get(sessionId) as { epoch: number } | undefined return row?.epoch ?? 0 @@ -561,7 +620,7 @@ export function getUninvokedLocalMessages( options?: { deliverableOnly?: boolean } ): StoredMessage[] { const deliverableClause = options?.deliverableOnly ? " AND delivery_state = 'queued'" : '' - const rows = prepareCached(db, + const rows = prepareCached(db, `SELECT * FROM messages WHERE session_id = ? AND invoked_at IS NULL AND local_id IS NOT NULL${deliverableClause} ORDER BY seq ASC` ).all(sessionId) as DbMessageRow[] return rows.map(toStoredMessage) @@ -608,7 +667,7 @@ export function getMatureScheduledMessages( db: Database, beforeTime: number ): StoredMessage[] { - const rows = prepareCached(db, + const rows = prepareCached(db, "SELECT * FROM messages WHERE scheduled_at IS NOT NULL AND scheduled_at <= ? AND invoked_at IS NULL AND delivery_state = 'queued' ORDER BY scheduled_at ASC" ).all(beforeTime) as DbMessageRow[] return rows.map(toStoredMessage) @@ -652,7 +711,7 @@ export function getImmediateQueuedLocalMessages( * tiann/hapi#872. */ export function countMessages(db: Database, sessionId: string): number { - const row = prepareCached(db, + const row = prepareCached(db, 'SELECT COUNT(*) AS count FROM messages WHERE session_id = ?' ).get(sessionId) as { count: number } | undefined return row?.count ?? 0 @@ -738,7 +797,7 @@ export function minFutureScheduledAtBySessionIds( } export function getMaxSeq(db: Database, sessionId: string): number { - const row = prepareCached(db, + const row = prepareCached(db, 'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?' ).get(sessionId) as { maxSeq: number } | undefined return row?.maxSeq ?? 0 @@ -804,6 +863,7 @@ export function cancelQueuedMessage( `).run(sessionId, messageId, messageId) if (deleted.changes > 0) { + removeMessageContentSearchIndex(db, row.id) bumpMessageEpoch(db, sessionId) } @@ -894,18 +954,23 @@ export function deleteQueuedMessageById( messageId: string ): boolean { return db.transaction(() => { + const row = prepareCached(db, ` + SELECT id FROM messages + WHERE session_id = ? AND (id = ? OR local_id = ?) AND invoked_at IS NULL + LIMIT 1 + `).get(sessionId, messageId, messageId) as { id: string } | undefined const deleted = prepareCached(db, ` DELETE FROM messages WHERE session_id = ? AND (id = ? OR local_id = ?) AND invoked_at IS NULL `).run(sessionId, messageId, messageId) if (deleted.changes > 0) { + if (row) removeMessageContentSearchIndex(db, row.id) bumpMessageEpoch(db, sessionId) return true } return false })() } - /** Mark messages as invoked at the given server timestamp. * Only updates rows whose local_id is in localIds. * First-write-wins: rows with a non-NULL invoked_at are not updated. A duplicate @@ -918,16 +983,35 @@ export function markMessagesInvoked( invokedAt: number ): number { if (localIds.length === 0) return 0 - const placeholders = localIds.map(() => '?').join(', ') - return prepareCached(db, - `UPDATE messages - SET invoked_at = ?, delivery_state = 'queued' - WHERE session_id = ? - AND local_id IN (${placeholders}) - AND invoked_at IS NULL` - ).run(invokedAt, sessionId, ...localIds).changes + return runInTransaction(db, () => { + const placeholders = localIds.map(() => '?').join(', ') + const changes = prepareCached(db, + `UPDATE messages + SET invoked_at = ?, delivery_state = 'queued' + WHERE session_id = ? + AND local_id IN (${placeholders}) + AND invoked_at IS NULL` + ).run(invokedAt, sessionId, ...localIds).changes + if (changes > 0) { + const rows = prepareCached(db, ` + SELECT id, session_id, content, created_at, seq, invoked_at + FROM messages + WHERE session_id = ? AND local_id IN (${placeholders}) AND invoked_at IS NOT NULL + `).all(sessionId, ...localIds) as DbMessageRow[] + for (const row of rows) { + indexMessageContent(db, { + id: row.id, + sessionId: row.session_id, + content: decodeMessageContent(row.content), + seq: row.seq, + createdAt: row.created_at, + invokedAt: row.invoked_at + }) + } + } + return changes + }) } - /** Move an uninvoked steer through its durable delivery states. */ export function setMessagesDeliveryState( db: Database, @@ -942,7 +1026,7 @@ export function setMessagesDeliveryState( : state === 'dispatching' ? "'queued', 'indeterminate'" : "'queued', 'dispatching'" - return prepareCached(db, + return prepareCached(db, `UPDATE messages SET delivery_state = ? WHERE session_id = ? @@ -968,29 +1052,45 @@ export function markUninvokedImmediateMessages( sessionId: string, invokedAt: number ): string[] { - const rows = prepareCached(db, ` - SELECT local_id FROM messages - WHERE session_id = ? - AND local_id IS NOT NULL - AND scheduled_at IS NULL - AND invoked_at IS NULL - AND delivery_state = 'queued' - ORDER BY seq ASC - `).all(sessionId) as Array<{ local_id: string }> - if (rows.length === 0) return [] + return runInTransaction(db, () => { + const rows = prepareCached(db, ` + SELECT local_id FROM messages + WHERE session_id = ? + AND local_id IS NOT NULL + AND scheduled_at IS NULL + AND invoked_at IS NULL + AND delivery_state = 'queued' + ORDER BY seq ASC + `).all(sessionId) as Array<{ local_id: string }> + if (rows.length === 0) return [] - prepareCached(db, ` - UPDATE messages - SET invoked_at = ? - WHERE session_id = ? - AND local_id IS NOT NULL - AND scheduled_at IS NULL - AND invoked_at IS NULL - AND delivery_state = 'queued' - `).run(invokedAt, sessionId) - return rows.map((row) => row.local_id) + prepareCached(db, ` + UPDATE messages + SET invoked_at = ? + WHERE session_id = ? + AND local_id IS NOT NULL + AND scheduled_at IS NULL + AND invoked_at IS NULL + AND delivery_state = 'queued' + `).run(invokedAt, sessionId) + const invokedRows = prepareCached(db, ` + SELECT id, session_id, content, created_at, seq, invoked_at + FROM messages + WHERE session_id = ? AND local_id IS NOT NULL AND scheduled_at IS NULL AND invoked_at = ? + `).all(sessionId, invokedAt) as DbMessageRow[] + for (const row of invokedRows) { + indexMessageContent(db, { + id: row.id, + sessionId: row.session_id, + content: decodeMessageContent(row.content), + seq: row.seq, + createdAt: row.created_at, + invokedAt: row.invoked_at + }) + } + return rows.map((row) => row.local_id) + }) } - /** * Reassign only uninvoked scheduled rows when an archived OpenCode session * is replaced by /clear. The transaction preserves ids/localIds so the normal @@ -1095,7 +1195,7 @@ export function mergeSessionMessages( db.exec('BEGIN') if (newMaxSeq > 0 && oldMaxSeq > 0) { - prepareCached(db, + prepareCached(db, 'UPDATE messages SET seq = seq + ? WHERE session_id = ?' ).run(oldMaxSeq, toSessionId) } @@ -1115,7 +1215,7 @@ export function mergeSessionMessages( // (markMessagesInvoked matches by local_id), so leaving invoked_at // NULL would strand the row in the queued floating bar forever. // Use COALESCE so an already-invoked row keeps its server timestamp. - prepareCached(db, + prepareCached(db, `UPDATE messages SET local_id = NULL, invoked_at = COALESCE(invoked_at, created_at) @@ -1148,6 +1248,7 @@ export function mergeSessionMessages( AND (read_revision < content_revision OR manual_unread = 1)`).run(fromSessionId) if (result.changes > 0) { + rebuildMessageContentSearchForSessions(db, [fromSessionId, toSessionId], true) prepareCached(db, ` UPDATE sessions SET last_user_message_at = MAX(COALESCE(last_user_message_at, 0), (SELECT last_user_message_at FROM sessions WHERE id = ?)) @@ -1193,6 +1294,14 @@ export function truncateMessagesFromLocalId( throw new Error(`Message not found for localId: ${localId}`) } + const deletedRows = prepareCached(db, ` + SELECT id FROM messages + WHERE session_id = ? + AND ( + COALESCE(invoked_at, created_at) > ? + OR (COALESCE(invoked_at, created_at) = ? AND seq >= ?) + ) + `).all(sessionId, target.position_at, target.position_at, target.seq) as Array<{ id: string }> const deleted = prepareCached(db, ` DELETE FROM messages WHERE session_id = ? @@ -1201,11 +1310,13 @@ export function truncateMessagesFromLocalId( OR (COALESCE(invoked_at, created_at) = ? AND seq >= ?) ) `).run(sessionId, target.position_at, target.position_at, target.seq) + invalidateMessageContentSearchInjectedTurnCache(db, [sessionId]) + for (const row of deletedRows) removeMessageContentSearchIndex(db, row.id) let inserted = 0 for (const message of replacement) { const now = Date.now() - const msgSeqRow = prepareCached(db, + const msgSeqRow = prepareCached(db, 'SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM messages WHERE session_id = ?' ).get(sessionId) as { nextSeq: number } const id = randomUUID() @@ -1224,6 +1335,14 @@ export function truncateMessagesFromLocalId( rowLocalId, invokedAt ) + indexMessageContent(db, { + id, + sessionId, + content: message.content, + seq: msgSeqRow.nextSeq, + createdAt, + invokedAt + }) recordUserMessage(db, sessionId, message.content, createdAt) inserted += 1 } diff --git a/hub/src/store/migration-v12.test.ts b/hub/src/store/migration-v12.test.ts index ee0e0376..3863f9e1 100644 --- a/hub/src/store/migration-v12.test.ts +++ b/hub/src/store/migration-v12.test.ts @@ -57,7 +57,8 @@ describe('Store V11→V12 migration: session_scratchlist table', () => { expect(sessions.map((r) => r.id)).toEqual(['s1']) } finally { store?.close() - rmSync(dir, { recursive: true, force: true }) + Bun.gc(true) + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } }) @@ -80,7 +81,8 @@ describe('Store V11→V12 migration: session_scratchlist table', () => { expect(scratchCols).toContain('entry_id') } finally { store?.close() - rmSync(dir, { recursive: true, force: true }) + Bun.gc(true) + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } }) @@ -99,7 +101,8 @@ describe('Store V11→V12 migration: session_scratchlist table', () => { } finally { store2?.close() store1?.close() - rmSync(dir, { recursive: true, force: true }) + Bun.gc(true) + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } }) @@ -225,7 +228,9 @@ describe('ScratchlistStore: CRUD through the typed-table wrapper', () => { function getColumns(store: Store, table: string): string[] { const db: Database = (store as unknown as { db: Database }).db - const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + const statement = db.prepare(`PRAGMA table_info(${table})`) + const rows = statement.all() as Array<{ name: string }> + statement.finalize() return rows.map((r) => r.name) } diff --git a/hub/src/store/migration-v13.test.ts b/hub/src/store/migration-v13.test.ts index b8c6c838..6c19f80d 100644 --- a/hub/src/store/migration-v13.test.ts +++ b/hub/src/store/migration-v13.test.ts @@ -10,7 +10,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { const store = new Store(':memory:') expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(30) + expect(getUserVersion(store)).toBe(31) store.close() }) @@ -34,7 +34,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(30) + expect(getUserVersion(store)).toBe(31) expect(store.messages.getMessageEpoch('session-1')).toBe(0) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { @@ -72,7 +72,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(30) + expect(getUserVersion(store)).toBe(31) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { store?.close() diff --git a/hub/src/store/migration-v15.test.ts b/hub/src/store/migration-v15.test.ts index 42f64ea8..5dc256e8 100644 --- a/hub/src/store/migration-v15.test.ts +++ b/hub/src/store/migration-v15.test.ts @@ -19,7 +19,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { expect(cols).toContain('attachments') expect(getColumns(store, 'usage_events')).toContain('last_input_tokens') expect(getColumns(store, 'usage_scan_state')).toContain('last_seq') - expect(getUserVersion(store)).toBe(30) + expect(getUserVersion(store)).toBe(31) store.close() }) @@ -40,7 +40,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { expect(cols).toContain('attachments') expect(getColumns(store, 'usage_events')).toContain('last_input_tokens') expect(getColumns(store, 'usage_scan_state')).toContain('last_seq') - expect(getUserVersion(store)).toBe(30) + expect(getUserVersion(store)).toBe(31) } finally { store?.close() rmSync(dir, { recursive: true, force: true }) @@ -60,7 +60,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { store2 = new Store(dbPath) const cols2 = getColumns(store2, 'session_scratchlist') expect(cols2).toEqual(cols1) - expect(getUserVersion(store2)).toBe(30) + expect(getUserVersion(store2)).toBe(31) } finally { store2?.close() store1?.close() diff --git a/hub/src/store/migration-v18.test.ts b/hub/src/store/migration-v18.test.ts index 5584ed30..d8ec3d0e 100644 --- a/hub/src/store/migration-v18.test.ts +++ b/hub/src/store/migration-v18.test.ts @@ -38,7 +38,7 @@ describe('Store V18->V19 migration: usage scan state', () => { const usageRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_events').get() as { count: number } expect(table?.name).toBe('usage_scan_state') - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) expect(usageRows.count).toBe(0) } finally { store?.close() diff --git a/hub/src/store/migration-v19.test.ts b/hub/src/store/migration-v19.test.ts index cf920011..80d910ff 100644 --- a/hub/src/store/migration-v19.test.ts +++ b/hub/src/store/migration-v19.test.ts @@ -37,7 +37,7 @@ describe('Store V19->current migration: usage re-index', () => { const scanRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_scan_state').get() as { count: number } const usageRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_events').get() as { count: number } - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) expect(scanRows.count).toBe(0) expect(usageRows.count).toBe(0) } finally { diff --git a/hub/src/store/migration-v20.test.ts b/hub/src/store/migration-v20.test.ts index 106472dc..beea8d44 100644 --- a/hub/src/store/migration-v20.test.ts +++ b/hub/src/store/migration-v20.test.ts @@ -43,7 +43,7 @@ describe('Store V20->V21 migration: usage semantics re-index', () => { store = new Store(dbPath) const internalDb = (store as unknown as { db: Database }).db const count = (table: string): number => (internalDb.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count - expect((internalDb.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(30) + expect((internalDb.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(31) expect(count('usage_events')).toBe(0) expect(count('usage_scan_state')).toBe(0) expect(store.messages.getMessages(session.id)).toHaveLength(1) diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 6ee41c8e..033654dd 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -4,16 +4,18 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Store } from './index' +import { MAX_INDEXED_MESSAGE_CHARACTERS } from './messageContentSearch' const tempDirs: string[] = [] afterEach(() => { for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) + Bun.gc(true) + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } }) -describe('schema migration v22 to v26', () => { +describe('schema migrations through v31', () => { it('adds events and event_links tables to a V22 database', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v23-')) tempDirs.push(dir) @@ -29,20 +31,217 @@ describe('schema migration v22 to v26', () => { legacy.close() const migrated = new Store(dbPath) - const internalDb = (migrated as unknown as { db: Database }).db - const events = internalDb.prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events'" - ).get() as { name: string } | null - const links = internalDb.prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'event_links'" - ).get() as { name: string } | null - const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + try { + const internalDb = (migrated as unknown as { db: Database }).db + const events = internalDb.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events'" + ).get() as { name: string } | null + const links = internalDb.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'event_links'" + ).get() as { name: string } | null + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } - expect(events?.name).toBe('events') - expect(links?.name).toBe('event_links') - const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> - expect(columns.map((column) => column.name)).toContain('delivery_state') - expect(version.user_version).toBe(30) - migrated.close() + expect(events?.name).toBe('events') + expect(links?.name).toBe('event_links') + const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> + expect(columns.map((column) => column.name)).toContain('delivery_state') + expect(version.user_version).toBe(31) + } finally { + migrated.close() + } + }) + + it('creates and backfills the message content search index after the v26 schema', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v26-')) + tempDirs.push(dir) + const dbPath = join(dir, 'hapi.db') + + const initial = new Store(dbPath) + const session = initial.sessions.getOrCreateSession('migration-search', { path: '/tmp/migration-search' }, null, 'default') + initial.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'backfill this message' } + }) + initial.close() + + const legacy = new Database(dbPath) + legacy.exec(` + DROP TABLE IF EXISTS message_content_search; + DROP TABLE IF EXISTS message_content_search_lookup; + DROP TABLE IF EXISTS message_content_search_short; + PRAGMA user_version = 26; + `) + legacy.close() + + const migrated = new Store(dbPath) + try { + expect(migrated.messages.searchContent('backfill this', 'default')[0]?.sessionId).toBe(session.id) + const internalDb = (migrated as unknown as { db: Database }).db + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + expect(version.user_version).toBe(31) + } finally { + migrated.close() + } + }) + + it('adds the indexed message lookup to an existing v27 search schema', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v27-')) + tempDirs.push(dir) + const dbPath = join(dir, 'hapi.db') + + const initial = new Store(dbPath) + const session = initial.sessions.getOrCreateSession('migration-lookup', { path: '/tmp/migration-lookup' }, null, 'default') + initial.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'backfill the lookup table' } + }) + initial.close() + + const legacy = new Database(dbPath) + legacy.exec(` + DROP TABLE IF EXISTS message_content_search_lookup; + DROP TABLE IF EXISTS message_content_search_short; + PRAGMA user_version = 27; + `) + legacy.close() + + const migrated = new Store(dbPath) + try { + expect(migrated.messages.searchContent('lookup table', 'default')[0]?.sessionId).toBe(session.id) + const internalDb = (migrated as unknown as { db: Database }).db + const lookup = internalDb.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message_content_search_lookup'" + ).get() as { name: string } | null + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + expect(lookup?.name).toBe('message_content_search_lookup') + expect(version.user_version).toBe(31) + } finally { + migrated.close() + } + }) + + it('backfills indexed short-query grams for an existing v28 search schema', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v28-')) + tempDirs.push(dir) + const dbPath = join(dir, 'hapi.db') + + const initial = new Store(dbPath) + const session = initial.sessions.getOrCreateSession('migration-short-query', { path: '/tmp/migration-short-query' }, null, 'default') + const text = `你好,短查询索引 ${Array.from({ length: MAX_INDEXED_MESSAGE_CHARACTERS + 1024 }, (_, index) => + String.fromCodePoint(0x1000 + index) + ).join('')} tail-migration-needle` + const message = initial.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text } + }) + initial.close() + + const legacy = new Database(dbPath) + legacy.prepare( + 'UPDATE message_content_search SET searchable_text = ? WHERE message_id = ?' + ).run(text, message.id) + legacy.exec(` + DROP TABLE IF EXISTS message_content_search_lookup; + CREATE TABLE message_content_search_lookup ( + search_rowid INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE + ); + INSERT INTO message_content_search_lookup (search_rowid, message_id) + SELECT rowid, message_id FROM message_content_search; + DROP TABLE IF EXISTS message_content_search_short; + PRAGMA user_version = 28; + `) + legacy.close() + + const migrated = new Store(dbPath) + try { + expect(migrated.messages.searchContent('你好', 'default')[0]?.sessionId).toBe(session.id) + expect(migrated.messages.searchContent('tail-migration-needle', 'default')[0]?.sessionId) + .toBe(session.id) + const internalDb = (migrated as unknown as { db: Database }).db + const shortIndex = internalDb.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message_content_search_short'" + ).get() as { name: string } | null + const lookupColumns = internalDb.prepare( + 'PRAGMA table_info(message_content_search_lookup)' + ).all() as Array<{ name: string }> + const target = internalDb.prepare( + 'SELECT target_message_id FROM message_content_search_lookup WHERE message_id = ?' + ).get(message.id) as { target_message_id: string } | undefined + const indexed = internalDb.prepare( + 'SELECT searchable_text FROM message_content_search WHERE message_id = ?' + ).get(message.id) as { searchable_text: string } | undefined + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + expect(shortIndex?.name).toBe('message_content_search_short') + expect(lookupColumns.map((column) => column.name)).toContain('target_message_id') + expect(target?.target_message_id).toBe(message.id) + expect(indexed?.searchable_text.length).toBeLessThanOrEqual(MAX_INDEXED_MESSAGE_CHARACTERS) + expect(indexed?.searchable_text).toContain('tail-migration-needle') + expect(version.user_version).toBe(31) + } finally { + migrated.close() + } + }) + + it('rebuilds a v29 content index with the larger cap and truncation metadata', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v29-content-search-')) + tempDirs.push(dir) + const dbPath = join(dir, 'hapi.db') + + const initial = new Store(dbPath) + const session = initial.sessions.getOrCreateSession( + 'migration-content-cap', + { path: '/tmp/migration-content-cap' }, + null, + 'default' + ) + const text = `${'a'.repeat(16_000)} v30-middle-needle ${'b'.repeat(18_000)}` + const message = initial.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text } + }) + initial.close() + + // Simulate the v29 derived index without deleting any user data. The + // old 16 KiB head/tail window misses the marker near its midpoint. + const legacy = new Database(dbPath) + const legacyIndexedText = `${text.slice(0, 8_191)} ${text.slice(-8_192)}` + legacy.prepare( + 'UPDATE message_content_search SET searchable_text = ? WHERE message_id = ?' + ).run(legacyIndexedText, message.id) + legacy.prepare( + `UPDATE message_content_search_lookup + SET is_truncated = 0, short_index_truncated = 0 + WHERE target_message_id = ?` + ).run(message.id) + legacy.exec('PRAGMA user_version = 29') + legacy.close() + + const migrated = new Store(dbPath) + try { + expect(migrated.messages.searchContent('v30-middle-needle', 'default')) + .toMatchObject([{ sessionId: session.id, truncated: true }]) + + const internalDb = (migrated as unknown as { db: Database }).db + const indexed = internalDb.prepare(` + SELECT searchable_text + FROM message_content_search + WHERE message_id = ? + `).get(message.id) as { searchable_text: string } | undefined + const metadata = internalDb.prepare(` + SELECT is_truncated, short_index_truncated + FROM message_content_search_lookup + WHERE target_message_id = ? + `).get(message.id) as { is_truncated: number; short_index_truncated: number } | undefined + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + + expect(indexed?.searchable_text.length).toBeLessThanOrEqual(MAX_INDEXED_MESSAGE_CHARACTERS) + expect(indexed?.searchable_text).toContain('v30-middle-needle') + expect(metadata?.is_truncated).toBe(1) + expect(metadata?.short_index_truncated).toBe(1) + expect(version.user_version).toBe(31) + } finally { + migrated.close() + } }) }) diff --git a/hub/src/store/migration-v24.test.ts b/hub/src/store/migration-v24.test.ts index be739557..f6ddf6e7 100644 --- a/hub/src/store/migration-v24.test.ts +++ b/hub/src/store/migration-v24.test.ts @@ -13,7 +13,7 @@ afterEach(() => { } }) -describe('schema migration v23 to v26', () => { +describe('schema migrations through v31', () => { it('adds fcm_devices.push_key to a V23 database and keeps existing rows', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v24-')) tempDirs.push(dir) @@ -41,7 +41,7 @@ describe('schema migration v23 to v26', () => { expect(columns.some((col) => col.name === 'push_key')).toBe(true) const messageColumns = messageColumnsStatement.all() as Array<{ name: string }> expect(messageColumns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) // Existing Android rows survive with a NULL push key. const devices = migrated.fcm.getDevicesByNamespace('default') @@ -91,7 +91,7 @@ describe('schema migration v23 to v26', () => { const version = versionStatement.get() as { user_version: number } const indexes = indexesStatement.all() as Array<{ name: string }> expect(columns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) expect(indexes.map((index) => index.name)).toEqual(expect.arrayContaining([ 'idx_messages_immediate_queued', 'idx_messages_future_scheduled_queued' diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index 07caa68f..66d682cb 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -41,7 +41,7 @@ describe('schema migration v25 to v26', () => { ORDER BY seq ASC `).all('session-id') as Array<{ detail: string }> - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) expect(plan.some((row) => row.detail.includes('idx_messages_immediate_queued'))).toBe(true) migrated.close() }) diff --git a/hub/src/store/migration-v26.test.ts b/hub/src/store/migration-v26.test.ts index abd79e76..02e99a2b 100644 --- a/hub/src/store/migration-v26.test.ts +++ b/hub/src/store/migration-v26.test.ts @@ -59,7 +59,7 @@ describe('schema migration v26 to v27', () => { const minPlan = minPlanStatement.all('a', 'b', 123) as Array<{ detail: string }> minPlanStatement.finalize() - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) expect(countPlan.some((row) => row.detail.includes('idx_messages_future_scheduled_queued'))).toBe(true) expect(minPlan.some((row) => row.detail.includes('idx_messages_future_scheduled_queued'))).toBe(true) migrated.close() diff --git a/hub/src/store/migration-v28.test.ts b/hub/src/store/migration-v28.test.ts index e1b779dc..96694ea8 100644 --- a/hub/src/store/migration-v28.test.ts +++ b/hub/src/store/migration-v28.test.ts @@ -32,7 +32,7 @@ describe('schema migration v27 to v28', () => { const migrated = new Store(dbPath) const internalDb = (migrated as unknown as { db: Database }).db const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } - expect(version.user_version).toBe(30) + expect(version.user_version).toBe(31) expect(migrated.turns.open(session.id, 'default', 1_000, 'agent')).toBe(true) expect(migrated.turns.open(session.id, 'default', 2_000, 'agent')).toBe(false) diff --git a/hub/src/store/migration-v30.test.ts b/hub/src/store/migration-v30.test.ts index 27c19de5..c5b4c736 100644 --- a/hub/src/store/migration-v30.test.ts +++ b/hub/src/store/migration-v30.test.ts @@ -30,5 +30,70 @@ it('upgrades timestamp-era history to a read baseline exactly once', () => { expect(restarted.sessions.getSession(id)?.contentRevision).toBe(1) expect(restarted.sessions.getSession(id)?.readState?.readRevision).toBe(0) restarted.close() - } finally { rmSync(dir, { recursive: true, force: true }) } + } finally { Bun.gc(true); rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } +}) +it('creates and backfills search tables when upgrading a base v30 database', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-search-v30-migration-')) + try { + const path = join(dir, 'hub.db') + const before = new Store(path) + const session = before.sessions.getOrCreateSession('search-v30', { path: '/tmp/search-v30' }, null, 'default') + before.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'upgrade searchable text' } }) + before.close() + const legacy = new Database(path) + legacy.exec(` + DROP TABLE message_content_search_short; + DROP TABLE message_content_search_lookup; + DROP TABLE message_content_search; + PRAGMA user_version = 30; + `) + legacy.close() + const migrated = new Store(path) + try { + const internalDb = (migrated as unknown as { db: Database }).db + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + expect(version.user_version).toBe(31) + expect(migrated.messages.searchContent('searchable', 'default')[0]?.sessionId).toBe(session.id) + } finally { migrated.close() } + } finally { Bun.gc(true); rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } +}) + +it('upgrades a genuine v26 schema without read-state or search table shortcuts', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-v26-current-migration-')) + try { + const path = join(dir, 'hub.db') + const before = new Store(path) + const session = before.sessions.getOrCreateSession('v26-current', { path: '/tmp/v26-current' }, null, 'default') + before.close() + const legacy = new Database(path) + legacy.exec(` + DROP TABLE session_turns; + DROP TABLE message_content_search_short; + DROP TABLE message_content_search_lookup; + DROP TABLE message_content_search; + ALTER TABLE messages DROP COLUMN content_revision; + ALTER TABLE sessions DROP COLUMN manual_unread; + ALTER TABLE sessions DROP COLUMN read_state_version; + ALTER TABLE sessions DROP COLUMN read_revision; + ALTER TABLE sessions DROP COLUMN content_revision; + ALTER TABLE sessions DROP COLUMN last_user_message_at; + PRAGMA user_version = 26; + `) + legacy.close() + const migrated = new Store(path) + try { + const internalDb = (migrated as unknown as { db: Database }).db + const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } + expect(version.user_version).toBe(31) + expect(migrated.sessions.getSession(session.id)?.readState).toEqual({ readRevision: 0, manualUnread: false, version: 0 }) + migrated.messages.addMessage(session.id, { + role: 'user', content: { type: 'text', text: 'genuine v26 searchable text' } + }) + const read = migrated.sessions.updateReadState(session.id, 'default', { + type: 'unread', expectedVersion: 0 + }) + expect(read?.readState).toEqual({ readRevision: 0, manualUnread: true, version: 1 }) + expect(migrated.messages.searchContent('genuine v26', 'default')[0]?.sessionId).toBe(session.id) + } finally { migrated.close() } + } finally { Bun.gc(true); rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } }) diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index b9ad56f1..75866bca 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -7,6 +7,11 @@ import { preserveSessionTitle } from './sessionTitleLock' import type { StoredSession, VersionedUpdateResult } from './types' import { safeJsonParse } from './json' import { updateVersionedField } from './versionedUpdates' +import { removeMessageContentSearchForSession } from './messageContentSearch' + +function runInTransaction(db: Database, operation: () => T): T { + return db.inTransaction ? operation() : db.transaction(operation)() +} // Carry-forward fields that the hub preserves across any metadata // replacement when the incoming write omits them. @@ -239,7 +244,7 @@ export function getOrCreateSession( modelReasoningEffort?: string, requestedId?: string ): StoredSession { - const existing = prepareCached(db, + const existing = prepareCached(db, 'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1' ).get(tag, namespace) as DbSessionRow | undefined @@ -918,7 +923,7 @@ export function getSession(db: Database, id: string): StoredSession | null { } export function getSessionByNamespace(db: Database, id: string, namespace: string): StoredSession | null { - const row = prepareCached(db, + const row = prepareCached(db, 'SELECT * FROM sessions WHERE id = ? AND namespace = ?' ).get(id, namespace) as DbSessionRow | undefined return row ? toStoredSession(row) : null @@ -930,21 +935,28 @@ export function getSessions(db: Database): StoredSession[] { } export function getSessionsByNamespace(db: Database, namespace: string): StoredSession[] { - const rows = prepareCached(db, + const rows = prepareCached(db, 'SELECT * FROM sessions WHERE namespace = ? ORDER BY updated_at DESC' ).all(namespace) as DbSessionRow[] return rows.map(toStoredSession) } export function deleteSession(db: Database, id: string, namespace: string): boolean { - const result = prepareCached(db, - 'DELETE FROM sessions WHERE id = ? AND namespace = ?' - ).run(id, namespace) - if (result.changes > 0) { - // Per-socket access memos stamp the epoch they were filled under; - // bumping makes their next event re-resolve instead of serving a - // grant for a row that no longer exists. - bumpSessionDeletionEpoch() - } - return result.changes > 0 + return runInTransaction(db, () => { + const existing = prepareCached(db, + 'SELECT 1 FROM sessions WHERE id = ? AND namespace = ?' + ).get(id, namespace) + if (!existing) return false + removeMessageContentSearchForSession(db, id) + const result = prepareCached(db, + 'DELETE FROM sessions WHERE id = ? AND namespace = ?' + ).run(id, namespace) + if (result.changes > 0) { + // Per-socket access memos stamp the epoch they were filled under; + // bumping makes their next event re-resolve instead of serving a + // grant for a row that no longer exists. + bumpSessionDeletionEpoch() + } + return result.changes > 0 + }) } diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index b90c5bc9..9f1ffb00 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -390,6 +390,70 @@ describe('MessageService message pagination', () => { expect(third.id).toBeDefined() }) + it('returns a bounded context around a searched message without scanning the full transcript into the response', () => { + const store = makeStore() + const session = makeSession(store, 'page-context') + const messages = Array.from({ length: 105 }, (_, index) => { + const message = store.messages.addMessage(session.id, `message-${index}`, `local-${index}`) + store.messages.markMessagesInvoked(session.id, [`local-${index}`], (index + 1) * 1_000) + return message + }) + const target = messages[54]! + + const context = makeService(store).getMessageContext(session.id, target.id) + + expect(context?.targetMessageId).toBe(target.id) + expect(context?.messages).toHaveLength(41) + expect(context?.messages[0]?.id).toBe(messages[34]!.id) + expect(context?.messages.at(-1)?.id).toBe(messages[74]!.id) + expect(context?.messages.map((message) => message.id)).toContain(target.id) + expect(context?.page).toMatchObject({ + epoch: 0, + nextBeforeAt: 35_000, + nextBeforeSeq: messages[34]!.seq, + snapshotHeadAt: 105_000, + snapshotHeadSeq: messages[104]!.seq, + hasMore: true + }) + + const otherSession = makeSession(store, 'page-context-other') + expect(makeService(store).getMessageContext(otherSession.id, target.id)).toBeNull() + }) + + it('includes pending local messages without changing the context pagination cursor', () => { + const store = makeStore() + const session = makeSession(store, 'page-context-pending') + const messages = Array.from({ length: 105 }, (_, index) => { + const message = store.messages.addMessage(session.id, `message-${index}`, `local-${index}`) + store.messages.markMessagesInvoked(session.id, [`local-${index}`], (index + 1) * 1_000) + return message + }) + const target = messages[54]! + const immediate = store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'pending immediate' } }, + 'pending-immediate' + ) + const scheduled = store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'pending scheduled' } }, + 'pending-scheduled', + Date.now() + 60_000 + ) + + const context = makeService(store).getMessageContext(session.id, target.id) + + expect(context?.messages.map((message) => message.id)).toEqual(expect.arrayContaining([ + immediate.id, + scheduled.id, + target.id + ])) + expect(context?.page).toMatchObject({ + nextBeforeAt: 35_000, + nextBeforeSeq: messages[34]!.seq + }) + }) + it('breaks equal timestamp ties by seq', () => { const store = makeStore() const session = makeSession(store, 'page-tie') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index c877cb27..0bed0c1f 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -12,7 +12,12 @@ import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import { isObject } from '@hapi/protocol' -import type { MessageDeliveryMode, MessagesResponse, QueuedStateResponse } from '@hapi/protocol/apiTypes' +import type { + MessageContextResponse, + MessageDeliveryMode, + MessagesResponse, + QueuedStateResponse +} from '@hapi/protocol/apiTypes' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' import type { Store, CancelQueuedMessageResult } from '../store' @@ -21,6 +26,12 @@ import { EventPublisher } from './eventPublisher' type StoredMessageForDelivery = ReturnType[number] type MessagePosition = { at: number; seq: number } +// Search jumps only need a compact render window around the hit. Keeping this +// bounded to 41 rows avoids transferring large historical attachments and +// tool payloads that are unrelated to the selected message; normal scrolling +// can request older pages when the user needs more context. +const MESSAGE_CONTEXT_BEFORE_LIMIT = 20 +const MESSAGE_CONTEXT_AFTER_LIMIT = 20 /** * localId prefix for peer submissions (`hapi ping-peer` / MCP `ping_peer`). * Excludes peer rows from the session-end synthetic sweep so their invocation @@ -201,6 +212,49 @@ export class MessageService { return toVisibleDecryptedMessages(stored) } + getMessageContext(sessionId: string, messageId: string): MessageContextResponse | null { + const target = this.store.messages.getMessageById(sessionId, messageId) + if (!target) { + return null + } + + const targetPosition = messagePosition(target) + const before = this.store.messages.getMessagesByPosition( + sessionId, + MESSAGE_CONTEXT_BEFORE_LIMIT, + targetPosition + ) + const snapshotHead = this.store.messages.getNewestMessagePosition(sessionId) + const after = this.store.messages.getMessagesAfterPosition( + sessionId, + MESSAGE_CONTEXT_AFTER_LIMIT, + targetPosition, + snapshotHead ?? undefined + ) + // Keep pending local prompts visible when a cold client opens a + // historical result. They are intentionally out-of-band: pending + // rows must not change the bounded context's pagination cursor. + const queued = this.store.messages.getUninvokedLocalMessages(sessionId) + const contextRows = [...new Map( + [...before, target, ...after, ...queued].map((message) => [message.id, message]) + ).values()].sort((a, b) => comparePosition(messagePosition(a), messagePosition(b))) + const oldest = before[0] ?? target + const oldestPosition = messagePosition(oldest) + + return { + messages: toVisibleDecryptedMessages(contextRows), + targetMessageId: messageId, + page: { + epoch: this.store.messages.getMessageEpoch(sessionId), + nextBeforeSeq: oldestPosition.seq, + nextBeforeAt: oldestPosition.at, + snapshotHeadSeq: snapshotHead?.seq ?? null, + snapshotHeadAt: snapshotHead?.at ?? null, + hasMore: this.store.messages.getMessagesByPosition(sessionId, 1, oldestPosition).length > 0 + } + } + } + getQueuedState(sessionId: string, localIds: string[]): QueuedStateResponse { const states = this.store.messages.getLocalMessageStates(sessionId, localIds) return { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 7761b58f..c0346efa 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -12,7 +12,7 @@ import { cliBinaryUpdatedOnDisk, isMachineCapabilitySkewed, } from '@hapi/protocol/runnerCapabilities' -import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, RewindConversationErrorCode, SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageContextResponse, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, RewindConversationErrorCode, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { ImplementCodexPlanResult } from '@hapi/protocol/apiTypes' import type { AgentFlavor, BackgroundTask, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' @@ -20,6 +20,10 @@ import { hasConversationMessageContent, unwrapRoleWrappedRecordEnvelope } from ' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' import type { Store, CancelQueuedMessageResult } from '../store' +import type { + MessageContentSearchMatch, + SessionMessageContentSearchResult +} from '../store/messageContentSearch' import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport' import type { RpcRegistry } from '../socket/rpcRegistry' import { clearAgentTerminalBuffer } from '../socket/agentTerminalBuffer' @@ -447,6 +451,32 @@ export class SyncEngine { return this.sessionCache.getSessionsByNamespace(namespace) } + searchSessionContent( + query: string, + namespace: string, + limit: number = 50, + sessionIds?: readonly string[] + ): MessageContentSearchMatch[] { + return this.store.messages.searchContent(query, namespace, limit, sessionIds) + } + + searchSessionContentMatches( + query: string, + namespace: string, + sessionId: string, + limit: number = 500 + ): SessionMessageContentSearchResult { + return this.store.messages.searchContentInSession(query, namespace, sessionId, limit) + } + + hasPotentiallyIncompleteSessionContent( + namespace: string, + query: string, + sessionIds?: readonly string[] + ): boolean { + return this.store.messages.hasPotentiallyIncompleteContent(namespace, query, sessionIds) + } + setSessionPinned(sessionId: string, pinned: boolean): void { this.sessionCache.setSessionPinned(sessionId, pinned) } @@ -528,6 +558,10 @@ export class SyncEngine { return this.messageService.getMessagesPage(sessionId, options) } + getMessageContext(sessionId: string, messageId: string): MessageContextResponse | null { + return this.messageService.getMessageContext(sessionId, messageId) + } + getQueuedState(sessionId: string, localIds: string[]): QueuedStateResponse { return this.messageService.getQueuedState(sessionId, localIds) } diff --git a/hub/src/web/routes/messages.test.ts b/hub/src/web/routes/messages.test.ts index 331b51bd..9982c1b1 100644 --- a/hub/src/web/routes/messages.test.ts +++ b/hub/src/web/routes/messages.test.ts @@ -12,6 +12,7 @@ import type { WebAppEnv } from '../middleware/auth' import { createMessagesRoutes } from './messages' type GetMessagesPage = SyncEngine['getMessagesPage'] +type GetMessageContext = SyncEngine['getMessageContext'] // TS note: engine is cast to unknown→SyncEngine so test helpers don't need to // satisfy the full SyncEngine shape (only the subset the route under test uses). @@ -24,6 +25,7 @@ function createApp(opts: { active?: boolean sendMessage?: (sessionId: string, payload: unknown) => Promise getMessagesPage?: GetMessagesPage + getMessageContext?: GetMessageContext getQueuedState?: (sessionId: string, localIds: string[]) => { queuedLocalIds: string[] indeterminateLocalIds: string[] @@ -62,6 +64,7 @@ function createApp(opts: { hasMore: false } })) + const getMessageContext = opts.getMessageContext ?? (() => null) const engine = { resolveSessionAccess: () => ({ @@ -74,6 +77,7 @@ function createApp(opts: { cancelQueuedMessage: async () => ({ status: 'cancelled' }), steerQueuedMessage: opts.steerQueuedMessage ?? (async () => ({ status: 'failed', error: 'Steer failed', localId: null })), getMessagesPage, + getMessageContext, } as unknown as SyncEngine const app = new Hono() @@ -196,6 +200,45 @@ describe('GET /api/sessions/:id/messages', () => { }) }) +describe('GET /api/sessions/:id/messages/:messageId/context', () => { + it('returns a bounded message context for an authorized session', async () => { + const context = { + messages: [], + targetMessageId: 'message-target', + page: { + epoch: 3, + nextBeforeSeq: 10, + nextBeforeAt: 1_000, + snapshotHeadSeq: 20, + snapshotHeadAt: 2_000, + hasMore: true + } + } + const calls: Array<{ sessionId: string; messageId: string }> = [] + const { app } = createApp({ + getMessageContext: (sessionId, messageId) => { + calls.push({ sessionId, messageId }) + return context + } + }) + + const response = await app.request('/api/sessions/session-1/messages/message-target/context') + + expect(response.status).toBe(200) + expect(calls).toEqual([{ sessionId: 'session-1', messageId: 'message-target' }]) + expect(await response.json()).toEqual(context) + }) + + it('returns null when the target message no longer exists', async () => { + const { app } = createApp({ getMessageContext: () => null }) + + const response = await app.request('/api/sessions/session-1/messages/missing/context') + + expect(response.status).toBe(200) + expect(await response.json()).toBeNull() + }) +}) + // --------------------------------------------------------------------------- // #2 server-side scheduledAt upper bound // --------------------------------------------------------------------------- diff --git a/hub/src/web/routes/messages.ts b/hub/src/web/routes/messages.ts index 92e46e22..9bb801f5 100644 --- a/hub/src/web/routes/messages.ts +++ b/hub/src/web/routes/messages.ts @@ -43,6 +43,20 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho })) }) + app.get('/sessions/:id/messages/:messageId/context', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + return c.json(engine.getMessageContext(sessionResult.sessionId, c.req.param('messageId'))) + }) + app.delete('/sessions/:id/messages/:messageId', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 78bc63cc..f7f6697d 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -1921,6 +1921,238 @@ describe('sessions routes', () => { expect(unlimitedBody.sessions).toHaveLength(3) }) + it('searches message content only through the explicit content endpoint', async () => { + const session = createSession({ id: 'content-session' }) + const engine = { + getSessionsByNamespace: () => [session], + getFutureScheduledMessageCounts: (ids: string[]) => new Map(ids.map((id) => [id, 0])), + getNextScheduledAtBySessionIds: (ids: string[]) => new Map(ids.map((id) => [id, null])), + hasPotentiallyIncompleteSessionContent: (namespace: string, query: string, sessionIds?: readonly string[]) => { + expect(namespace).toBe('default') + expect(query).toBe('needle') + expect(sessionIds).toEqual([session.id]) + return true + }, + searchSessionContent: (query: string, namespace: string, limit: number, sessionIds?: readonly string[]) => { + expect(query).toBe('needle') + expect(namespace).toBe('default') + expect(limit).toBe(7) + expect(sessionIds).toEqual([session.id]) + return [{ + sessionId: session.id, + messageId: 'message-1', + role: 'assistant' as const, + seq: 3, + createdAt: 123, + snippet: 'context needle', + truncated: true + }] + } + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/sessions/content-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'needle', + limit: 7, + sessionIds: [session.id, 'unknown-session', session.id] + }) + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + hasPotentiallyIncompleteResults: true, + results: [{ + session: expect.objectContaining({ id: session.id }), + match: { + messageId: 'message-1', + role: 'assistant', + seq: 3, + createdAt: 123, + snippet: 'context needle', + truncated: true + } + }] + }) + }) + + it('allows scoped content-search requests with more than 500 session IDs', async () => { + const sessions = Array.from({ length: 501 }, (_, index) => createSession({ id: `session-${index}` })) + let receivedSessionIds: readonly string[] | undefined + const engine = { + getSessionsByNamespace: () => sessions, + getFutureScheduledMessageCounts: (ids: string[]) => new Map(ids.map((id) => [id, 0])), + getNextScheduledAtBySessionIds: (ids: string[]) => new Map(ids.map((id) => [id, null])), + hasPotentiallyIncompleteSessionContent: () => false, + searchSessionContent: (_query: string, _namespace: string, _limit: number, sessionIds?: readonly string[]) => { + receivedSessionIds = sessionIds + return [] + } + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/sessions/content-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'needle', + sessionIds: Array.from({ length: 501 }, (_, index) => `session-${index}`) + }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ results: [], hasPotentiallyIncompleteResults: false }) + expect(receivedSessionIds).toEqual(sessions.map((session) => session.id)) + }) + + it('rejects an oversized content-search body before JSON parsing', async () => { + const engine = { + getSessionsByNamespace: () => [], + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map(), + searchSessionContent: () => [] + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/sessions/content-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'x'.repeat(300 * 1024) + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ error: 'Content search request too large' }) + }) + + it('rejects overlong content-search queries consistently across endpoints', async () => { + const session = createSession({ id: 'overlong-content-query-session' }) + const engine = { + getSessionsByNamespace: () => [session], + resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + searchSessionContent: () => { + throw new Error('must reject before searching') + }, + searchSessionContentMatches: () => { + throw new Error('must reject before searching') + }, + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map() + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine as SyncEngine)) + + const query = 'x'.repeat(201) + const expected = { error: 'Content search query too long' } + const postResponse = await app.request('/api/sessions/content-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }) + const getResponse = await app.request(`/api/sessions/content-search?query=${query}`) + const sessionResponse = await app.request( + `/api/sessions/${session.id}/content-search?query=${query}` + ) + + expect(postResponse.status).toBe(400) + expect(await postResponse.json()).toEqual(expected) + expect(getResponse.status).toBe(400) + expect(await getResponse.json()).toEqual(expected) + expect(sessionResponse.status).toBe(400) + expect(await sessionResponse.json()).toEqual(expected) + }) + + it('lists all content matches for an opened session without exposing its session id', async () => { + const session = createSession({ id: 'content-session-matches' }) + const engine = { + resolveSessionAccess: (sessionId: string, namespace: string) => { + expect(sessionId).toBe(session.id) + expect(namespace).toBe('default') + return { ok: true as const, sessionId, session } + }, + searchSessionContentMatches: (query: string, namespace: string, sessionId: string, limit: number) => { + expect(query).toBe('needle') + expect(namespace).toBe('default') + expect(sessionId).toBe(session.id) + expect(limit).toBe(12) + return { + total: 2, + matches: [ + { + sessionId, + messageId: 'message-new', + role: 'assistant' as const, + seq: 8, + createdAt: 456, + snippet: 'new needle', + truncated: false + }, + { + sessionId, + messageId: 'message-old', + role: 'user' as const, + seq: 3, + createdAt: 123, + snippet: 'old needle', + truncated: false + } + ], + hasPotentiallyIncompleteResults: false + } + } + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine as SyncEngine)) + + const response = await app.request(`/api/sessions/${session.id}/content-search?query=needle&limit=12`) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + matches: [ + { + messageId: 'message-new', + role: 'assistant', + seq: 8, + createdAt: 456, + snippet: 'new needle', + truncated: false + }, + { + messageId: 'message-old', + role: 'user', + seq: 3, + createdAt: 123, + snippet: 'old needle', + truncated: false + } + ], + total: 2, + hasPotentiallyIncompleteResults: false + }) + }) + it('order=updatedAt truncates newest-first including inactive peers', async () => { const sessions = [ createSession({ id: 'old-active', active: true, updatedAt: 10 }), diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 6883e67b..db70c5ed 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -31,16 +31,25 @@ import { import { RPC_METHODS, RPC_TARGET_MISSING_ERROR_CODE } from '@hapi/protocol/rpcMethods' import type { SlashCommand } from '@hapi/protocol/apiTypes' import { Hono, type Context } from 'hono' +import { bodyLimit } from 'hono/body-limit' import type { SyncEngine, Session } from '../../sync/syncEngine' import { RpcTargetMissingError } from '../../sync/rpcGateway' import type { WebAppEnv } from '../middleware/auth' import { loadScratchlistAttachmentLimitsFromEnv } from '../../config/scratchlistAttachmentLimits' import { validateScratchlistAttachmentsForWrite, scratchlistSessionBytesBeforeForPut } from '../../scratchlistAttachments/validate' import { TitleSuggestionError } from '../../sync/titleSuggestion' +import { + MAX_CONTENT_SEARCH_SESSION_SCOPE_BYTES, + serializeContentSearchSessionIds +} from '../../store/messageContentSearch' import { ArchiveCleanupError } from '../../sync/archiveCleanupError' import { requireSessionFromParam, requireSyncEngine } from './guards' const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +// Leave room for the query, limit, and JSON envelope; the serialized session +// scope itself remains bounded by MAX_CONTENT_SEARCH_SESSION_SCOPE_BYTES. +const MAX_CONTENT_SEARCH_REQUEST_BYTES = MAX_CONTENT_SEARCH_SESSION_SCOPE_BYTES + 4 * 1024 +const MAX_CONTENT_SEARCH_QUERY_CHARACTERS = 200 function commandsFromMetadataSlashCommands(names: readonly string[] | undefined): SlashCommand[] { if (!names?.length) { @@ -115,6 +124,177 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ sessions }) }) + const respondToSessionContentSearch = ( + c: Context, + engine: SyncEngine, + query: string, + limit: number, + sessionIds?: readonly string[] + ) => { + if (!query) return c.json({ results: [], hasPotentiallyIncompleteResults: false }) + if (Array.from(query).length > MAX_CONTENT_SEARCH_QUERY_CHARACTERS) { + return c.json({ error: 'Content search query too long' }, 400) + } + + const namespace = c.get('namespace') + const sessionsById = new Map( + engine.getSessionsByNamespace(namespace).map((session) => [session.id, session]) + ) + const normalizedSessionIds = sessionIds === undefined + ? undefined + : [...new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))] + const scopedSessionIds = normalizedSessionIds?.filter((sessionId) => sessionsById.has(sessionId)) + if (scopedSessionIds && serializeContentSearchSessionIds(scopedSessionIds) === null) { + return c.json({ error: 'sessionIds too large' }, 400) + } + const matches = engine.searchSessionContent(query, namespace, limit, scopedSessionIds) + const hasPotentiallyIncompleteResults = engine.hasPotentiallyIncompleteSessionContent( + namespace, + query, + scopedSessionIds + ) + const matchedSessionIds = matches.map((match) => match.sessionId) + const scheduledCounts = engine.getFutureScheduledMessageCounts(matchedSessionIds) + const nextScheduledAt = engine.getNextScheduledAtBySessionIds(matchedSessionIds) + const results = matches + .flatMap((match) => { + const session = sessionsById.get(match.sessionId) + if (!session) return [] + const summary = toSessionSummary(session) + return [{ + session: { + ...summary, + futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0, + nextScheduledAt: nextScheduledAt.get(session.id) ?? null + }, + match: { + messageId: match.messageId, + role: match.role, + seq: match.seq, + createdAt: match.createdAt, + snippet: match.snippet, + truncated: match.truncated + } + }] + }) + + return c.json({ results, hasPotentiallyIncompleteResults }) + } + + app.get('/sessions/content-search', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const query = c.req.query('query')?.trim() ?? '' + const limitRaw = Number(c.req.query('limit')) + const limit = Number.isFinite(limitRaw) + ? Math.min(100, Math.max(1, Math.floor(limitRaw))) + : 50 + const searchUrl = new URL(c.req.url) + const requestedSessionIds = searchUrl.searchParams.getAll('sessionId') + .map((sessionId) => sessionId.trim()) + .filter(Boolean) + const sessionIds = searchUrl.searchParams.has('sessionId') + ? [...new Set(requestedSessionIds)] + : undefined + return respondToSessionContentSearch(c, engine, query, limit, sessionIds) + }) + + app.post( + '/sessions/content-search', + bodyLimit({ + maxSize: MAX_CONTENT_SEARCH_REQUEST_BYTES, + onError: (c) => c.json({ error: 'Content search request too large' }, 413) + }), + async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + let body: { + query?: unknown + limit?: unknown + sessionIds?: unknown + } | null + try { + body = await c.req.json() as { + query?: unknown + limit?: unknown + sessionIds?: unknown + } + } catch (error) { + if (error instanceof Error && error.name === 'BodyLimitError') { + return c.json({ error: 'Content search request too large' }, 413) + } + body = null + } + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return c.json({ error: 'Invalid body' }, 400) + } + if (body.query !== undefined && typeof body.query !== 'string') { + return c.json({ error: 'Invalid query' }, 400) + } + if (body.sessionIds !== undefined && ( + !Array.isArray(body.sessionIds) + || body.sessionIds.some((sessionId) => typeof sessionId !== 'string') + )) { + return c.json({ error: 'Invalid sessionIds' }, 400) + } + + const query = typeof body.query === 'string' ? body.query.trim() : '' + const limitRaw = Number(body.limit) + const limit = Number.isFinite(limitRaw) + ? Math.min(100, Math.max(1, Math.floor(limitRaw))) + : 50 + const sessionIds = body.sessionIds === undefined + ? undefined + : [...new Set( + (body.sessionIds as string[]) + .map((sessionId) => sessionId.trim()) + .filter(Boolean) + )] + return respondToSessionContentSearch(c, engine, query, limit, sessionIds) + } + ) + + app.get('/sessions/:id/content-search', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const query = c.req.query('query')?.trim() ?? '' + if (!query) return c.json({ matches: [], total: 0, hasPotentiallyIncompleteResults: false }) + if (Array.from(query).length > MAX_CONTENT_SEARCH_QUERY_CHARACTERS) { + return c.json({ error: 'Content search query too long' }, 400) + } + + const limitRaw = Number(c.req.query('limit')) + const limit = Number.isFinite(limitRaw) + ? Math.min(1000, Math.max(1, Math.floor(limitRaw))) + : 500 + const result = engine.searchSessionContentMatches( + query, + c.get('namespace'), + sessionResult.session.id, + limit + ) + + return c.json({ + matches: result.matches.map(({ sessionId: _sessionId, ...match }) => match), + total: result.total, + hasPotentiallyIncompleteResults: result.hasPotentiallyIncompleteResults + }) + }) + app.put('/sessions/:id/read-state', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) return engine diff --git a/package.json b/package.json index ecf3db3e..d0c700c7 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:e2e:ui": "playwright test --ui", "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", "test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts", + "test:session-content-search:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.content-search.live.config.ts", "seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts", "gen:fixtures": "cd web && bun scripts/generate-fixtures.ts", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", diff --git a/shared/package.json b/shared/package.json index 9f011fa6..c8529413 100644 --- a/shared/package.json +++ b/shared/package.json @@ -34,6 +34,10 @@ "test": "bun test" }, "dependencies": { + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-gfm": "^3.1.0", + "mdast-util-to-string": "^4.0.0", + "micromark-extension-gfm": "^3.0.0", "proper-lockfile": "^4.1.2", "zod": "^4.2.1" }, diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 26054958..24cb766c 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -130,6 +130,28 @@ export type AuthResponse = { } export type SessionsResponse = { sessions: SessionSummary[] } +export type SessionContentMatch = { + messageId: string + role: 'user' | 'assistant' + seq: number + createdAt: number + snippet: string + truncated: boolean +} +export type SessionContentSearchResponse = { + results: Array<{ + session: SessionSummary + match: SessionContentMatch + }> + /** True when this query may miss content omitted by an indexing optimization. */ + hasPotentiallyIncompleteResults: boolean +} +export type SessionContentMatchesResponse = { + matches: SessionContentMatch[] + total: number + /** True when this query may miss content omitted by an indexing optimization. */ + hasPotentiallyIncompleteResults: boolean +} export type SessionResponse = { session: Session } export type MessagesResponse = { messages: DecryptedMessage[] @@ -150,6 +172,20 @@ export type MessagesResponse = { } } +/** A bounded history window centered on a message selected from search results. */ +export type MessageContextResponse = { + messages: DecryptedMessage[] + targetMessageId: string + page: { + epoch: number + nextBeforeSeq: number | null + nextBeforeAt: number | null + snapshotHeadSeq: number | null + snapshotHeadAt: number | null + hasMore: boolean + } +} + export type MachinesResponse = { machines: Machine[] } export type SpawnResponse = diff --git a/shared/src/messages.test.ts b/shared/src/messages.test.ts index 1b29edb0..38ee4e51 100644 --- a/shared/src/messages.test.ts +++ b/shared/src/messages.test.ts @@ -2,12 +2,19 @@ import { describe, expect, test } from 'bun:test' import { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' import { extractAssistantPlainText, + extractInjectedTurnUuid, + extractMessageRenderKey, + extractSearchableMessageText, + extractUserPlainText, + isLiveStreamSnapshot, hasConversationMessageContent, extractNotifySummary, + getAgyTaskLogId, getLiveReasoningStreamId, getReasoningStreamId, isRedundantGoalStatusEventContent, splitNotifySummary, + stripAgyEchoedTaskResult, stripNotifySummaryFooter, type NotifySummary } from './messages' @@ -20,6 +27,28 @@ describe('extractAssistantPlainText', () => { expect(extractAssistantPlainText(42)).toBeNull() }) + test('extracts Claude assistant prose sent as a plain string content', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { role: 'assistant', content: 'Here is the answer.' } + } + } + expect(extractAssistantPlainText(content)).toBe('Here is the answer.') + }) + + test('returns null for blank string assistant content', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { role: 'assistant', content: ' ' } + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + test('extracts codex/message text', () => { const content = { type: 'codex', @@ -114,6 +143,382 @@ describe('extractAssistantPlainText', () => { }) }) +describe('extractSearchableMessageText', () => { + test('identifies live stream snapshots without treating terminal snapshots as live', () => { + expect(isLiveStreamSnapshot({ + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', streamSnapshot: true, live: true, message: 'partial' } + } + })).toBe(true) + expect(isLiveStreamSnapshot({ + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', streamSnapshot: true, message: 'complete' } + } + })).toBe(false) + expect(isLiveStreamSnapshot({ + role: 'agent', + content: { type: 'text', streamSnapshot: true, live: true, text: 'partial' } + })).toBe(true) + }) + + test('extracts user text and normalizes whitespace', () => { + expect(extractUserPlainText([{ type: 'text', text: ' hello\nworld ' }, { type: 'image' }])) + .toBe('hello world') + expect(extractUserPlainText('Use **KV Cache** for this path.')) + .toBe('Use KV Cache for this path.') + expect(extractSearchableMessageText({ + role: 'user', + content: { type: 'text', text: 'Find this prompt' } + })).toEqual({ role: 'user', text: 'Find this prompt' }) + }) + + test('bounds oversized structured user text before Markdown normalization', () => { + const text = extractUserPlainText([ + { type: 'text', text: 'Head '.repeat(50) }, + { type: 'text', text: 'Middle '.repeat(50) }, + { type: 'text', text: 'Tail phrase' } + ], 32) + + expect(text).toMatch(/^Head/) + expect(text).toContain('Tail phrase') + expect(text).not.toContain('Middle') + expect(text?.length).toBeLessThanOrEqual(32) + }) + + test('normalizes GFM strikethrough and tables like the renderer', () => { + // `Use ~~old~~ cache` displays as "Use old cache"; the tildes must not + // break the indexed phrase. + expect(extractSearchableMessageText({ + role: 'user', + content: { type: 'text', text: 'Use ~~old~~ cache' } + })?.text).toContain('Use old cache') + + // Table pipes are source markup, not displayed text. + const table = extractSearchableMessageText({ + role: 'user', + content: { type: 'text', text: '| key | value |\n| --- | --- |\n| ttl | 30s |' } + })?.text + expect(table).not.toContain('---') + expect(table).toContain('ttl') + }) + + test('does not index hidden HTML comments', () => { + // The renderer omits these, so a hit would have nothing to highlight. + expect(extractSearchableMessageText({ + role: 'user', + content: { type: 'text', text: 'visible text ' } + })?.text).not.toContain('hidden phrase') + }) + + test('indexes Claude assistant prose sent as a plain string content', () => { + // Rendered via normalizeAgent's string branch; it must reach search too. + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { role: 'assistant', content: 'Here is the answer.' } + } + } + })).toEqual({ role: 'assistant', text: 'Here is the answer.' }) + }) + + test('bounds string assistant content like block text', () => { + const assistant = extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + content: `head ${'middle filler '.repeat(20)}unique middle-only marker tail phrase` + } + } + } + }, { maxSourceCharacters: 32 }) + + expect(assistant).toMatchObject({ role: 'assistant', truncated: true }) + expect(assistant?.text).toContain('tail phrase') + expect(assistant?.text).not.toContain('unique middle-only marker') + }) + + test('applies the injected-turn sentinel filter to string assistant content', () => { + const sentinel = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'injected-turn', + message: { role: 'assistant', content: 'No response requested.' } + } + } + } + + expect(extractSearchableMessageText(sentinel, { + injectedTurnUuids: new Set(['injected-turn']) + })).toBeNull() + expect(extractSearchableMessageText(sentinel, { + injectedTurnUuids: new Set(['other-turn']) + })).toEqual({ role: 'assistant', text: 'No response requested.' }) + }) + + test('skips blank string assistant content', () => { + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { type: 'assistant', message: { role: 'assistant', content: ' ' } } + } + })).toBeNull() + }) + + test('marks searchable text that was truncated for indexing', () => { + const user = extractSearchableMessageText({ + role: 'user', + content: { type: 'text', text: `head ${'middle filler '.repeat(20)}unique middle-only marker tail phrase` } + }, { maxSourceCharacters: 32 }) + const assistant = extractSearchableMessageText({ + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', message: `head ${'middle filler '.repeat(20)}unique middle-only marker tail phrase` } + } + }, { maxSourceCharacters: 32 }) + + expect(user).toMatchObject({ role: 'user', truncated: true }) + expect(user?.text).toContain('tail phrase') + expect(user?.text).not.toContain('unique middle-only marker') + expect(assistant).toMatchObject({ role: 'assistant', truncated: true }) + expect(assistant?.text).toContain('tail phrase') + expect(assistant?.text).not.toContain('unique middle-only marker') + }) + + test('extracts assistant prose but excludes tool and reasoning records', () => { + expect(extractSearchableMessageText({ + role: 'agent', + content: { type: 'codex', data: { type: 'message', id: 'stream-1', message: 'Visible answer' } } + })).toEqual({ role: 'assistant', text: 'Visible answer', renderKey: 'stream-1' }) + expect(extractSearchableMessageText({ role: 'agent', content: 'Legacy visible answer' })) + .toEqual({ role: 'assistant', text: 'Legacy visible answer' }) + expect(extractSearchableMessageText({ + role: 'agent', + content: { type: 'codex', data: { type: 'tool-call', input: { secret: 'do not index' } } } + })).toBeNull() + expect(extractSearchableMessageText({ + role: 'agent', + content: { type: 'output', data: { type: 'user', message: { content: 'not assistant prose' } } } + })).toBeNull() + }) + + test('excludes Claude Task/Agent prompts rendered only in the tool card', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Inspect the repository' }, + { type: 'tool_use', name: 'Task', input: { prompt: 'Inspect the repository' } }, + { type: 'text', text: 'The repository is ready.' }, + { type: 'tool_use', name: 'Agent', input: { prompt: 'Run the second agent' } }, + { type: 'text', text: 'Run the second agent' }, + ] + } + } + } + + expect(extractSearchableMessageText({ role: 'agent', content })) + .toEqual({ role: 'assistant', text: 'The repository is ready. Run the second agent' }) + }) + + test('excludes No response requested only when its parent is system-injected', () => { + const sidechain = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'injected-turn', + message: { content: '...' } + } + } + } + const sentinel = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + parentUuid: 'injected-turn', + message: { content: [{ type: 'text', text: 'No response requested.' }] } + } + } + } + const normalSentinel = { + ...sentinel, + content: { + ...sentinel.content, + data: { + ...sentinel.content.data, + parentUuid: 'normal-turn' + } + } + } + + expect(extractInjectedTurnUuid(sidechain)).toBe('injected-turn') + expect(extractSearchableMessageText(sentinel, { + injectedTurnUuids: new Set(['injected-turn']) + })).toBeNull() + expect(extractSearchableMessageText(normalSentinel, { + injectedTurnUuids: new Set(['injected-turn']) + })).toEqual({ role: 'assistant', text: 'No response requested.' }) + }) + + test('extracts visible non-sidechain Claude user records', () => { + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + isSidechain: false, + message: { + content: [ + { type: 'text', text: 'Find this Claude prompt.' }, + { type: 'text', text: 'Keep searching.' } + ] + } + } + } + })).toEqual({ role: 'user', text: 'Find this Claude prompt. Keep searching.' }) + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + isSidechain: false, + message: { + content: [ + { type: 'text', text: 'Visible mixed prompt.' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'ignored' } }, + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ignored tool output' } + ] + } + } + } + })).toEqual({ role: 'user', text: 'Visible mixed prompt.' }) + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + isSidechain: true, + message: { content: [{ type: 'text', text: 'Hidden prompt.' }] } + } + } + })).toBeNull() + }) + + test('normalizes assistant Markdown to the rendered text', () => { + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', message: 'Use **KV Cache** and [the docs](https://example.com).' } + } + })).toEqual({ role: 'assistant', text: 'Use KV Cache and the docs.' }) + expect(extractUserPlainText('## Deploy\n\nRun migrations')).toBe('Deploy Run migrations') + expect(extractUserPlainText('cache \nkey')).toBe('cache key') + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'codex', + data: { type: 'message', message: 'cache \nkey' } + } + })).toEqual({ role: 'assistant', text: 'cache key' }) + }) + + test('keeps the stream identity even when a snapshot has no searchable text', () => { + const emptySnapshot = { + role: 'agent', + content: { type: 'codex', data: { type: 'message', id: 'stream-1', message: '' } } + } + expect(extractSearchableMessageText(emptySnapshot)).toBeNull() + expect(extractMessageRenderKey(emptySnapshot)).toBe('stream-1') + }) + + test('excludes hidden assistant metadata, compact-summary, and sidechain output', () => { + for (const flag of ['isMeta', 'isCompactSummary', 'isSidechain'] as const) { + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + [flag]: true, + message: { content: [{ type: 'text', text: 'Hidden renderer output' }] } + } + } + })).toBeNull() + } + }) + + test('excludes a trailing notify summary footer from assistant search text', () => { + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Visible answer.\n\nAGENT_NOTIFY_SUMMARY {"status":"done","summary":"Hidden footer"}' + } + } + })).toEqual({ role: 'assistant', text: 'Visible answer.' }) + + expect(extractSearchableMessageText({ + role: 'agent', + content: 'AGENT_NOTIFY_SUMMARY {"status":"done","summary":"Only footer"}' + })).toBeNull() + }) + + test('matches the rendered AGY text by removing echoed task results', () => { + expect(stripAgyEchoedTaskResult( + 'Inside the task-246 log...\n[Message] timestamp=2026-07-08T06:04:31Z sender=u/task-246 content=Task finished with result:\nHidden task output' + )).toBe('Inside the task-246 log...') + + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'agy_message', + content: 'Visible planner prose\n[Message] timestamp=2026-07-08T06:04:31Z content=Hidden task output' + } + } + })).toEqual({ role: 'assistant', text: 'Visible planner prose' }) + }) + + test('excludes AGY task-log narration rendered as a tool chip', () => { + expect(getAgyTaskLogId('Inside the task-246 log...')).toBe('246') + expect(extractSearchableMessageText({ + role: 'agent', + content: { + type: 'output', + data: { type: 'agy_message', content: 'Inside the task-246 log...\n[Message] timestamp=2026-07-08T06:04:31Z content=Hidden task output' } + } + })).toBeNull() + }) +}) + describe('extractNotifySummary', () => { const FULL_LINE = 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"hapi-monitor agent","project":"hapi-monitor","status":"done","action":"Revoke tokens","summary":"Published v0.1.0"}' diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 06f2cc5b..6469a23c 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -1,3 +1,6 @@ +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' import { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' import { isObject } from './utils' @@ -163,6 +166,15 @@ export function extractAssistantPlainText(content: unknown): string | null { if (data.type !== 'assistant') return null const message = isObject(data.message) ? data.message : null + if (typeof data.message === 'string' && data.message.trim().length > 0) { + return data.message + } + // Claude also sends assistant prose as a plain string `message.content` + // (the renderer handles this shape in normalizeAgent). Without this the + // reply is visible but absent from both backfill and live indexing. + if (typeof message?.content === 'string') { + return message.content.trim().length > 0 ? message.content : null + } const blocks = Array.isArray(message?.content) ? message.content : null if (!blocks) return null const textParts: string[] = [] @@ -179,6 +191,457 @@ export function extractAssistantPlainText(content: unknown): string | null { return null } +// AGY sometimes echoes an async task's raw result into its own PLANNER_RESPONSE +// prose. The web renderer strips this block and renders the corresponding task +// result separately, so keep it out of the searchable assistant text as well. +export function stripAgyEchoedTaskResult(text: string): string { + return text.replace(/\n*\[Message\]\s+timestamp=[\s\S]*$/, '').trim() +} + +// AGY's transitional task-log narration is rendered as a compact tool chip, +// not an assistant text bubble. Return its task number for renderer/index parity. +export function getAgyTaskLogId(text: string): string | null { + return text.match(/^Inside the task-(\d+) log\b/)?.[1] ?? null +} + +function normalizeSearchablePlainText(value: string): string | null { + const text = value.trim().replace(/\s+/g, ' ') + return text.length > 0 ? text : null +} + +type BoundedText = { + text: string + truncated: boolean +} + +type BoundedTextChunks = { + chunks: string[] + truncated: boolean +} + +function sliceJoinedText( + parts: readonly string[], + separator: string, + start: number, + length: number +): string { + if (length <= 0) return '' + + const end = start + length + const chunks: string[] = [] + let offset = 0 + for (let index = 0; index < parts.length; index++) { + const part = parts[index]! + const partStart = offset + const partEnd = partStart + part.length + if (partEnd > start && partStart < end) { + chunks.push(part.slice( + Math.max(0, start - partStart), + Math.min(part.length, end - partStart) + )) + } + offset = partEnd + + if (index === parts.length - 1) break + const separatorStart = offset + const separatorEnd = separatorStart + separator.length + if (separatorEnd > start && separatorStart < end) { + chunks.push(separator.slice( + Math.max(0, start - separatorStart), + Math.min(separator.length, end - separatorStart) + )) + } + offset = separatorEnd + if (offset >= end) break + } + return chunks.join('') +} + +/** + * Join only the bounded head and tail of a multi-part message. In particular, + * do not materialize a potentially unbounded structured prompt before the + * Markdown parser's source limit is applied. + */ +function collectJoinedHeadTail( + parts: readonly string[], + separator: string, + maxSourceCharacters: number +): BoundedTextChunks { + if (parts.length === 0) return { chunks: [], truncated: false } + if (!Number.isFinite(maxSourceCharacters)) { + return { chunks: [parts.join(separator)], truncated: false } + } + + const maxCharacters = Math.max(0, Math.floor(maxSourceCharacters)) + if (maxCharacters === 0) return { chunks: [], truncated: parts.length > 0 } + + let totalCharacters = 0 + for (let index = 0; index < parts.length; index++) { + totalCharacters += parts[index]!.length + if (index > 0) totalCharacters += separator.length + } + if (totalCharacters <= maxCharacters) { + return { chunks: [parts.join(separator)], truncated: false } + } + + if (maxCharacters <= separator.length) { + return { + chunks: [sliceJoinedText(parts, separator, 0, maxCharacters)], + truncated: true + } + } + + const contentCharacters = maxCharacters - separator.length + const headCharacters = Math.ceil(contentCharacters / 2) + const tailCharacters = contentCharacters - headCharacters + const chunks = [sliceJoinedText(parts, separator, 0, headCharacters)] + if (tailCharacters > 0) { + chunks.push(sliceJoinedText(parts, separator, totalCharacters - tailCharacters, tailCharacters)) + } + return { chunks, truncated: true } +} + +function normalizeSearchableMarkdownChunks(chunks: readonly string[]): string | null { + const markdownBlockTypes = new Set([ + 'blockquote', + 'code', + 'heading', + 'html', + 'list', + 'listItem', + 'paragraph', + 'root', + 'table', + 'tableCell', + 'tableRow' + ]) + const renderMarkdownNode = (node: { + type?: string + value?: string + children?: readonly unknown[] + }): string => { + if (node.type === 'break') return ' ' + // The chat renderer drops raw HTML, so indexing node.value here would + // make an HTML comment searchable and surface it in snippets with no + // rendered text to highlight. + if (node.type === 'html') return '' + if (typeof node.value === 'string') return node.value + + let rendered = '' + let previousType: string | undefined + for (const child of node.children ?? []) { + if (!isObject(child)) continue + const childText = renderMarkdownNode(child) + if (!childText) continue + if ( + rendered + && (markdownBlockTypes.has(child.type as string) || markdownBlockTypes.has(previousType ?? '')) + ) { + rendered += ' ' + } + rendered += childText + previousType = child.type as string | undefined + } + return rendered + } + + const rendered = chunks.map((chunk) => { + try { + // The chat renders with remark-gfm, so parse the same dialect: + // without it `~~old~~` keeps its tildes and a search for the + // displayed phrase cannot match, and table pipes stay in the index. + return renderMarkdownNode(fromMarkdown(chunk, { + extensions: [gfm({ singleTilde: false })], + mdastExtensions: [gfmFromMarkdown()] + })) + } catch { + // Keep indexing malformed/incomplete streamed Markdown rather than + // dropping otherwise visible assistant text. + return chunk + } + }).join(' ') + return normalizeSearchablePlainText(rendered) +} + +function normalizeSearchableMarkdownTextWithMetadata( + value: string, + maxSourceCharacters = Number.POSITIVE_INFINITY +): BoundedText | null { + const bounded = collectJoinedHeadTail([value], ' ', maxSourceCharacters) + const text = normalizeSearchableMarkdownChunks(bounded.chunks) + return text ? { text, truncated: bounded.truncated } : null +} + +function extractUserPlainTextWithMetadata( + content: unknown, + maxSourceCharacters = Number.POSITIVE_INFINITY +): BoundedText | null { + if (typeof content === 'string') { + return normalizeSearchableMarkdownTextWithMetadata(content, maxSourceCharacters) + } + + const blocks = Array.isArray(content) ? content : [content] + const textParts = blocks + .map((block) => { + if (!isObject(block) || block.type !== 'text' || typeof block.text !== 'string') { + return null + } + return block.text + }) + .filter((text): text is string => text !== null) + + const bounded = collectJoinedHeadTail(textParts, ' ', maxSourceCharacters) + const text = normalizeSearchableMarkdownChunks(bounded.chunks) + return text ? { text, truncated: bounded.truncated } : null +} + +export function extractUserPlainText(content: unknown, maxSourceCharacters = Number.POSITIVE_INFINITY): string | null { + return extractUserPlainTextWithMetadata(content, maxSourceCharacters)?.text ?? null +} + +function extractClaudeUserPlainText(content: unknown, maxSourceCharacters = Number.POSITIVE_INFINITY): BoundedText | null { + if (!isObject(content) || content.type !== 'output') return null + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'user' || Boolean(data.isSidechain)) return null + + const message = isObject(data.message) ? data.message : null + const blocks = Array.isArray(message?.content) ? message.content : null + if (!blocks || blocks.length === 0) return null + + return extractUserPlainTextWithMetadata(blocks, maxSourceCharacters) +} + +export type SearchableMessageContext = { + injectedTurnUuids?: ReadonlySet + maxSourceCharacters?: number +} + +/** Return the Claude UUID that identifies a system-injected user turn. */ +export function extractInjectedTurnUuid(value: unknown): string | null { + const record = unwrapRoleWrappedRecordEnvelope(value) + if (!record || record.role !== 'agent' || !isObject(record.content) || record.content.type !== 'output') { + return null + } + const data = isObject(record.content.data) ? record.content.data : null + if (!data || data.type !== 'user') return null + const message = isObject(data.message) ? data.message : null + const messageContent = message?.content + const isSystemInjected = Boolean(data.isSidechain) || typeof messageContent === 'string' + if (!isSystemInjected || typeof data.uuid !== 'string' || data.uuid.length === 0) return null + return data.uuid +} + +export function extractAssistantParentUuid(content: unknown): string | null { + if (!isObject(content) || content.type !== 'output') return null + const data = isObject(content.data) ? content.data : null + return data?.type === 'assistant' && typeof data.parentUuid === 'string' && data.parentUuid.length > 0 + ? data.parentUuid + : null +} + +function isNoResponseRequestedText(text: string): boolean { + const trimmed = text.trim() + return trimmed === 'No response requested.' || trimmed === 'No response requested' +} + +function extractSearchableAssistantPlainText( + content: unknown, + context?: SearchableMessageContext +): BoundedText | null { + if (!isObject(content) || content.type !== 'output') { + const text = extractAssistantPlainText(content) + return text === null ? null : { text, truncated: false } + } + + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'assistant') { + const text = extractAssistantPlainText(content) + return text === null ? null : { text, truncated: false } + } + + const message = isObject(data.message) ? data.message : null + const blocks = Array.isArray(message?.content) ? message.content : null + // String `message.content` runs through the same sentinel filtering and + // bounding as block text; the plain-text fallback below is unbounded. + const stringContent = !blocks && typeof message?.content === 'string' + ? message.content + : null + if (!blocks && stringContent === null) { + const text = extractAssistantPlainText(content) + return text === null ? null : { text, truncated: false } + } + + const taskToolCall = blocks?.find((block) => { + if (!isObject(block) || block.type !== 'tool_use') return false + const name = typeof block.name === 'string' ? block.name : '' + return name === 'Task' + || name === 'Agent' + || name.startsWith('Task:') + || name.startsWith('Agent:') + }) + const taskToolInput = isObject(taskToolCall) && isObject(taskToolCall.input) + ? taskToolCall.input + : null + const hiddenTaskPrompt = typeof taskToolInput?.prompt === 'string' + ? taskToolInput.prompt.trim() + : null + const textParts = blocks + ? blocks.flatMap((block) => { + if (!isObject(block) || block.type !== 'text' || typeof block.text !== 'string') return [] + return hiddenTaskPrompt && block.text.trim() === hiddenTaskPrompt ? [] : [block.text] + }) + : stringContent !== null && stringContent.trim().length > 0 + ? [stringContent] + : [] + if (textParts.length === 1) { + const parentUuid = extractAssistantParentUuid(content) + if ( + parentUuid + && context?.injectedTurnUuids?.has(parentUuid) + && isNoResponseRequestedText(textParts[0]!) + ) { + return null + } + } + if (textParts.length === 0) return null + const bounded = collectJoinedHeadTail( + textParts, + '\n', + context?.maxSourceCharacters ?? Number.POSITIVE_INFINITY + ) + return bounded.chunks.length > 0 + ? { text: bounded.chunks.join('\n'), truncated: bounded.truncated } + : null +} + +export type SearchableMessage = { + role: 'user' | 'assistant' + text: string + /** Stable renderer identity used to coalesce streamed assistant snapshots. */ + renderKey?: string + /** True when the source text exceeded the indexing budget and was bounded. */ + truncated?: boolean +} + +function getMessageRenderKey(content: unknown): string | undefined { + if (!isObject(content)) return undefined + + if (content.type === 'codex') { + const data = isObject(content.data) ? content.data : null + if (data?.type === 'message' && typeof data.id === 'string' && data.id.length > 0) { + return data.id + } + } + + if (content.type === 'text' && typeof content.streamId === 'string' && content.streamId.length > 0) { + return content.streamId + } + + return undefined +} + +/** Return the renderer identity for a visible message, even when it has no text. */ +export function extractMessageRenderKey(value: unknown): string | null { + const record = unwrapRoleWrappedRecordEnvelope(value) + if (!record || (record.role !== 'agent' && record.role !== 'assistant')) return null + return getMessageRenderKey(record.content) ?? null +} + +/** Return whether a message is a cumulative live stream snapshot. */ +export function isLiveStreamSnapshot(value: unknown): boolean { + const record = unwrapRoleWrappedRecordEnvelope(value) + if (!record || (record.role !== 'agent' && record.role !== 'assistant')) return false + if (!isObject(record.content)) return false + + if (record.content.type === 'codex') { + const data = isObject(record.content.data) ? record.content.data : null + return data?.type === 'message' && data.streamSnapshot === true && data.live === true + } + + return record.content.type === 'text' + && record.content.streamSnapshot === true + && record.content.live === true +} + +function isHiddenAssistantOutput(content: unknown): boolean { + if (!isObject(content) || content.type !== 'output') return false + const data = isObject(content.data) ? content.data : null + return Boolean(data?.isMeta) || Boolean(data?.isCompactSummary) || Boolean(data?.isSidechain) +} + +/** Extract only user-visible user/assistant prose from a stored role envelope. */ +export function extractSearchableMessageText( + value: unknown, + context?: SearchableMessageContext +): SearchableMessage | null { + const record = unwrapRoleWrappedRecordEnvelope(value) + if (!record) return null + + if (record.role === 'user') { + const extracted = extractUserPlainTextWithMetadata(record.content, context?.maxSourceCharacters) + return extracted + ? { + role: 'user', + text: extracted.text, + ...(extracted.truncated ? { truncated: true } : {}) + } + : null + } + + if (record.role === 'agent' || record.role === 'assistant') { + if (isHiddenAssistantOutput(record.content)) return null + const claudeUserText = extractClaudeUserPlainText(record.content, context?.maxSourceCharacters) + if (claudeUserText) { + return { + role: 'user', + text: claudeUserText.text, + ...(claudeUserText.truncated ? { truncated: true } : {}) + } + } + const renderKey = getMessageRenderKey(record.content) + const isAgyPlannerMessage = isObject(record.content) + && record.content.type === 'output' + && isObject(record.content.data) + && record.content.data.type === 'agy_message' + const directText = typeof record.content === 'string' + ? record.content + : isObject(record.content) + && record.content.type === 'text' + && typeof record.content.text === 'string' + ? record.content.text + : null + const extracted = isAgyPlannerMessage + ? (() => { + const text = directText ?? extractAssistantPlainText(record.content) + return text === null ? null : { text, truncated: false } + })() + : directText !== null + ? { text: directText, truncated: false } + : extractSearchableAssistantPlainText(record.content, context) + if (!extracted) return null + + const rawText = stripNotifySummaryFooter( + isAgyPlannerMessage + ? stripAgyEchoedTaskResult(extracted.text) + : extracted.text + ) + const normalized = normalizeSearchableMarkdownTextWithMetadata( + rawText, + context?.maxSourceCharacters + ) + if (!normalized || (isAgyPlannerMessage && getAgyTaskLogId(normalizeSearchablePlainText(rawText) ?? ''))) return null + const truncated = extracted.truncated || normalized.truncated + return { + role: 'assistant', + text: normalized.text, + ...(renderKey ? { renderKey } : {}), + ...(truncated ? { truncated: true } : {}) + } + } + + return null +} + /** Output a reader can consume. Bookkeeping, reasoning and tool traffic do not * create unread activity. Run at durable ingest, never on heartbeat/metadata. */ export function hasUnreadMessageContent(value: unknown): boolean { diff --git a/web/e2e/session-content-search-jump.spec.ts b/web/e2e/session-content-search-jump.spec.ts new file mode 100644 index 00000000..26268980 --- /dev/null +++ b/web/e2e/session-content-search-jump.spec.ts @@ -0,0 +1,559 @@ +import { expect, test } from '@playwright/test' +import { + getHapiBaseUrl, + installHapiAuth, + readCliAccessToken, +} from './helpers/hapi-live' + +const liveEnabled = process.env.HAPI_LIVE === '1' + +test('content search opens the matching message instead of the session tail', async ({ page, request }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const accessToken = readCliAccessToken() + const authResponse = await request.post(`${baseUrl}/api/auth`, { + data: { accessToken } + }) + expect(authResponse.ok()).toBe(true) + const auth = await authResponse.json() as { token: string } + const headers = { authorization: `Bearer ${auth.token}` } + + const contentResponse = await request.get( + `${baseUrl}/api/sessions/content-search?query=cache&limit=1`, + { headers } + ) + expect(contentResponse.ok()).toBe(true) + const content = await contentResponse.json() as { + results: Array<{ + session: { id: string } + match: { messageId: string; snippet: string } + }> + } + if (content.results.length === 0) { + test.skip(true, 'The live database has no indexed message-content hit for the smoke query') + return + } + const hit = content.results[0]! + + await installHapiAuth(page, baseUrl, accessToken) + await page.addInitScript(() => { + localStorage.setItem('hapi-appearance', 'dark') + localStorage.removeItem('hapi-color-theme') + }) + await page.goto(`${baseUrl}/sessions`, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + + const searchButton = page.getByRole('button', { name: /Search sessions/ }).first() + await expect(searchButton).toBeVisible({ timeout: 30_000 }) + await searchButton.click() + + const searchInput = page.getByRole('searchbox') + await expect(searchInput).toBeVisible() + const scopeButton = page.getByTestId('session-list-search-scope') + await expect(scopeButton).toBeVisible() + await expect(scopeButton).toHaveAttribute('data-search-scope', 'metadata') + // The toggle stays an icon-sized endcap, and matches the clear endcap's hit target. + await expect.poll(() => scopeButton.evaluate((button) => button.getBoundingClientRect().width)) + .toBeLessThan(80) + await expect.poll(() => scopeButton.evaluate((button) => { + const box = button.getBoundingClientRect() + return Math.min(box.width, box.height) + })).toBeGreaterThanOrEqual(48) + await scopeButton.click() + await expect(scopeButton).toHaveAttribute('data-search-scope', 'content') + await searchInput.fill('cache') + + const row = page.locator('.session-list-item').filter({ + hasText: hit.match.snippet.slice(0, 20) + }).first() + await expect(row).toBeVisible({ timeout: 30_000 }) + await row.click() + + await expect.poll(() => new URL(page.url()).pathname).toBe(`/sessions/${hit.session.id}`) + const match = page.locator(`[${'data-hapi-source-search-match'}="true"]`) + await expect(match).toHaveCount(1, { timeout: 30_000 }) + await expect(match).toHaveAttribute('data-hapi-source-message-id', hit.match.messageId) + await expect.poll(() => match.evaluate((element) => { + const style = getComputedStyle(element) + return { + animationName: style.animationName, + backgroundColor: style.backgroundColor, + color: style.color, + boxShadow: style.boxShadow, + } + })).toEqual({ + animationName: 'none', + backgroundColor: 'rgba(250, 204, 21, 0.3)', + color: 'rgb(254, 240, 138)', + boxShadow: 'none', + }) + await expect(page.getByTestId('search-match-navigation')).toBeVisible() + const viewportHeight = await page.evaluate(() => window.innerHeight) + await expect.poll(async () => { + const matchBox = await match.boundingBox() + return matchBox !== null + && matchBox.y >= 0 + && matchBox.y < viewportHeight + }, { timeout: 30_000 }).toBe(true) +}) + +test('content search shows its loading state before the debounce completes', async ({ page }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const accessToken = readCliAccessToken() + await installHapiAuth(page, baseUrl, accessToken) + await page.route('**/api/sessions/content-search', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + await route.continue() + }) + await page.goto(`${baseUrl}/sessions`, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + + const searchButton = page.getByRole('button', { name: /Search sessions/ }).first() + await expect(searchButton).toBeVisible({ timeout: 30_000 }) + await searchButton.click() + const searchInput = page.getByRole('searchbox') + await expect(searchInput).toBeVisible() + await page.getByTestId('session-list-search-scope').click() + await searchInput.fill('cache') + + const loading = page.getByText(/Searching message content|正在搜索消息内容/).first() + await expect(loading).toBeVisible({ timeout: 150 }) + await expect(page.getByText(/No messages match your search|没有消息匹配搜索内容/)).toHaveCount(0) +}) + +test('content search navigates between multiple matching messages in one session', async ({ page, request }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const accessToken = readCliAccessToken() + const authResponse = await request.post(`${baseUrl}/api/auth`, { data: { accessToken } }) + expect(authResponse.ok()).toBe(true) + const auth = await authResponse.json() as { token: string } + const headers = { authorization: `Bearer ${auth.token}` } + const globalResponse = await request.get( + `${baseUrl}/api/sessions/content-search?query=${encodeURIComponent('你好')}&limit=100`, + { headers } + ) + expect(globalResponse.ok()).toBe(true) + const globalContent = await globalResponse.json() as { + results: Array<{ session: { id: string }; match: { messageId: string } }> + } + + let sessionHit: { + sessionId: string + matches: Array<{ messageId: string }> + total: number + } | null = null + for (const result of globalContent.results) { + const response = await request.get( + `${baseUrl}/api/sessions/${result.session.id}/content-search?query=${encodeURIComponent('你好')}&limit=500`, + { headers } + ) + if (!response.ok()) continue + const body = await response.json() as { + matches: Array<{ messageId: string }> + total: number + } + if (body.matches.length >= 2) { + sessionHit = { sessionId: result.session.id, matches: body.matches, total: body.total } + break + } + } + if (!sessionHit) { + test.skip(true, 'The live database has no session with multiple matching messages') + return + } + + await installHapiAuth(page, baseUrl, accessToken) + await page.addInitScript(() => { + localStorage.setItem('hapi-appearance', 'dark') + localStorage.removeItem('hapi-color-theme') + }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto( + `${baseUrl}/sessions/${sessionHit.sessionId}?messageId=${encodeURIComponent(sessionHit.matches[0]!.messageId)}&messageQuery=${encodeURIComponent('你好')}`, + { waitUntil: 'domcontentloaded', timeout: 60_000 } + ) + + const navigation = page.getByTestId('search-match-navigation') + await expect(navigation).toBeVisible({ timeout: 60_000 }) + await expect(navigation).toContainText(`1/${sessionHit.total}`) + const match = page.locator('[data-hapi-source-search-match="true"]') + await expect(match).toHaveAttribute('data-hapi-source-message-id', sessionHit.matches[0]!.messageId, { + timeout: 60_000 + }) + + await navigation.getByRole('button', { name: 'Next match' }).click() + await expect(match).toHaveAttribute('data-hapi-source-message-id', sessionHit.matches[1]!.messageId, { + timeout: 60_000 + }) + await expect(navigation).toContainText(`2/${sessionHit.total}`) +}) + +test('content search keeps both arrows usable for a single matching message', async ({ page, request }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const query = process.env.HAPI_SINGLE_RESULT_QUERY ?? 'cache' + const accessToken = readCliAccessToken() + const authResponse = await request.post(`${baseUrl}/api/auth`, { data: { accessToken } }) + expect(authResponse.ok()).toBe(true) + const auth = await authResponse.json() as { token: string } + const headers = { authorization: `Bearer ${auth.token}` } + const globalResponse = await request.get( + `${baseUrl}/api/sessions/content-search?query=${encodeURIComponent(query)}&limit=100`, + { headers } + ) + expect(globalResponse.ok()).toBe(true) + const globalContent = await globalResponse.json() as { + results: Array<{ session: { id: string } }> + } + + let singleHit: { sessionId: string; messageId: string } | null = null + for (const result of globalContent.results) { + const response = await request.get( + `${baseUrl}/api/sessions/${result.session.id}/content-search?query=${encodeURIComponent(query)}&limit=500`, + { headers } + ) + if (!response.ok()) continue + const body = await response.json() as { + matches: Array<{ messageId: string }> + total: number + } + if (body.total === 1 && body.matches.length === 1) { + singleHit = { + sessionId: result.session.id, + messageId: body.matches[0]!.messageId + } + break + } + } + if (!singleHit) { + test.skip(true, 'The live database has no session with exactly one matching message') + return + } + + await installHapiAuth(page, baseUrl, accessToken) + await page.addInitScript(() => { + localStorage.setItem('hapi-appearance', 'dark') + localStorage.removeItem('hapi-color-theme') + }) + await page.goto( + `${baseUrl}/sessions/${singleHit.sessionId}?messageId=${encodeURIComponent(singleHit.messageId)}&messageQuery=${encodeURIComponent(query)}`, + { waitUntil: 'domcontentloaded', timeout: 60_000 } + ) + + const navigation = page.getByTestId('search-match-navigation') + await expect(navigation).toBeVisible({ timeout: 60_000 }) + await expect(navigation).toContainText('1/1') + const previous = navigation.getByRole('button', { name: 'Previous match' }) + const next = navigation.getByRole('button', { name: 'Next match' }) + await expect(previous).toBeEnabled() + await expect(next).toBeEnabled() + + const match = page.locator('[data-hapi-source-search-match="true"]') + await expect(match).toHaveAttribute('data-hapi-source-message-id', singleHit.messageId, { + timeout: 60_000 + }) + const gotIt = page.getByRole('button', { name: 'Got it' }) + if (await gotIt.isVisible().catch(() => false)) { + await gotIt.click() + } + const screenshotPath = process.env.HAPI_SINGLE_RESULT_SCREENSHOT + if (screenshotPath) { + await page.screenshot({ path: screenshotPath }) + } + + await previous.click() + await expect(match).toHaveAttribute('data-hapi-source-message-id', singleHit.messageId, { + timeout: 30_000 + }) + await next.click() + await expect(match).toHaveAttribute('data-hapi-source-message-id', singleHit.messageId, { + timeout: 30_000 + }) +}) + +test('content search loads and opens a matching message outside the initial latest page', async ({ page, request }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const accessToken = readCliAccessToken() + const authResponse = await request.post(`${baseUrl}/api/auth`, { + data: { accessToken } + }) + expect(authResponse.ok()).toBe(true) + const auth = await authResponse.json() as { token: string } + const headers = { authorization: `Bearer ${auth.token}` } + + const contentResponse = await request.get( + `${baseUrl}/api/sessions/content-search?query=cache&limit=20`, + { headers } + ) + expect(contentResponse.ok()).toBe(true) + const content = await contentResponse.json() as { + results: Array<{ + session: { id: string } + match: { messageId: string; snippet: string; seq: number } + }> + } + + let coldHit: (typeof content.results)[number] | null = null + for (const candidate of content.results) { + const latestResponse = await request.get( + `${baseUrl}/api/sessions/${candidate.session.id}/messages?limit=200`, + { headers } + ) + if (!latestResponse.ok()) continue + const latest = await latestResponse.json() as { + messages: Array<{ id: string; seq?: number }> + } + const oldestSeq = latest.messages.reduce((oldest, message) => ( + typeof message.seq === 'number' + ? (oldest === null ? message.seq : Math.min(oldest, message.seq)) + : oldest + ), null) + if ( + !latest.messages.some((message) => message.id === candidate.match.messageId) + && oldestSeq !== null + && candidate.match.seq < oldestSeq - 200 + ) { + coldHit = candidate + break + } + } + if (!coldHit) { + test.skip(true, 'The live database has no content-search hit older than the initial latest page') + return + } + + await page.route( + `**/api/sessions/${coldHit.session.id}/messages/${coldHit.match.messageId}/context`, + async (route) => { + await new Promise((resolve) => setTimeout(resolve, 500)) + await route.continue() + } + ) + + const contextRequests: string[] = [] + page.on('request', (request) => { + if (request.url().includes(`/messages/${coldHit!.match.messageId}/context`)) { + contextRequests.push(request.url()) + } + }) + + await installHapiAuth(page, baseUrl, accessToken) + await page.addInitScript(() => { + localStorage.setItem('hapi-appearance', 'dark') + localStorage.removeItem('hapi-color-theme') + }) + await page.goto(`${baseUrl}/sessions`, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + + const searchButton = page.getByRole('button', { name: /Search sessions/ }).first() + await expect(searchButton).toBeVisible({ timeout: 30_000 }) + await searchButton.click() + + const searchInput = page.getByRole('searchbox') + await expect(searchInput).toBeVisible() + await page.getByTestId('session-list-search-scope').click() + await searchInput.fill('cache') + + const row = page.locator('.session-list-item').filter({ + hasText: coldHit.match.snippet.slice(0, 20) + }).first() + await expect(row).toBeVisible({ timeout: 30_000 }) + await row.click() + + await expect.poll(() => new URL(page.url()).pathname).toBe(`/sessions/${coldHit.session.id}`) + const locatingStatus = page.getByTestId('search-target-status') + await expect(locatingStatus).toBeVisible({ timeout: 5_000 }) + await expect(locatingStatus).toContainText('Locating message') + const match = page.locator(`[${'data-hapi-source-search-match'}="true"]`) + await expect(match).toHaveCount(1, { timeout: 45_000 }) + await expect(match).toHaveAttribute('data-hapi-source-message-id', coldHit.match.messageId) + expect(contextRequests.length).toBeGreaterThan(0) + + const viewportHeight = await page.evaluate(() => window.innerHeight) + await expect.poll(async () => { + const matchBox = await match.boundingBox() + return matchBox !== null + && matchBox.y >= 0 + && matchBox.y < viewportHeight + }, { timeout: 30_000 }).toBe(true) +}) + +test('content search can jump to the same historical message repeatedly', async ({ page, request }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const accessToken = readCliAccessToken() + const authResponse = await request.post(`${baseUrl}/api/auth`, { + data: { accessToken } + }) + expect(authResponse.ok()).toBe(true) + const auth = await authResponse.json() as { token: string } + const headers = { authorization: `Bearer ${auth.token}` } + + const contentResponse = await request.get( + `${baseUrl}/api/sessions/content-search?query=cache&limit=20`, + { headers } + ) + expect(contentResponse.ok()).toBe(true) + const content = await contentResponse.json() as { + results: Array<{ + session: { id: string } + match: { messageId: string; snippet: string; seq: number } + }> + } + + let coldHit: (typeof content.results)[number] | null = null + for (const candidate of content.results) { + const latestResponse = await request.get( + `${baseUrl}/api/sessions/${candidate.session.id}/messages?limit=200`, + { headers } + ) + if (!latestResponse.ok()) continue + const latest = await latestResponse.json() as { + messages: Array<{ id: string; seq?: number }> + } + const oldestSeq = latest.messages.reduce((oldest, message) => ( + typeof message.seq === 'number' + ? (oldest === null ? message.seq : Math.min(oldest, message.seq)) + : oldest + ), null) + if ( + !latest.messages.some((message) => message.id === candidate.match.messageId) + && oldestSeq !== null + && candidate.match.seq < oldestSeq - 200 + ) { + coldHit = candidate + break + } + } + if (!coldHit) { + test.skip(true, 'The live database has no content-search hit older than the initial latest page') + return + } + + const contextRequests: string[] = [] + page.on('request', (request) => { + if (request.url().includes(`/messages/${coldHit!.match.messageId}/context`)) { + contextRequests.push(request.url()) + } + }) + + await installHapiAuth(page, baseUrl, accessToken) + await page.addInitScript(() => { + localStorage.setItem('hapi-appearance', 'dark') + localStorage.removeItem('hapi-color-theme') + }) + + let firstOpen = true + const openHistoricalResult = async () => { + if (firstOpen) { + await page.goto(`${baseUrl}/sessions`, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + firstOpen = false + } else { + await page.goBack({ waitUntil: 'domcontentloaded', timeout: 60_000 }) + } + const searchButton = page.getByRole('button', { name: /Search (sessions|message content)/ }).first() + await expect(searchButton).toBeVisible({ timeout: 30_000 }) + await searchButton.click() + const searchInput = page.getByRole('searchbox') + await expect(searchInput).toBeVisible() + await page.getByTestId('session-list-search-scope').click() + await searchInput.fill('cache') + + const row = page.locator('.session-list-item').filter({ + hasText: coldHit!.match.snippet.slice(0, 20) + }).first() + await expect(row).toBeVisible({ timeout: 30_000 }) + await row.click() + + await expect.poll(() => new URL(page.url()).pathname) + .toBe(`/sessions/${coldHit!.session.id}`) + const match = page.locator(`[${'data-hapi-source-search-match'}="true"]`) + await expect.poll(async () => { + const state = await match.evaluateAll((elements) => elements.map((element) => ({ + sourceMessageId: element.getAttribute('data-hapi-source-message-id'), + }))) + return state.length === 1 && state[0]?.sourceMessageId === coldHit!.match.messageId + }, { timeout: 45_000 }).toBe(true) + const viewportHeight = await page.evaluate(() => window.innerHeight) + + // The historical context can be committed before the initial tail + // reconciliation and assistant-ui runtime settle. Re-check after the + // settling window so a transiently correct jump cannot hide a later + // snap back to the latest messages. + await page.waitForTimeout(3_000) + await expect(match).toHaveCount(1) + await expect(match).toHaveAttribute('data-hapi-source-message-id', coldHit!.match.messageId) + await expect.poll(async () => { + const matchBox = await match.boundingBox() + return matchBox !== null + && matchBox.y >= 0 + && matchBox.y < viewportHeight + }, { timeout: 30_000 }).toBe(true) + } + + await openHistoricalResult() + await openHistoricalResult() + await openHistoricalResult() + expect(contextRequests.length).toBeGreaterThan(0) +}) + +test('content search locates the reported ancient hit without fetching the oversized latest page', async ({ page, request }) => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + const baseUrl = getHapiBaseUrl() + const accessToken = readCliAccessToken() + const authResponse = await request.post(`${baseUrl}/api/auth`, { data: { accessToken } }) + expect(authResponse.ok()).toBe(true) + const auth = await authResponse.json() as { token: string } + const headers = { authorization: `Bearer ${auth.token}` } + const contentResponse = await request.get( + `${baseUrl}/api/sessions/content-search?query=${encodeURIComponent('你好')}&limit=100`, + { headers } + ) + expect(contentResponse.ok()).toBe(true) + const content = await contentResponse.json() as { + results: Array<{ + session: { id: string } + match: { messageId: string; seq: number } + }> + } + const hit = content.results.find((result) => result.session.id === 'dd0e368b-06cb-4071-b8c3-cc82fb58494e') + if (!hit) { + test.skip(true, 'The live database no longer contains the reported ancient Chinese hit') + return + } + + const contextRequests: string[] = [] + const latestRequests: string[] = [] + page.on('request', (requestEvent) => { + const url = requestEvent.url() + if (url.includes(`/api/sessions/${hit.session.id}/messages/${hit.match.messageId}/context`)) { + contextRequests.push(url) + } else if (url.includes(`/api/sessions/${hit.session.id}/messages?`)) { + latestRequests.push(url) + } + }) + + await installHapiAuth(page, baseUrl, accessToken) + await page.addInitScript(() => { + localStorage.setItem('hapi-appearance', 'dark') + localStorage.removeItem('hapi-color-theme') + }) + await page.goto( + `${baseUrl}/sessions/${hit.session.id}?messageId=${encodeURIComponent(hit.match.messageId)}&messageQuery=${encodeURIComponent('你好')}`, + { waitUntil: 'domcontentloaded', timeout: 60_000 } + ) + + const match = page.locator('[data-hapi-source-search-match="true"]') + await expect(match).toHaveCount(1, { timeout: 60_000 }) + await expect(match).toHaveAttribute('data-hapi-source-message-id', hit.match.messageId) + await expect(page.getByTestId('search-target-status')).toHaveCount(0) + expect(contextRequests.length).toBeGreaterThan(0) + expect(latestRequests).toEqual([]) +}) diff --git a/web/playwright.content-search.live.config.ts b/web/playwright.content-search.live.config.ts new file mode 100644 index 00000000..c354a017 --- /dev/null +++ b/web/playwright.content-search.live.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +/** Live Hub session-content-search tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */ +export default defineConfig({ + testDir: './e2e', + testMatch: 'session-content-search-jump.spec.ts', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, +}) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 4211782b..021219be 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -357,6 +357,100 @@ describe('ApiClient error mapping', () => { }) }) +describe('ApiClient session content search', () => { + it('uses the opt-in content-search endpoint with encoded query, limit, and session scope', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ results: [] }), { status: 200 }) + ) + try { + const api = new ApiClient('test-token') + await api.searchSessionContent(' cache search ', 12, undefined, ['session /?#', 'session /?#', 'other']) + const request = fetchMock.mock.calls[0]?.[0] + const init = fetchMock.mock.calls[0]?.[1] + expect(String(request)).toBe('/api/sessions/content-search') + expect(init?.method).toBe('POST') + expect(init?.body).toBe(JSON.stringify({ + query: 'cache search', + limit: 12, + sessionIds: ['session /?#', 'other'] + })) + } finally { + fetchMock.mockRestore() + } + }) + + it('chunks oversized session scopes and merges results before applying the limit', async () => { + const requests: string[][] = [] + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { sessionIds: string[] } + requests.push(body.sessionIds) + const resultSessionId = body.sessionIds.at(-1) ?? '' + const index = Number(resultSessionId.match(/session-(\d+)/)?.[1] ?? 0) + return new Response(JSON.stringify({ + results: [{ + session: { id: resultSessionId, updatedAt: index }, + match: { messageId: `message-${index}`, role: 'user', seq: index, createdAt: index, snippet: `hit-${index}`, truncated: false } + }], + hasPotentiallyIncompleteResults: body.sessionIds.some(id => id.startsWith('session-0-')) + }), { status: 200 }) + }) + + try { + const sessionIds = Array.from({ length: 7_000 }, (_, index) => + `session-${index}-${'x'.repeat(32)}` + ) + const api = new ApiClient('test-token') + const response = await api.searchSessionContent('cache', 2, undefined, sessionIds) + + expect(requests.length).toBeGreaterThan(1) + expect(requests.flat()).toHaveLength(sessionIds.length) + expect(Math.max(...requests.map(chunk => new TextEncoder().encode(JSON.stringify(chunk)).byteLength))) + .toBeLessThan(192 * 1024) + const expectedTopIds = requests + .map(chunk => Number(chunk.at(-1)?.match(/session-(\d+)/)?.[1] ?? 0)) + .sort((a, b) => b - a) + .slice(0, 2) + expect(response.results.map(result => result.match.messageId)).toEqual([ + ...expectedTopIds.map(index => `message-${index}`) + ]) + expect(response.hasPotentiallyIncompleteResults).toBe(true) + } finally { + fetchMock.mockRestore() + } + }) + + it('requests an encoded message context endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(null), { status: 200 }) + ) + try { + const api = new ApiClient('test-token') + await expect(api.getMessageContext('session /?#', 'message /?#')).resolves.toBeNull() + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/sessions/session%20%2F%3F%23/messages/message%20%2F%3F%23/context' + ) + } finally { + fetchMock.mockRestore() + } + }) + + it('loads all matching messages for an opened session', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ matches: [], total: 0 }), { status: 200 }) + ) + try { + const api = new ApiClient('test-token') + await api.searchSessionContentMatches('session /?#', ' cache search ', 42) + const request = fetchMock.mock.calls[0]?.[0] + expect(String(request)).toContain( + '/api/sessions/session%20%2F%3F%23/content-search?query=cache+search&limit=42' + ) + } finally { + fetchMock.mockRestore() + } + }) +}) + describe('ApiClient Kimi session model discovery', () => { let originalFetch: typeof globalThis.fetch let fetchMock: ReturnType diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1f05d375..b12ea4a2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { CopilotAgentMode, FileSearchResponse, MachinesResponse, + MessageContextResponse, MessagesResponse, OpencodeImportSessionsResponse, OpencodeLocalSessionsResponse, @@ -29,8 +30,10 @@ import type { HapiSessionExportResponse, HubHealthResponse, SessionResponse, + SessionContentMatchesResponse, SessionTitleSuggestionResponse, - SessionsResponse + SessionsResponse, + SessionContentSearchResponse } from '@/types/api' import type { AgyModelsResponse, @@ -126,6 +129,58 @@ export interface TranscriptionCredentialsUpdate { elevenLabsMaxDurationSeconds?: number | null } +// Keep scoped content-search bodies comfortably below the Hub's 256 KiB +// request limit. The remaining space covers the query, limit, and JSON body +// framing; callers still get one merged result set for large visible scopes. +const CONTENT_SEARCH_SCOPE_CHUNK_BYTES = 192 * 1024 + +function chunkContentSearchSessionIds(sessionIds: readonly string[]): string[][] { + if (sessionIds.length === 0) return [] + + const encoder = new TextEncoder() + const chunks: string[][] = [] + let current: string[] = [] + let currentBytes = 2 // [] + + for (const sessionId of sessionIds) { + const itemBytes = encoder.encode(JSON.stringify(sessionId)).byteLength + const separatorBytes = current.length > 0 ? 1 : 0 + if ( + current.length > 0 + && currentBytes + separatorBytes + itemBytes > CONTENT_SEARCH_SCOPE_CHUNK_BYTES + ) { + chunks.push(current) + current = [] + currentBytes = 2 + } + + current.push(sessionId) + currentBytes += (current.length > 1 ? 1 : 0) + itemBytes + } + + if (current.length > 0) chunks.push(current) + return chunks +} + +function mergeContentSearchResponses( + responses: SessionContentSearchResponse[], + limit: number +): SessionContentSearchResponse { + const results = responses + .flatMap(response => response.results) + .sort((a, b) => ( + b.session.updatedAt - a.session.updatedAt + || b.match.seq - a.match.seq + || b.match.createdAt - a.match.createdAt + || a.session.id.localeCompare(b.session.id) + )) + + return { + results: results.slice(0, limit), + hasPotentiallyIncompleteResults: responses.some(response => response.hasPotentiallyIncompleteResults) + } +} + type ApiClientOptions = { baseUrl?: string getToken?: () => string | null @@ -291,6 +346,65 @@ export class ApiClient { return await this.request('/health') } + async searchSessionContent( + query: string, + limit: number = 50, + signal?: AbortSignal, + sessionIds?: readonly string[] + ): Promise { + const normalizedQuery = query.trim() + if (sessionIds !== undefined) { + const normalizedSessionIds = [...new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))] + const chunks = chunkContentSearchSessionIds(normalizedSessionIds) + if (chunks.length <= 1) { + return await this.request( + '/api/sessions/content-search', + { + method: 'POST', + signal, + body: JSON.stringify({ + query: normalizedQuery, + limit, + sessionIds: normalizedSessionIds + }) + } + ) + } + + const responses = await Promise.all(chunks.map((chunk) => this.request( + '/api/sessions/content-search', + { + method: 'POST', + signal, + body: JSON.stringify({ + query: normalizedQuery, + limit, + sessionIds: chunk + }) + } + ))) + return mergeContentSearchResponses(responses, limit) + } + const params = new URLSearchParams({ query: normalizedQuery, limit: String(limit) }) + return await this.request( + `/api/sessions/content-search?${params.toString()}`, + { signal } + ) + } + + async searchSessionContentMatches( + sessionId: string, + query: string, + limit: number = 500, + signal?: AbortSignal + ): Promise { + const params = new URLSearchParams({ query: query.trim(), limit: String(limit) }) + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/content-search?${params.toString()}`, + { signal } + ) + } + async getPushVapidPublicKey(): Promise { return await this.request('/api/push/vapid-public-key') } @@ -454,6 +568,12 @@ export class ApiClient { return await this.request(url) } + async getMessageContext(sessionId: string, messageId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/context` + ) + } + async getGitStatus(sessionId: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-status`) } diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 81b5e234..07ba9569 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,7 +1,8 @@ import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, RoundModelUsage, RoundSummary, ToolResultPermission, UsageData } from '@/chat/types' import { inlineMediaSourceFromWire } from '@/chat/inlineMediaSource' import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol' -import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' +import { getAgyTaskLogId, isClaudeChatVisibleMessage, stripAgyEchoedTaskResult } from '@hapi/protocol/messages' +export { stripAgyEchoedTaskResult } from '@hapi/protocol/messages' import { parseAgentTimestampMs } from '@/chat/agentTimestamp' function normalizeToolResultPermissions(value: unknown): ToolResultPermission | undefined { @@ -539,16 +540,6 @@ export function stripAgyReadArtifacts(content: string): string { .trimEnd() } -// agy sometimes echoes an async task's raw result into its own PLANNER_RESPONSE -// prose: "Inside the task-246 log…\n[Message] timestamp=… content=Task id … finished -// with result: … Output: …". That echoed block duplicates the background-task card -// rendered from the corresponding SYSTEM_MESSAGE (and leaks the raw `[Message]` -// framing), so strip it from the "…log…" marker onward and keep only the agent's -// narration. The narration itself is the agent's real words — left intact. -export function stripAgyEchoedTaskResult(text: string): string { - return text.replace(/\n*\[Message\]\s+timestamp=[\s\S]*$/, '').trim() -} - // Canonical tool id for agy's transitional "Inside the task-NNN log…" narration, // rendered as a compact Send-Message-style chip instead of a full agent bubble. export const AGY_TASK_LOG_TOOL = 'AgyTaskLog' @@ -881,8 +872,8 @@ export function normalizeAgentRecord( // Send-Message-style chip, not a full agent bubble. The actual task // result renders in its own background-task card, so this stays a thin // marker of what the agent is doing. - const taskLog = text.match(/^Inside the task-(\d+) log\b/) - if (taskLog) { + const taskLogId = getAgyTaskLogId(text) + if (taskLogId) { const toolCallId = `${messageId}:tasklog` return { id: messageId, @@ -891,7 +882,7 @@ export function normalizeAgentRecord( role: 'agent', isSidechain: false, content: [ - { type: 'tool-call', id: toolCallId, name: AGY_TASK_LOG_TOOL, input: { task: `task-${taskLog[1]}` }, description: null, uuid: messageId, parentUUID: null }, + { type: 'tool-call', id: toolCallId, name: AGY_TASK_LOG_TOOL, input: { task: `task-${taskLogId}` }, description: null, uuid: messageId, parentUUID: null }, { type: 'tool-result', tool_use_id: toolCallId, content: '', is_error: false, uuid: messageId, parentUUID: null } ], meta diff --git a/web/src/chat/outline.test.ts b/web/src/chat/outline.test.ts index 152b4d92..e0a17e87 100644 --- a/web/src/chat/outline.test.ts +++ b/web/src/chat/outline.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import type { AgentEvent, ChatBlock } from '@/chat/types' -import { buildConversationOutline, truncateOutlineLabel } from '@/chat/outline' +import { + buildConversationOutline, + findConversationMessageAnchor, + findConversationMessageTextRange, + truncateOutlineLabel +} from '@/chat/outline' function userBlock( id: string, @@ -83,4 +88,99 @@ describe('conversation outline', () => { 'outline:user-text:second' ]) }) + + it('finds rendered block anchors for raw HAPI message ids', () => { + const direct = document.createElement('div') + direct.id = 'hapi-message-user-text:direct' + const rendered = document.createElement('div') + rendered.id = 'hapi-message-agent-text:raw-message:0' + const joined = document.createElement('div') + joined.id = 'hapi-message-tool-call:tool-1' + joined.setAttribute('data-hapi-source-message-ids', 'tool-source raw-message-2') + const fragment = document.createElement('div') + fragment.setAttribute('data-hapi-source-message-id', 'raw-message-3') + joined.append(fragment) + document.body.append(direct, rendered, joined) + + expect(findConversationMessageAnchor('direct')).toBe(direct) + expect(findConversationMessageAnchor('raw-message')).toBe(rendered) + expect(findConversationMessageAnchor('raw-message-2')).toBe(joined) + expect(findConversationMessageAnchor('raw-message-3')).toBe(fragment) + + direct.remove() + rendered.remove() + joined.remove() + }) + + it('anchors a terminal-row hit to its own part when two streams share a phrase', () => { + // Each stream kept a live and a terminal row, so the part exposes the + // live id singly but carries both. A hit on the later stream's + // terminal row must land on that part, not on the group marker (which + // would select the first occurrence in the whole reply). + const group = document.createElement('div') + group.setAttribute('data-hapi-source-message-ids', 'live-1 terminal-1 live-2 terminal-2') + + const firstPart = document.createElement('div') + firstPart.setAttribute('data-hapi-source-message-id', 'live-1') + firstPart.setAttribute('data-hapi-source-part-message-ids', 'live-1 terminal-1') + firstPart.textContent = 'The shared phrase appears here first' + + const secondPart = document.createElement('div') + secondPart.setAttribute('data-hapi-source-message-id', 'live-2') + secondPart.setAttribute('data-hapi-source-part-message-ids', 'live-2 terminal-2') + secondPart.textContent = 'The shared phrase appears here again' + + group.append(firstPart, secondPart) + document.body.append(group) + + expect(findConversationMessageAnchor('terminal-2')).toBe(secondPart) + expect(findConversationMessageAnchor('terminal-2', 'shared phrase')).toBe(secondPart) + expect(findConversationMessageAnchor('terminal-1', 'shared phrase')).toBe(firstPart) + // Group-wide ids still resolve for callers with no per-part marker. + expect(findConversationMessageAnchor('live-1')).toBe(firstPart) + + group.remove() + }) + + it('selects the rendered block containing the search query when a message is split', () => { + const first = document.createElement('div') + first.setAttribute('data-hapi-source-message-id', 'raw-message') + first.textContent = 'Earlier block' + const second = document.createElement('div') + second.setAttribute('data-hapi-source-message-id', 'raw-message') + second.textContent = 'The matching phrase is here' + document.body.append(first, second) + + expect(findConversationMessageAnchor('raw-message')).toBe(first) + expect(findConversationMessageAnchor('raw-message', 'matching phrase')).toBe(second) + + first.remove() + second.remove() + }) + + it('finds a normalized search phrase across rendered text nodes', () => { + const anchor = document.createElement('div') + anchor.append('KV ', document.createElement('strong')) + anchor.querySelector('strong')!.textContent = 'Cache' + anchor.append(' is reusable') + document.body.append(anchor) + + const range = findConversationMessageTextRange(anchor, ' kv cache ') + + expect(range).not.toBeNull() + expect(range!.toString()).toBe('KV Cache') + anchor.remove() + }) + + it('keeps highlights aligned after whitespace collapse and case folding', () => { + const anchor = document.createElement('div') + anchor.textContent = 'Use İSTANBUL' + document.body.append(anchor) + + const range = findConversationMessageTextRange(anchor, 'use i\u0307stanbul') + + expect(range).not.toBeNull() + expect(range!.toString()).toBe('Use İSTANBUL') + anchor.remove() + }) }) diff --git a/web/src/chat/outline.ts b/web/src/chat/outline.ts index f325bfaf..0bccda99 100644 --- a/web/src/chat/outline.ts +++ b/web/src/chat/outline.ts @@ -53,3 +53,107 @@ export function buildConversationOutline(blocks: readonly ChatBlock[]): Conversa export function getConversationMessageAnchorId(messageId: string): string { return `hapi-message-${messageId}` } + +/** + * Find a case-insensitive, whitespace-normalized query within one rendered + * message part. The returned range can cross Markdown text nodes, so callers + * can scroll to the actual matching phrase instead of centering a long card. + */ +export function findConversationMessageTextRange(anchor: HTMLElement, query: string): Range | null { + const normalizedQuery = query.trim().replace(/\s+/g, ' ').toLocaleLowerCase() + if (!normalizedQuery) return null + + const walker = document.createTreeWalker(anchor, NodeFilter.SHOW_TEXT) + const positions: Array<{ node: Text; offset: number }> = [] + let normalizedText = '' + let current: Node | null + while ((current = walker.nextNode())) { + const node = current as Text + for (let offset = 0; offset < node.data.length; offset += 1) { + const character = node.data[offset]! + if (/\s/.test(character)) { + if (normalizedText.endsWith(' ')) continue + normalizedText += ' ' + positions.push({ node, offset }) + } else { + const normalizedCharacter = character.toLocaleLowerCase() + normalizedText += normalizedCharacter + for (let normalizedOffset = 0; normalizedOffset < normalizedCharacter.length; normalizedOffset += 1) { + positions.push({ node, offset }) + } + } + } + } + + const matchAt = normalizedText.indexOf(normalizedQuery) + if (matchAt < 0) return null + const start = positions[matchAt] + const end = positions[matchAt + normalizedQuery.length - 1] + if (!start || !end) return null + + const range = document.createRange() + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset + 1) + return range +} + +/** + * Resolve an anchor for either a rendered assistant-ui message id or a raw + * HAPI message id. Assistant blocks append their source id after the block + * kind (for example, `agent-text::0`), so a raw search result + * does not always have the exact DOM id returned by getConversationMessageAnchorId. + */ +function getConversationMessageCandidates(messageId: string): HTMLElement[] { + const candidates: HTMLElement[] = [] + const seen = new Set() + const add = (element: HTMLElement | null) => { + if (!element || seen.has(element)) return + seen.add(element) + candidates.push(element) + } + + for (const element of document.querySelectorAll('[data-hapi-source-message-id]')) { + if (element.getAttribute('data-hapi-source-message-id') === messageId) add(element) + } + + // Per-part ids before the group-wide marker below: a block that kept both + // its live and terminal rows only exposes the first as + // data-hapi-source-message-id, and falling through to the group marker + // would match the first occurrence anywhere in the joined reply. + for (const element of document.querySelectorAll('[data-hapi-source-part-message-ids]')) { + const partIds = element.getAttribute('data-hapi-source-part-message-ids')?.split(/\s+/) ?? [] + if (partIds.includes(messageId)) add(element) + } + + add(document.getElementById(getConversationMessageAnchorId(messageId))) + + for (const element of document.querySelectorAll('[data-hapi-source-message-ids]')) { + const sourceIds = element.getAttribute('data-hapi-source-message-ids')?.split(/\s+/) ?? [] + if (sourceIds.includes(messageId)) add(element) + } + + const sourceMarker = `:${messageId}` + for (const element of document.querySelectorAll('[id]')) { + const candidate = element.id + if ( + candidate.startsWith('hapi-message-') + && ( + candidate.endsWith(sourceMarker) + || candidate.includes(`${sourceMarker}:`) + ) + ) { + add(element) + } + } + + return candidates +} + +export function findConversationMessageAnchor(messageId: string, query?: string): HTMLElement | null { + const candidates = getConversationMessageCandidates(messageId) + if (query) { + const matching = candidates.find((element) => findConversationMessageTextRange(element, query) !== null) + if (matching) return matching + } + return candidates[0] ?? null +} diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 570eb091..7d533501 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -156,6 +156,7 @@ function areUserTextBlocksEqual(left: UserTextBlock, right: UserTextBlock): bool function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): boolean { return left.text === right.text + && areStringArraysEqual(left.sourceMessageIds, right.sourceMessageIds) && left.localId === right.localId && left.createdAt === right.createdAt && left.meta === right.meta @@ -164,6 +165,7 @@ function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): b function areAgentReasoningBlocksEqual(left: AgentReasoningBlock, right: AgentReasoningBlock): boolean { return left.text === right.text + && areStringArraysEqual(left.sourceMessageIds, right.sourceMessageIds) && left.localId === right.localId && left.createdAt === right.createdAt && left.meta === right.meta diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index e15e35e0..84dba435 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { reduceChatBlocks } from './reducer' import { reconcileChatBlocks } from './reconcile' import { normalizeDecryptedMessage } from './normalize' -import type { NormalizedMessage } from './types' +import type { AgentTextBlock, NormalizedMessage } from './types' import type { DecryptedMessage } from '@/types/api' import type { AgentState, ThreadGoal, ThreadGoalStatus } from '@/types/api' @@ -639,4 +639,27 @@ describe('reduceChatBlocks', () => { const assistant = reconciled.blocks.find(block => block.kind === 'agent-text') expect(assistant?.kind === 'agent-text' ? assistant.roundSummary : undefined).toEqual(summary) }) + + it('keeps terminal stream source IDs when reconciling unchanged text', () => { + const previous: AgentTextBlock = { + kind: 'agent-text', + id: 'stream-1', + sourceMessageIds: ['live-row'], + localId: null, + createdAt: 2, + text: 'same answer' + } + const next: AgentTextBlock = { + ...previous, + sourceMessageIds: ['live-row', 'terminal-row'], + createdAt: 3 + } + + const reconciled = reconcileChatBlocks([next], new Map([[previous.id, previous]])) + const assistant = reconciled.blocks[0] + + expect(assistant).toBe(next) + expect(assistant.kind === 'agent-text' ? assistant.sourceMessageIds : undefined) + .toEqual(['live-row', 'terminal-row']) + }) }) diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index e8d8dd97..b36df605 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -587,7 +587,8 @@ describe('reduceTimeline', () => { expect(textBlocks).toHaveLength(2) expect(textBlocks[0]).toMatchObject({ id: 'text-stream-1', - text: 'first second' + text: 'first second', + sourceMessageIds: ['text-row-1', 'text-row-2'] }) expect(textBlocks[1]).toMatchObject({ id: 'text-row-3:0', diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index b19b77df..289a8f66 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -878,6 +878,10 @@ export function reduceTimeline( if (streamId) { const existing = textBlocksByStreamId.get(streamId) if (existing) { + existing.sourceMessageIds ??= [] + if (!existing.sourceMessageIds.includes(msg.id)) { + existing.sourceMessageIds.push(msg.id) + } existing.text = c.text existing.usage = msg.usage existing.model = msg.model @@ -889,13 +893,11 @@ export function reduceTimeline( const block: AgentTextBlock = { kind: 'agent-text', - // Streamed snapshots under one stream id arrive as - // separate message rows that the window keeps swapping - // for newer rows. Deriving the id from the stream id - // (unique per stream) keeps the block identity stable - // across snapshots so the rendered component is - // updated in place instead of being remounted. + // Stream-stable identity keeps the rendered component + // mounted while retaining every raw message ID for + // content-search navigation. id: streamId ?? `${msg.id}:${idx}`, + sourceMessageIds: [msg.id], localId: msg.localId, createdAt: msg.createdAt, invokedAt: msg.invokedAt, @@ -932,6 +934,10 @@ export function reduceTimeline( if (streamId) { const existing = reasoningBlocksByStreamId.get(streamId) if (existing) { + existing.sourceMessageIds ??= [] + if (!existing.sourceMessageIds.includes(msg.id)) { + existing.sourceMessageIds.push(msg.id) + } existing.text = c.text existing.usage = msg.usage existing.model = msg.model @@ -943,11 +949,10 @@ export function reduceTimeline( const block: AgentReasoningBlock = { kind: 'agent-reasoning', - // Same as agent-text above: a stream-stable id keeps - // the reasoning panel mounted while its snapshots - // arrive, so the smooth streaming continues from the - // previous text instead of replaying from a remount. + // Keep the reasoning panel mounted across snapshots, + // while preserving every source row for search jumps. id: streamId ?? `${msg.id}:${idx}`, + sourceMessageIds: [msg.id], localId: msg.localId, createdAt: msg.createdAt, invokedAt: msg.invokedAt, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index b9fa7085..b3e2cce6 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -240,6 +240,8 @@ export type UserTextBlock = { export type AgentTextBlock = { kind: 'agent-text' id: string + /** Raw HAPI message ids represented by a coalesced streaming block. */ + sourceMessageIds?: string[] localId: string | null createdAt: number invokedAt?: number | null @@ -254,6 +256,8 @@ export type AgentTextBlock = { export type AgentReasoningBlock = { kind: 'agent-reasoning' id: string + /** Raw HAPI message ids represented by a coalesced streaming block. */ + sourceMessageIds?: string[] localId: string | null createdAt: number invokedAt?: number | null diff --git a/web/src/components/AssistantChat/HappyThread.forward-scroll.test.ts b/web/src/components/AssistantChat/HappyThread.forward-scroll.test.ts new file mode 100644 index 00000000..dd97de51 --- /dev/null +++ b/web/src/components/AssistantChat/HappyThread.forward-scroll.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * Reading forward past the window limit trims rows off the old end. Message + * CSS disables browser scroll anchoring, so without capturing an anchor before + * the page is applied and restoring it afterwards, the unchanged scrollTop + * lands roughly a page later and jumps past the newly loaded messages + * (tiann/hapi#1598 review). + * + * The behaviour itself needs a browser to assert end to end; this guards the + * wiring so the anchor cannot be dropped from the forward path unnoticed. + */ +describe('forward history load scroll anchoring', () => { + const source = readFileSync(resolve(__dirname, 'HappyThread.tsx'), 'utf8') + + it('captures a scroll anchor before requesting a newer page', () => { + const trigger = source.slice( + source.indexOf('requestForwardPageRef.current = () => {'), + source.indexOf('.finally(() => { loadingNewerRef.current = false })') + ) + + expect(trigger).toContain('captureScrollAnchor') + expect(trigger).toContain('pendingForwardAnchorRef.current =') + // The protected row must be resolved when the page lands, not now. + expect(trigger).toContain('() => anchorMessageIdFromAnchor(pendingForwardAnchorRef.current)') + }) + + it('also requests a page when the context renders shorter than the viewport', () => { + // No scroll event ever fires in that case, so the scroll handler alone + // would strand the reader. + const effect = source.slice( + source.indexOf('if (props.hasMoreNewerMessages !== true) return'), + source.indexOf('}, [props.hasMoreNewerMessages,') + ) + + expect(effect).toContain('scrollHeight - viewport.clientHeight') + expect(effect).toContain('requestForwardPageRef.current()') + }) + + it('refreshes the pending anchor while the user scrolls during a load', () => { + const scrollHandler = source.slice( + source.indexOf('const pending = pendingScrollRef.current'), + source.indexOf('const needsCoverage = needsViewportCoverageRef.current()') + ) + + // Restoring the start-of-request anchor would drag a user who scrolled + // away back to where they were. + expect(scrollHandler).toContain('pendingForwardAnchorRef.current = captureScrollAnchor(viewport)') + }) + + it('restores that anchor once the loaded history version renders', () => { + const restoreEffect = source.slice( + source.indexOf('const forwardAnchor = pendingForwardAnchorRef.current'), + source.indexOf('}, [appliedHistoryVersion])') + ) + + expect(restoreEffect).toContain('restoreScrollAnchor') + // Clearing it matters: a stale anchor would fight the next scroll. + expect(restoreEffect).toContain('pendingForwardAnchorRef.current = null') + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx index 6b5e58db..b52769cd 100644 --- a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx @@ -1,9 +1,14 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import type { PropsWithChildren } from 'react' +import type { PropsWithChildren, ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' +const searchTargetTestState = vi.hoisted(() => ({ + extras: undefined as unknown, + renderMessages: (() => null) as () => ReactNode +})) + vi.mock('@/hooks/queries/useMachines', () => ({ useMachines: () => ({ machines: [] }) })) @@ -13,22 +18,24 @@ vi.mock('@assistant-ui/react', async (importOriginal) => { return { ...actual, useAuiState: (selector: (state: unknown) => unknown) => selector({ - thread: { extras: undefined } + thread: { extras: searchTargetTestState.extras } }), - unstable_useThreadMessageIds: () => [], + unstable_useThreadMessageIds: () => ['target-message'], ThreadPrimitive: { ...actual.ThreadPrimitive, Root: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
{children}
), Viewport: ({ children }: PropsWithChildren) => children, - Messages: () => null + Messages: () => searchTargetTestState.renderMessages(), + Unstable_MessageById: () => searchTargetTestState.renderMessages() } } }) import { HappyThread } from '@/components/AssistantChat/HappyThread' import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' import type { Session } from '@/types/api' const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo') @@ -46,7 +53,11 @@ class TestResizeObserver { disconnect() {} } -function renderThread(onViewModeChange = vi.fn(), unseenCount = 0) { +function renderThread( + onViewModeChange = vi.fn(), + unseenCount = 0, + onJumpToTail?: () => void +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -61,6 +72,7 @@ function renderThread(onViewModeChange = vi.fn(), unseenCount = 0) { disabled={false} onRefresh={vi.fn()} onViewModeChange={onViewModeChange} + onJumpToTail={onJumpToTail} isSyncingTail={false} messagesWarning={null} hasMoreMessages={false} @@ -122,6 +134,8 @@ afterEach(() => { cleanup() vi.clearAllTimers() vi.useRealTimers() + searchTargetTestState.extras = undefined + searchTargetTestState.renderMessages = () => null if (originalScrollTo) { Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo) } else { @@ -139,7 +153,89 @@ afterEach(() => { } }) +function renderSearchThread(options: { + initialTargetMessageId?: string + initialTargetMessageQuery?: string + searchRequestId?: number + onLoadMessageContext: (messageId: string) => Promise + onInitialTargetConsumed: () => void + onSearchTargetDismissed?: () => void + onViewModeChange?: (mode: 'tail' | 'history') => void + messagesVersion: number + historyVersion: number + rawMessagesCount: number +}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }) + const onViewModeChange = options.onViewModeChange ?? (() => {}) + const api = { + getHubSettings: vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }), + searchSessionContentMatches: vi.fn().mockResolvedValue({ + matches: options.initialTargetMessageId + ? [{ + messageId: options.initialTargetMessageId, + role: 'user' as const, + seq: 1, + createdAt: 1, + snippet: options.initialTargetMessageQuery ?? '' + }] + : [], + total: options.initialTargetMessageId ? 1 : 0 + }) + } as unknown as ApiClient + const renderHappyThread = () => ( + + + + + + ) + return { queryClient, renderHappyThread, onViewModeChange } +} + describe('mobile initial scroll settling', () => { + it('uses the explicit tail transition when a send requests a forced scroll', () => { + const onViewModeChange = vi.fn() + const onJumpToTail = vi.fn() + const { rerenderThread } = renderThread(onViewModeChange, 0, onJumpToTail) + onViewModeChange.mockClear() + + rerenderThread(1) + + expect(onJumpToTail).toHaveBeenCalledTimes(1) + expect(onViewModeChange).not.toHaveBeenCalledWith('tail') + }) + it('checks the final keyboard position before clearing intent at scrollend', () => { const { container, viewport } = renderThread() const sentinel = container.querySelector('.chat-scroll-content > [aria-hidden="true"]')! @@ -355,6 +451,470 @@ describe('explicit tail scrolling', () => { }) }) +describe('search target loading', () => { + it('keeps both navigation arrows active for a single result and re-jumps to it', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => ( +
target
+ ) + + const { queryClient, renderHappyThread } = renderSearchThread({ + initialTargetMessageId: 'target-message', + initialTargetMessageQuery: 'target', + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + }) + const result = render(renderHappyThread()) + act(() => { + queryClient.setQueryData(queryKeys.sessionContentSearch('search-target-session', 'target'), { + matches: [{ + messageId: 'target-message', + role: 'user' as const, + seq: 1, + createdAt: 1, + snippet: 'target' + }], + total: 1 + }) + result.rerender(renderHappyThread()) + }) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + const previous = result.getByRole('button', { name: 'Previous match' }) + expect(previous).not.toBeDisabled() + const next = result.getByRole('button', { name: 'Next match' }) + expect(next).not.toBeDisabled() + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + + fireEvent.click(previous) + await act(async () => { + await Promise.resolve() + }) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(2) + + fireEvent.click(next) + await act(async () => { + await Promise.resolve() + }) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(3) + }) + + it('waits for assistant-ui to apply the current message window before anchoring', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => ( +
stale target
+ ) + + const options = { + initialTargetMessageId: 'target-message', + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 2, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + expect(result.container.querySelector('[data-testid="search-target-status"]')?.textContent) + .toContain('Locating message') + expect(onLoadMessageContext).not.toHaveBeenCalled() + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 0 } + await act(async () => { + result.rerender(renderHappyThread()) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + expect(result.container.querySelector('[data-testid="search-target-status"]')).not.toBeInTheDocument() + }) + + it('refreshes cached server context before anchoring an already-rendered target', async () => { + let resolveContext: ((loaded: boolean) => void) | undefined + const onLoadMessageContext = vi.fn(() => new Promise((resolve) => { + resolveContext = resolve + })) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => ( +
cached target
+ ) + + const { renderHappyThread } = renderSearchThread({ + initialTargetMessageId: 'target-message', + searchRequestId: 7, + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + }) + render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadMessageContext).toHaveBeenCalledWith('target-message') + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + + await act(async () => { + resolveContext?.(true) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + }) + + it('clears a fallback card highlight when the search target is dismissed', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => ( +
target card
+ ) + + const options = { + initialTargetMessageId: 'target-message' as string | undefined, + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1, + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + + const target = result.container.querySelector('[data-hapi-source-message-id="target-message"]') + expect(target).toHaveClass('hapi-message-search-target') + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + + options.initialTargetMessageId = undefined + options.messagesVersion = 2 + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 0 } + act(() => { + result.rerender(renderHappyThread()) + }) + + expect(target).not.toHaveClass('hapi-message-search-target') + }) + + it('visibly highlights a markdown match spanning rendered text nodes', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => ( +
+ KV Cache +
+ ) + + const { renderHappyThread } = renderSearchThread({ + initialTargetMessageId: 'target-message', + initialTargetMessageQuery: 'KV Cache', + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + }) + const result = render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + + const markers = result.container.querySelectorAll( + '[data-hapi-source-search-match="true"]' + ) + expect(markers.length).toBeGreaterThan(1) + expect(Array.from(markers).map((marker) => marker.textContent).join('')) + .toContain('KV Cache') + expect(result.container.querySelector('strong [data-hapi-source-search-match="true"]')) + .toBeInTheDocument() + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + }) + + it('loads context after the runtime catches up when the target is not in the latest window', () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => null + + const options = { + initialTargetMessageId: 'target-message', + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 2, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + expect(onLoadMessageContext).not.toHaveBeenCalled() + + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 0 } + act(() => { + result.rerender(renderHappyThread()) + }) + + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + }) + + it('waits for assistant-ui to render a loaded context instead of requesting it again', async () => { + let resolveContext: ((loaded: boolean) => void) | undefined + const onLoadMessageContext = vi.fn(() => new Promise((resolve) => { + resolveContext = resolve + })) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => null + + const options = { + initialTargetMessageId: 'target-message', + initialTargetMessageQuery: 'cache', + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + expect(resolveContext).toBeDefined() + + await act(async () => { + resolveContext?.(true) + await Promise.resolve() + }) + + result.rerender(renderHappyThread()) + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + + searchTargetTestState.renderMessages = () => ( +
cache hit
+ ) + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 1 } + options.messagesVersion = 2 + options.historyVersion = 1 + options.rawMessagesCount = 3 + act(() => { + result.rerender(renderHappyThread()) + }) + + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + expect(result.container.querySelector('[data-hapi-source-search-match="true"]')).toBeInTheDocument() + }) + + it('does not let a later initial render reset a consumed historical jump to tail', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + const onViewModeChange = vi.fn<(mode: 'tail' | 'history') => void>() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => null + + const options = { + initialTargetMessageId: 'target-message' as string | undefined, + onLoadMessageContext, + onInitialTargetConsumed, + onViewModeChange, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + searchTargetTestState.renderMessages = () => ( +
target
+ ) + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 1 } + options.messagesVersion = 2 + options.historyVersion = 1 + options.rawMessagesCount = 2 + act(() => { + result.rerender(renderHappyThread()) + }) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + + onViewModeChange.mockClear() + options.initialTargetMessageId = undefined + options.messagesVersion = 3 + act(() => { + result.rerender(renderHappyThread()) + }) + + expect(onViewModeChange).not.toHaveBeenCalledWith('tail') + }) + + it('retries a transient context rejection without clearing the route target', async () => { + const onLoadMessageContext = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => null + + const options = { + initialTargetMessageId: 'target-message', + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(75) + }) + await act(async () => { + await Promise.resolve() + }) + expect(onLoadMessageContext).toHaveBeenCalledTimes(2) + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + + searchTargetTestState.renderMessages = () => ( +
target
+ ) + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 1 } + options.messagesVersion = 2 + options.historyVersion = 1 + options.rawMessagesCount = 2 + act(() => { + result.rerender(renderHappyThread()) + }) + + expect(onLoadMessageContext).toHaveBeenCalledTimes(2) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + }) + + it('dismisses the route target after terminal context-load failure', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(false) + const onInitialTargetConsumed = vi.fn() + const onSearchTargetDismissed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => null + + const options = { + initialTargetMessageId: 'target-message', + onLoadMessageContext, + onInitialTargetConsumed, + onSearchTargetDismissed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + + for (let attempt = 0; attempt < 3; attempt += 1) { + act(() => { + vi.advanceTimersByTime(75) + }) + await act(async () => { + await Promise.resolve() + }) + } + + expect(onLoadMessageContext).toHaveBeenCalledTimes(4) + expect(onSearchTargetDismissed).toHaveBeenCalledTimes(1) + expect(onInitialTargetConsumed).not.toHaveBeenCalled() + }) + + it('resets a consumed target so the same result can be opened again', async () => { + const onLoadMessageContext = vi.fn().mockResolvedValue(true) + const onInitialTargetConsumed = vi.fn() + searchTargetTestState.extras = { messagesVersion: 1, historyVersion: 0 } + searchTargetTestState.renderMessages = () => null + + const options = { + initialTargetMessageId: 'target-message' as string | undefined, + searchRequestId: undefined as number | undefined, + onLoadMessageContext, + onInitialTargetConsumed, + messagesVersion: 1, + historyVersion: 0, + rawMessagesCount: 1 + } + const { renderHappyThread } = renderSearchThread(options) + const result = render(renderHappyThread()) + + await act(async () => { + await Promise.resolve() + }) + expect(onLoadMessageContext).toHaveBeenCalledTimes(1) + + searchTargetTestState.renderMessages = () => ( +
target
+ ) + searchTargetTestState.extras = { messagesVersion: 2, historyVersion: 1 } + options.messagesVersion = 2 + options.historyVersion = 1 + options.rawMessagesCount = 2 + act(() => { + result.rerender(renderHappyThread()) + }) + expect(onInitialTargetConsumed).toHaveBeenCalledTimes(1) + + options.searchRequestId = 1 + searchTargetTestState.renderMessages = () => null + searchTargetTestState.extras = { messagesVersion: 3, historyVersion: 2 } + options.messagesVersion = 3 + options.historyVersion = 2 + act(() => { + result.rerender(renderHappyThread()) + }) + + await act(async () => { + await Promise.resolve() + }) + expect(onLoadMessageContext).toHaveBeenCalledTimes(2) + }) +}) describe('tail following survives layout-driven movement', () => { it('re-pins to the tail when content grows without a user gesture', () => { const { viewport, onViewModeChange } = renderThread() diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index 89d0f2e7..333279ca 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -4,6 +4,7 @@ import { useState, type ComponentProps } from 'react' import { I18nProvider } from '@/lib/i18n-context' import { ConversationOutlinePanel, + anchorMessageIdFromAnchor, captureScrollAnchor, getHistoryCoverageRetryDelay, getPullToLoadState, @@ -435,6 +436,24 @@ describe('scroll anchor helpers', () => { expect(shouldCancelInitialScrollSettling(intent, false)).toBe(false) }) + it('resolves a streamed card anchor to its raw message id, not the stream id', () => { + // A Pi assistant card renders with its stream id in the DOM id, while + // the store protects rows by canonical message id. + const group = document.createElement('div') + group.id = 'hapi-message-agent-text:stream-abc:0' + group.setAttribute('data-hapi-source-message-ids', 'raw-msg-1 raw-msg-2') + document.body.append(group) + + expect(anchorMessageIdFromAnchor({ id: group.id, topOffset: 0 })).toBe('raw-msg-1') + + group.remove() + }) + + it('falls back to the DOM id for kinds that embed a raw message id', () => { + expect(anchorMessageIdFromAnchor({ id: 'hapi-message-user-text:raw-msg-9', topOffset: 0 })) + .toBe('raw-msg-9') + }) + it('restores the captured message to the same viewport offset', () => { const viewport = document.createElement('div') const message = document.createElement('div') diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 1a3e0a18..bbe8b680 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -5,9 +5,13 @@ import type { ComponentProps } from 'react' import { useQuery } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { HappyRuntimeExtras } from '@/lib/assistant-runtime' -import type { Session, SessionMetadataSummary } from '@/types/api' +import type { Session, SessionContentMatch, SessionMetadataSummary } from '@/types/api' import type { ConversationOutlineItem } from '@/chat/outline' -import { getConversationMessageAnchorId } from '@/chat/outline' +import { + findConversationMessageAnchor, + findConversationMessageTextRange, + getConversationMessageAnchorId +} from '@/chat/outline' import { formatMessageTimestampTitle, formatOutlineTimestamp } from '@/chat/presentation' import { HappyChatProvider, @@ -34,9 +38,9 @@ import { getSessionProjectLabel, getSessionProjectPath } from '@/lib/sessionProj import { formatRelativeTime } from '@/lib/relativeTime' import { formatSessionHeaderTimestamp } from '@/lib/sessionHeaderTimestamp' import { getShareTurnReasoningLabel, selectShareTurnMetadata } from '@/lib/shareTurnMetadata' -import { useMinuteTick } from '@/hooks/useMinuteTick' import { useTransientScrollbar } from '@/hooks/useTransientScrollbar' import { queryKeys } from '@/lib/query-keys' +import { useMinuteTick } from '@/hooks/useMinuteTick' import { matchesSearchQuery } from '@hapi/protocol' type ScrollAnchor = { @@ -63,6 +67,8 @@ type HistoryLoaderState = { autoPaused: boolean } +type SearchTargetPhase = 'idle' | 'loading' | 'waiting-runtime' | 'waiting-render' | 'complete' | 'failed' + type ShareTurnState = { id: number snapshots: ShareTurnSnapshot[] @@ -124,6 +130,16 @@ export function prependMissingUserSnapshot( } const MESSAGE_ANCHOR_SELECTOR = '.happy-thread-messages > [id]' +const SEARCH_TARGET_HIGHLIGHT_CLASS = 'hapi-message-search-target' +const SEARCH_TARGET_MATCH_ATTRIBUTE = 'data-hapi-source-search-match' +const SEARCH_TARGET_QUERY_RETRY_DELAY_MS = 75 +const MAX_SEARCH_TARGET_QUERY_RETRIES = 80 +const MAX_SEARCH_TARGET_CONTEXT_RETRIES = 3 +const MAX_SEARCH_TARGET_RENDER_RETRIES = 120 +const SEARCH_TARGET_SCROLL_DELAYS_MS = [ + 0, 16, 50, 120, 250, 500, 900, 1400, 2200, 3500, 5000, 7000, + 10_000, 15_000, 22_000, 30_000, 45_000 +] as const // Resume tail-following only once the user has actually reached the bottom. // A wider proximity threshold makes a downward-reading user enter tail mode // early; the next content/layout update then snaps the viewport to the end. @@ -220,6 +236,37 @@ export function shouldCancelInitialScrollSettling( && intent.distanceFromBottom > MANUAL_SCROLL_EPSILON_PX } +/** + * Recover the raw message id behind a scroll anchor so the store can protect + * that row from trimming. + * + * Parsing the rendered DOM id is not enough: a streamed Pi assistant card + * carries its *stream* id there, while the store matches canonical + * `message.id` / `localId`. Prefer the source metadata the renderer already + * emits, and fall back to the id only for kinds that embed a raw id. + */ +export function anchorMessageIdFromAnchor(anchor: ScrollAnchor | null): string | null { + if (!anchor) return null + + const element = document.getElementById(anchor.id) + if (element) { + const own = element + .closest('[data-hapi-source-message-id]') + ?.getAttribute('data-hapi-source-message-id') + if (own) return own + + for (const attribute of ['data-hapi-source-part-message-ids', 'data-hapi-source-message-ids']) { + const ids = element.closest(`[${attribute}]`)?.getAttribute(attribute) + const first = ids?.split(/\s+/).filter(Boolean)[0] + if (first) return first + } + } + + const withoutPrefix = anchor.id.replace(/^hapi-message-/, '') + const parts = withoutPrefix.split(':') + return parts.length > 1 ? parts[1]! : null +} + export function captureScrollAnchor(viewport: HTMLElement): ScrollAnchor | null { const viewportRect = viewport.getBoundingClientRect() const messages = Array.from(viewport.querySelectorAll(MESSAGE_ANCHOR_SELECTOR)) @@ -266,6 +313,120 @@ export async function locateOutlineTargetMessage(options: LocateOutlineTargetOpt return target } +function createSearchMatchMarkerElement(sourceMessageId: string | null): HTMLElement { + const marker = document.createElement('mark') + marker.className = SEARCH_TARGET_HIGHLIGHT_CLASS + marker.setAttribute(SEARCH_TARGET_MATCH_ATTRIBUTE, 'true') + if (sourceMessageId) { + marker.setAttribute('data-hapi-source-message-id', sourceMessageId) + } + return marker +} + +function getSearchMatchTextSegments(range: Range): Array<{ node: Text; start: number; end: number }> { + const segments: Array<{ node: Text; start: number; end: number }> = [] + const root = range.commonAncestorContainer.nodeType === Node.TEXT_NODE + ? range.commonAncestorContainer.parentNode + : range.commonAncestorContainer + if (!root) return segments + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let current: Node | null + while ((current = walker.nextNode())) { + const node = current as Text + if (!range.intersectsNode(node)) continue + const start = node === range.startContainer ? range.startOffset : 0 + const end = node === range.endContainer ? range.endOffset : node.data.length + if (end > start) segments.push({ node, start, end }) + } + return segments +} + +function createSearchMatchMarker(range: Range, sourceMessageId: string | null): HTMLElement | null { + const marker = createSearchMatchMarkerElement(sourceMessageId) + + try { + range.surroundContents(marker) + return marker + } catch { + // Markdown can split one logical match across several text nodes, + // such as `KV Cache`. Wrapping the entire range then + // throws because it would partially contain the strong element. Wrap + // each intersecting text segment instead so the matched text remains + // visibly highlighted without disturbing the markdown structure. + const markers: HTMLElement[] = [] + for (const segment of getSearchMatchTextSegments(range)) { + const segmentMarker = createSearchMatchMarkerElement(sourceMessageId) + const segmentRange = document.createRange() + segmentRange.setStart(segment.node, segment.start) + segmentRange.setEnd(segment.node, segment.end) + try { + segmentRange.surroundContents(segmentMarker) + markers.push(segmentMarker) + } catch { + // Continue with other text nodes; a class-only card fallback + // below still keeps the target visibly identifiable. + } + } + if (markers.length > 0) return markers[0]! + + const fallback = range.commonAncestorContainer instanceof HTMLElement + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement + fallback?.classList.add(SEARCH_TARGET_HIGHLIGHT_CLASS) + return fallback + } +} + +function findScrollableAncestor(element: HTMLElement): HTMLElement | null { + let current = element.parentElement + while (current) { + const overflowY = window.getComputedStyle(current).overflowY + if ( + current.scrollHeight > current.clientHeight + 1 + && (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') + ) { + return current + } + current = current.parentElement + } + return null +} + +function scrollSearchTargetIntoView(target: HTMLElement, preferredViewport?: HTMLElement | null): void { + const viewport = preferredViewport ?? findScrollableAncestor(target) + if (!viewport) { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }) + return + } + + const targetRect = target.getBoundingClientRect() + const viewportRect = viewport.getBoundingClientRect() + const targetCenter = (targetRect.top + targetRect.bottom) / 2 + const viewportCenter = (viewportRect.top + viewportRect.bottom) / 2 + const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + const nextScrollTop = Math.min( + maxScrollTop, + Math.max(0, viewport.scrollTop + targetCenter - viewportCenter) + ) + viewport.scrollTo({ top: nextScrollTop, behavior: 'auto' }) +} + +function removeSearchMatchMarker(marker: HTMLElement, target: HTMLElement): void { + if (marker === target) { + marker.classList.remove(SEARCH_TARGET_HIGHLIGHT_CLASS) + return + } + + const parent = marker.parentNode + if (!parent) return + while (marker.firstChild) { + parent.insertBefore(marker.firstChild, marker) + } + marker.remove() + parent.normalize() +} + export function shouldLoadOlderForViewport(params: { scrollHeight: number clientHeight: number @@ -542,12 +703,17 @@ export function HappyThread(props: { onRewindConversation?: (messageLocalId: string) => Promise isLatestCompletedBoundary?: (messageId: string) => boolean onViewModeChange: (mode: 'tail' | 'history') => void + onJumpToTail?: () => void readThroughRevision?: number isSyncingTail: boolean messagesWarning: string | null hasMoreMessages: boolean + /** A context window still has messages between it and the session head. */ + hasMoreNewerMessages?: boolean isLoadingMoreMessages: boolean onLoadMore: (onBeforeApply?: (historyVersion: number) => boolean) => Promise + /** Walk a context window one page forward; resolves true once caught up. */ + onLoadNewer?: (resolvePreserveMessageId?: () => string | null) => Promise onCancelLoadMore: () => void unseenCount: number rawMessagesCount: number @@ -559,6 +725,12 @@ export function HappyThread(props: { outlineItems: readonly ConversationOutlineItem[] onOutlineOpenChange: (open: boolean) => void onOutlineItemClick?: (item: ConversationOutlineItem) => void + initialTargetMessageId?: string + initialTargetMessageQuery?: string + searchRequestId?: number + onLoadMessageContext?: (messageId: string) => Promise + onInitialTargetConsumed?: () => void + onSearchTargetDismissed?: () => void }) { const { t, locale } = useTranslation() const { preferences: headerMetadata } = useSessionHeaderMetadata() @@ -629,6 +801,39 @@ export function HappyThread(props: { retry: false, }) const showSessionSummaryInChat = hubSettingsQuery.data?.sessionSummaryInChat === true + const targetSearchQuery = props.initialTargetMessageQuery?.trim() ?? '' + const initialSearchTargetId = props.initialTargetMessageId?.trim() ?? '' + const hasSearchTarget = Boolean(initialSearchTargetId && targetSearchQuery) + const [activeSearchMatchId, setActiveSearchMatchId] = useState(null) + const activeSearchMatchRequestIdRef = useRef(undefined) + const sessionContentMatchesQuery = useQuery({ + queryKey: queryKeys.sessionContentSearch(props.sessionId, targetSearchQuery), + queryFn: ({ signal }) => props.api.searchSessionContentMatches( + props.sessionId, + targetSearchQuery, + 500, + signal + ), + enabled: hasSearchTarget, + staleTime: 30_000, + retry: false + }) + const searchMatches: SessionContentMatch[] = sessionContentMatchesQuery.data?.matches ?? [] + const activeSearchTargetId = activeSearchMatchRequestIdRef.current === props.searchRequestId + ? activeSearchMatchId ?? initialSearchTargetId + : initialSearchTargetId + const activeSearchMatchIndex = searchMatches.findIndex((match) => match.messageId === activeSearchTargetId) + const searchMatchTotal = sessionContentMatchesQuery.data?.total ?? searchMatches.length + const searchMatchTotalLabel = sessionContentMatchesQuery.isLoading + ? '…' + : searchMatchTotal > searchMatches.length + ? `${searchMatches.length}+` + : String(searchMatchTotal) + const searchMatchCurrentLabel = activeSearchMatchIndex >= 0 + ? String(activeSearchMatchIndex + 1) + : sessionContentMatchesQuery.isLoading + ? '…' + : '—' const runtimeExtras = useAuiState((s) => s.thread.extras) as HappyRuntimeExtras | undefined const appliedMessagesVersion = runtimeExtras?.messagesVersion ?? props.messagesVersion const appliedHistoryVersion = runtimeExtras?.historyVersion ?? props.historyVersion @@ -636,6 +841,8 @@ export function HappyThread(props: { useTransientScrollbar(viewportRef, 'right') const contentRef = useRef(null) const [pullToLoadState, setPullToLoadState] = useState('idle') + const [searchTargetRetryVersion, setSearchTargetRetryVersion] = useState(0) + const [isLocatingSearchTarget, setIsLocatingSearchTarget] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const pullToLoadStateRef = useRef('idle') const shareTurnIdRef = useRef(0) @@ -696,6 +903,31 @@ export function HappyThread(props: { const initialScrollSessionRef = useRef(null) const initialScrollDeadlineRef = useRef(0) const initialScrollTimersRef = useRef([]) + const searchTargetJumpRef = useRef<{ + messageId: string + messageQuery?: string + requestId?: number + phase: SearchTargetPhase + } | null>(null) + // Keep automatic initial/tail reconciliation from reclaiming the viewport + // after a historical search result has been applied. The lock is released + // by an explicit jump to the latest messages or by a new session/target. + const searchTargetHistoryLockRef = useRef(false) + const hasMoreNewerRef = useRef(props.hasMoreNewerMessages === true) + // A forward page can trim 200 rows off the old end of the window. Message + // CSS disables browser scroll anchoring, so without restoring an anchor + // the unchanged scrollTop lands ~200 messages later. + const pendingForwardAnchorRef = useRef(null) + const requestForwardPageRef = useRef<() => void>(() => {}) + const onLoadNewerRef = useRef(props.onLoadNewer) + const loadingNewerRef = useRef(false) + const searchTargetInputKeyRef = useRef(null) + const searchTargetValidatedRequestKeyRef = useRef(null) + const searchTargetRetryTimerRef = useRef(null) + const searchTargetQueryRetryCountRef = useRef(0) + const searchTargetContextRetryCountRef = useRef(0) + const searchTargetRenderRetryCountRef = useRef(0) + const searchTargetScrollTimersRef = useRef([]) // Smart scroll state: enabled only while the user is intentionally at the bottom. const autoScrollEnabledRef = useRef(true) @@ -711,6 +943,71 @@ export function HappyThread(props: { useLayoutEffect(() => { isLoadingMoreRef.current = props.isLoadingMoreMessages }, [props.isLoadingMoreMessages]) + + useEffect(() => { + hasMoreNewerRef.current = props.hasMoreNewerMessages === true + }, [props.hasMoreNewerMessages]) + + useEffect(() => { + onLoadNewerRef.current = props.onLoadNewer + }, [props.onLoadNewer]) + + useEffect(() => { + requestForwardPageRef.current = () => { + if (!hasMoreNewerRef.current || loadingNewerRef.current) return + const load = onLoadNewerRef.current + if (!load) return + loadingNewerRef.current = true + const viewport = viewportRef.current + pendingForwardAnchorRef.current = viewport ? captureScrollAnchor(viewport) : null + // Resolved when the page is applied, so a reader who scrolled + // during the request keeps the row they are on rather than the + // one they started from. + void load(() => anchorMessageIdFromAnchor(pendingForwardAnchorRef.current)) + .finally(() => { loadingNewerRef.current = false }) + } + }, []) + + // A coalesced context (streamed cards, collapsed tool groups) can render + // shorter than the viewport. Then scrollTop never moves, no scroll event + // fires, and the reader is stranded with no way to ask for the next page. + // + // Keyed on the *applied* runtime version as well: replacing an overflowing + // window with a short context would otherwise measure the previous DOM, + // return, and never run again because nothing else changes. A resize + // observer covers late layout shifts for the same reason. + useEffect(() => { + if (props.hasMoreNewerMessages !== true) return + const viewport = viewportRef.current + if (!viewport) return + + let frame = 0 + const check = () => { + if (props.hasMoreNewerMessages !== true) return + if (viewport.scrollHeight - viewport.clientHeight > 2) return + requestForwardPageRef.current() + } + const schedule = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(check) + } + + schedule() + const observer = new ResizeObserver(schedule) + observer.observe(viewport) + const content = viewport.firstElementChild + if (content) observer.observe(content) + + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + } + }, [ + props.hasMoreNewerMessages, + props.historyVersion, + props.messagesVersion, + appliedHistoryVersion + ]) useEffect(() => { onLoadMoreRef.current = props.onLoadMore }, [props.onLoadMore]) @@ -722,6 +1019,36 @@ export function HappyThread(props: { sessionIdRef.current = props.sessionId }, [props.sessionId]) + useLayoutEffect(() => { + const inputKey = hasSearchTarget + ? `${props.sessionId}\u0000${initialSearchTargetId}\u0000${targetSearchQuery}\u0000${props.searchRequestId ?? 0}` + : null + if (searchTargetInputKeyRef.current === inputKey) return + searchTargetInputKeyRef.current = inputKey + activeSearchMatchRequestIdRef.current = props.searchRequestId + setActiveSearchMatchId(initialSearchTargetId || null) + }, [hasSearchTarget, initialSearchTargetId, props.searchRequestId, props.sessionId, targetSearchQuery]) + + const navigateSearchMatch = useCallback((direction: -1 | 1) => { + if (searchMatches.length === 0) return + const currentIndex = activeSearchMatchIndex >= 0 ? activeSearchMatchIndex : 0 + const nextIndex = (currentIndex + direction + searchMatches.length) % searchMatches.length + const nextMatch = searchMatches[nextIndex] + if (nextMatch) { + activeSearchMatchRequestIdRef.current = props.searchRequestId + // A single result still represents a useful navigation action: + // clicking either arrow should re-center the current match after + // the user has scrolled away. Reset the completed jump so the + // normal locating path runs again even though the message id is + // unchanged. + if (searchMatches.length === 1 && nextMatch.messageId === activeSearchTargetId) { + searchTargetJumpRef.current = null + setSearchTargetRetryVersion((version) => version + 1) + } + setActiveSearchMatchId(nextMatch.messageId) + } + }, [activeSearchMatchIndex, activeSearchTargetId, props.searchRequestId, searchMatches]) + const isInitialScrollSettling = useCallback(() => { return initialScrollSessionRef.current === sessionIdRef.current && Date.now() < initialScrollDeadlineRef.current }, []) @@ -732,6 +1059,88 @@ export function HappyThread(props: { } initialScrollTimersRef.current = [] }, []) + const clearSearchTargetScrollTimers = useCallback(() => { + for (const timer of searchTargetScrollTimersRef.current) { + window.clearTimeout(timer) + } + searchTargetScrollTimersRef.current = [] + }, []) + const clearSearchTargetRetryTimer = useCallback(() => { + if (searchTargetRetryTimerRef.current === null) return + window.clearTimeout(searchTargetRetryTimerRef.current) + searchTargetRetryTimerRef.current = null + }, []) + const scheduleSearchTargetRetry = useCallback((delay: number) => { + if (searchTargetRetryTimerRef.current !== null) return + searchTargetRetryTimerRef.current = window.setTimeout(() => { + searchTargetRetryTimerRef.current = null + setSearchTargetRetryVersion((version) => version + 1) + }, delay) + }, []) + const clearSearchTargetMarker = useCallback(() => { + const markers = Array.from(document.querySelectorAll( + `[${SEARCH_TARGET_MATCH_ATTRIBUTE}="true"]` + )) + for (const marker of markers) { + const parent = marker.parentElement + if (parent) { + removeSearchMatchMarker(marker, parent) + } + } + // A message without a text-range match uses a class-only card + // highlight. It has no marker attribute, so clean it up separately. + for (const fallback of Array.from(document.querySelectorAll( + `.${SEARCH_TARGET_HIGHLIGHT_CLASS}` + ))) { + if (fallback.getAttribute(SEARCH_TARGET_MATCH_ATTRIBUTE) !== 'true') { + fallback.classList.remove(SEARCH_TARGET_HIGHLIGHT_CLASS) + } + } + }, []) + const releaseSearchTargetHistoryLock = useCallback(() => { + searchTargetHistoryLockRef.current = false + clearSearchTargetScrollTimers() + clearSearchTargetRetryTimer() + clearSearchTargetMarker() + }, [clearSearchTargetMarker, clearSearchTargetRetryTimer, clearSearchTargetScrollTimers]) + const dismissSearchTarget = useCallback(() => { + releaseSearchTargetHistoryLock() + setActiveSearchMatchId(null) + setIsLocatingSearchTarget(false) + props.onSearchTargetDismissed?.() + }, [props.onSearchTargetDismissed, releaseSearchTargetHistoryLock]) + const scheduleSearchTargetScroll = useCallback(( + targetMessageId: string, + query: string | undefined, + fallbackTarget: HTMLElement + ) => { + clearSearchTargetScrollTimers() + searchTargetScrollTimersRef.current = SEARCH_TARGET_SCROLL_DELAYS_MS.map((delay) => window.setTimeout(() => { + const targetAnchor = findConversationMessageAnchor(targetMessageId, query) ?? fallbackTarget + let target = targetAnchor + if (query) { + const existingMarker = document.querySelector( + `[${SEARCH_TARGET_MATCH_ATTRIBUTE}="true"]` + ) + const markerBelongsToTarget = existingMarker + && targetAnchor.contains(existingMarker) + if (markerBelongsToTarget) { + target = existingMarker + } else { + const range = findConversationMessageTextRange(targetAnchor, query) + if (range) { + target = createSearchMatchMarker( + range, + targetMessageId + ) ?? targetAnchor + } + } + } + if (target.isConnected) { + scrollSearchTargetIntoView(target, viewportRef.current) + } + }, delay)) + }, [clearSearchTargetScrollTimers]) const clearCoverageCheckTimer = useCallback(() => { if (coverageCheckTimerRef.current !== null) { @@ -799,6 +1208,18 @@ export function HappyThread(props: { if (atBottom === atBottomRef.current) { return } + if (atBottom && searchTargetHistoryLockRef.current) { + // A clamped/programmatic scroll can look like the bottom was + // reached while the historical search window is being mounted. + // Do not let that transient event re-enter tail mode. + atBottomRef.current = false + // The window stops short of the head, so this bottom is the + // end of the loaded context rather than the end of the + // conversation. Pull the next page forward instead of + // stranding the reader (tiann/hapi#1598 review). + requestForwardPageRef.current() + return + } atBottomRef.current = atBottom onViewModeChangeRef.current(atBottom ? 'tail' : 'history') } @@ -811,6 +1232,13 @@ export function HappyThread(props: { let wheelIntentUntil = 0 let wheelLatched = false + const cancelSearchRecentering = () => { + for (const timer of searchTargetScrollTimersRef.current) { + window.clearTimeout(timer) + } + searchTargetScrollTimersRef.current = [] + } + const hasExplicitUpwardIntent = (intent: ScrollIntent): boolean => { return intent.isScrollingUp && ( pointerResumeActive @@ -862,6 +1290,13 @@ export function HappyThread(props: { pending.scrollHeight = viewport.scrollHeight } + // Same hazard for a pending forward page: restoring the anchor + // captured when the request started would drag the user back to + // where they were before they scrolled away. + if (pendingForwardAnchorRef.current) { + pendingForwardAnchorRef.current = captureScrollAnchor(viewport) + } + const needsCoverage = needsViewportCoverageRef.current() if (!needsCoverage) { // Older pages retain the oldest side of the bounded window. @@ -894,7 +1329,12 @@ export function HappyThread(props: { // decides whether this demand may start a request; programmatic // scroll events cannot bypass backoff or a paused coverage run. if (needsCoverage) { - void requestOlderRef.current(explicitUpwardIntent ? 'user' : 'coverage') + // A search jump owns the viewport while its historical window + // is being displayed. Keep automatic resize/scroll events from + // changing that window, but preserve an explicit user gesture. + if (explicitUpwardIntent || !searchTargetHistoryLockRef.current) { + void requestOlderRef.current(explicitUpwardIntent ? 'user' : 'coverage') + } } // Content growth and viewport resizes (mobile keyboard) move the @@ -979,6 +1419,7 @@ export function HappyThread(props: { ) { return } + cancelSearchRecentering() // Native keyboard scrolling can outlive a fixed intent timeout. // Keep this gesture armed until consumed, completed, or cancelled. keyboardResumeActive = true @@ -999,6 +1440,7 @@ export function HappyThread(props: { if (isNestedScrollEvent(event) || event.button !== 0) { return } + cancelSearchRecentering() armPointerIntent() } @@ -1016,6 +1458,7 @@ export function HappyThread(props: { const handleWindowPointerDown = (event: PointerEvent) => { if (isNestedScrollEvent(event)) return if (event.button === 0 && isInsideViewport(event.clientX, event.clientY)) { + cancelSearchRecentering() armPointerIntent() } } @@ -1023,6 +1466,7 @@ export function HappyThread(props: { const handleWindowMouseDown = (event: MouseEvent) => { if (isNestedScrollEvent(event)) return if (event.button === 0 && isInsideViewport(event.clientX, event.clientY)) { + cancelSearchRecentering() armPointerIntent() } } @@ -1050,6 +1494,7 @@ export function HappyThread(props: { const handleWheel = (event: WheelEvent) => { if (isNestedScrollEvent(event)) return + cancelSearchRecentering() keyboardResumeActive = false if (event.deltaY >= 0) { wheelIntentUntil = 0 @@ -1074,6 +1519,7 @@ export function HappyThread(props: { const handleTouchStart = (event: TouchEvent) => { if (isNestedScrollEvent(event)) return + cancelSearchRecentering() keyboardResumeActive = false updatePullToLoadState('idle') pullStartY = ( @@ -1202,6 +1648,11 @@ export function HappyThread(props: { // Scroll to bottom handler for the indicator button const scrollToBottom = useCallback(() => { + if (activeSearchTargetId) { + dismissSearchTarget() + } else { + releaseSearchTargetHistoryLock() + } const viewport = viewportRef.current setShowScrollToBottom(false) if (viewport) { @@ -1214,20 +1665,34 @@ export function HappyThread(props: { scrollToBottomSmooth() lastScrollTopRef.current = viewport.scrollTop } - if (!atBottomRef.current) { - atBottomRef.current = true + autoScrollEnabledRef.current = true + atBottomRef.current = true + if (props.onJumpToTail) { + // A historical search window can look like it is already at the + // bottom after a clamped/programmatic scroll. Always use the + // explicit tail transition so the bounded window is reconciled + // with the server's latest messages before the next turn renders. + props.onJumpToTail() + } else { onViewModeChangeRef.current('tail') } - }, [scrollToBottomSmooth]) + }, [activeSearchTargetId, dismissSearchTarget, props.onJumpToTail, releaseSearchTargetHistoryLock, scrollToBottomSmooth]) // Reset state when session changes useLayoutEffect(() => { + const hasInitialSearchTarget = Boolean(props.initialTargetMessageId?.trim()) + searchTargetHistoryLockRef.current = false setShowScrollToBottom(false) autoScrollEnabledRef.current = true tailScrollInProgressRef.current = false lastScrollTopRef.current = viewportRef.current?.scrollTop ?? 0 atBottomRef.current = true - onViewModeChangeRef.current('tail') + if (hasInitialSearchTarget) { + autoScrollEnabledRef.current = false + atBottomRef.current = false + } else { + onViewModeChangeRef.current('tail') + } forceScrollTokenRef.current = props.forceScrollToken pendingScrollRef.current = null pullToLoadStateRef.current = 'idle' @@ -1242,12 +1707,22 @@ export function HappyThread(props: { initialScrollSessionRef.current = null initialScrollDeadlineRef.current = 0 clearInitialScrollTimers() + clearSearchTargetMarker() + clearSearchTargetScrollTimers() clearCoverageCheckTimer() clearFailureRetryTimer() + clearSearchTargetRetryTimer() + searchTargetValidatedRequestKeyRef.current = null + searchTargetQueryRetryCountRef.current = 0 + searchTargetContextRetryCountRef.current = 0 + searchTargetRenderRetryCountRef.current = 0 settlePendingLoad('transient-stop') }, [ props.sessionId, clearInitialScrollTimers, + clearSearchTargetMarker, + clearSearchTargetRetryTimer, + clearSearchTargetScrollTimers, clearCoverageCheckTimer, clearFailureRetryTimer, settlePendingLoad @@ -1255,7 +1730,9 @@ export function HappyThread(props: { useLayoutEffect(() => { if ( - initialScrollSessionRef.current === props.sessionId + props.initialTargetMessageId?.trim() + || searchTargetHistoryLockRef.current + || initialScrollSessionRef.current === props.sessionId || props.isSyncingTail || props.rawMessagesCount === 0 || pendingScrollRef.current @@ -1275,6 +1752,7 @@ export function HappyThread(props: { if ( initialScrollSessionRef.current !== props.sessionId || !autoScrollEnabledRef.current + || searchTargetHistoryLockRef.current || pendingScrollRef.current ) { return @@ -1283,6 +1761,7 @@ export function HappyThread(props: { }, delay)) }, [ props.sessionId, + props.initialTargetMessageId, props.isSyncingTail, props.rawMessagesCount, props.messagesVersion, @@ -1290,15 +1769,261 @@ export function HappyThread(props: { clearInitialScrollTimers ]) + useLayoutEffect(() => { + const targetMessageId = activeSearchTargetId + if (!targetMessageId) { + releaseSearchTargetHistoryLock() + setIsLocatingSearchTarget(false) + // The route target is cleared after a successful jump. Reset the + // per-target phase so selecting the same search result later can + // start a fresh jump instead of being stuck at `complete`. + if (searchTargetJumpRef.current !== null) { + searchTargetJumpRef.current = null + searchTargetValidatedRequestKeyRef.current = null + clearSearchTargetRetryTimer() + searchTargetQueryRetryCountRef.current = 0 + searchTargetContextRetryCountRef.current = 0 + searchTargetRenderRetryCountRef.current = 0 + } + return + } + const searchRequestKey = `${props.sessionId}\u0000${props.searchRequestId ?? 0}\u0000${targetSearchQuery}` + searchTargetHistoryLockRef.current = true + autoScrollEnabledRef.current = false + atBottomRef.current = false + const previous = searchTargetJumpRef.current + if ( + previous + && previous.messageId === targetMessageId + && previous.messageQuery === targetSearchQuery + && previous.requestId === props.searchRequestId + && previous.phase === 'complete' + ) { + setIsLocatingSearchTarget(false) + return + } + if ( + previous + && previous.messageId === targetMessageId + && previous.messageQuery === targetSearchQuery + && previous.requestId === props.searchRequestId + && previous.phase === 'failed' + ) { + searchTargetHistoryLockRef.current = false + setIsLocatingSearchTarget(false) + return + } + if ( + props.isSyncingTail + || props.isLoadingMoreMessages + || (!targetMessageId && props.rawMessagesCount === 0) + ) { + setIsLocatingSearchTarget(true) + return + } + + if ( + !previous + || previous.messageId !== targetMessageId + || previous.messageQuery !== targetSearchQuery + || previous.requestId !== props.searchRequestId + ) { + clearSearchTargetRetryTimer() + clearSearchTargetMarker() + clearSearchTargetScrollTimers() + searchTargetQueryRetryCountRef.current = 0 + searchTargetContextRetryCountRef.current = 0 + searchTargetRenderRetryCountRef.current = 0 + searchTargetJumpRef.current = { + messageId: targetMessageId, + messageQuery: targetSearchQuery, + requestId: props.searchRequestId, + phase: 'idle' + } + } + const jump = searchTargetJumpRef.current + if (!jump) { + setIsLocatingSearchTarget(false) + return + } + if (jump.phase === 'complete' || jump.phase === 'failed') { + setIsLocatingSearchTarget(false) + return + } + setIsLocatingSearchTarget(true) + if (jump.phase === 'loading') { + return + } + + // assistant-ui can still have the previous window's DOM mounted for + // one or more renders after the message store publishes a new window. + // Do not anchor a search target against that stale DOM: a tail + // reconciliation can otherwise clear the route target before the + // historical context is rendered, making the jump appear to work and + // then snap back to the latest messages. + if ( + appliedMessagesVersion !== props.messagesVersion + || appliedHistoryVersion !== props.historyVersion + ) { + if (searchTargetRenderRetryCountRef.current >= MAX_SEARCH_TARGET_RENDER_RETRIES) { + jump.phase = 'failed' + dismissSearchTarget() + return + } + if (jump.phase !== 'waiting-render') { + jump.phase = 'waiting-runtime' + } + searchTargetRenderRetryCountRef.current += 1 + scheduleSearchTargetRetry(SEARCH_TARGET_QUERY_RETRY_DELAY_MS) + return + } + + // Even when a cached window already renders the target row, refresh + // bounded server context once per search request so offline appends or + // rewinds update epoch, snapshot head, and forward pagination cursors. + const hasValidatedServerContext = !props.onLoadMessageContext + || searchTargetValidatedRequestKeyRef.current === searchRequestKey + const target = hasValidatedServerContext + ? findConversationMessageAnchor(targetMessageId, targetSearchQuery) + : null + if (target instanceof HTMLElement) { + const targetRange = targetSearchQuery + ? findConversationMessageTextRange(target, targetSearchQuery) + : null + if ( + targetSearchQuery + && !targetRange + && searchTargetQueryRetryCountRef.current < MAX_SEARCH_TARGET_QUERY_RETRIES + ) { + searchTargetQueryRetryCountRef.current += 1 + scheduleSearchTargetRetry(SEARCH_TARGET_QUERY_RETRY_DELAY_MS) + return + } + + clearSearchTargetRetryTimer() + jump.phase = 'complete' + setIsLocatingSearchTarget(false) + searchTargetHistoryLockRef.current = true + clearInitialScrollTimers() + autoScrollEnabledRef.current = false + atBottomRef.current = false + onViewModeChangeRef.current('history') + const matchMarker = targetRange + ? createSearchMatchMarker( + targetRange, + targetMessageId + ) + : null + scrollSearchTargetIntoView(matchMarker ?? target, viewportRef.current) + scheduleSearchTargetScroll(targetMessageId, targetSearchQuery, target) + if (!matchMarker) { + target.classList.add(SEARCH_TARGET_HIGHLIGHT_CLASS) + } + props.onInitialTargetConsumed?.() + return + } + + // The context response is committed to the message window before + // assistant-ui has necessarily rendered its message components. Keep + // the target alive and wait for a later runtime/version or DOM retry; + // do not issue a second context request during this hand-off window. + if (jump.phase === 'waiting-render') { + if (searchTargetRenderRetryCountRef.current >= MAX_SEARCH_TARGET_RENDER_RETRIES) { + jump.phase = 'failed' + dismissSearchTarget() + return + } + searchTargetRenderRetryCountRef.current += 1 + scheduleSearchTargetRetry(SEARCH_TARGET_QUERY_RETRY_DELAY_MS) + return + } + + if (!props.onLoadMessageContext) { + jump.phase = 'failed' + dismissSearchTarget() + return + } + + jump.phase = 'loading' + setIsLocatingSearchTarget(true) + const searchRequestId = props.searchRequestId + void props.onLoadMessageContext(targetMessageId).then((loaded) => { + const current = searchTargetJumpRef.current + if (!current || current.messageId !== targetMessageId || current.requestId !== searchRequestId) return + if (!loaded) { + // A false result can be a transient busy/invalidated window. + // Retry a few times while preserving the route target instead + // of silently consuming it and leaving the user at the tail. + if (searchTargetContextRetryCountRef.current < MAX_SEARCH_TARGET_CONTEXT_RETRIES) { + searchTargetContextRetryCountRef.current += 1 + current.phase = 'idle' + scheduleSearchTargetRetry(SEARCH_TARGET_QUERY_RETRY_DELAY_MS) + } else { + current.phase = 'failed' + dismissSearchTarget() + } + return + } + searchTargetValidatedRequestKeyRef.current = searchRequestKey + current.phase = 'waiting-render' + searchTargetRenderRetryCountRef.current = 0 + setSearchTargetRetryVersion((version) => version + 1) + }).catch(() => { + const current = searchTargetJumpRef.current + if (!current || current.messageId !== targetMessageId || current.requestId !== searchRequestId) return + if (searchTargetContextRetryCountRef.current < MAX_SEARCH_TARGET_CONTEXT_RETRIES) { + searchTargetContextRetryCountRef.current += 1 + current.phase = 'idle' + scheduleSearchTargetRetry(SEARCH_TARGET_QUERY_RETRY_DELAY_MS) + } else { + current.phase = 'failed' + dismissSearchTarget() + } + }) + }, [ + activeSearchTargetId, + clearInitialScrollTimers, + clearSearchTargetMarker, + clearSearchTargetRetryTimer, + clearSearchTargetScrollTimers, + dismissSearchTarget, + releaseSearchTargetHistoryLock, + scheduleSearchTargetRetry, + scheduleSearchTargetScroll, + appliedHistoryVersion, + appliedMessagesVersion, + props.historyVersion, + props.isLoadingMoreMessages, + props.isSyncingTail, + props.messagesVersion, + props.onInitialTargetConsumed, + props.onLoadMessageContext, + props.rawMessagesCount, + props.searchRequestId, + searchTargetRetryVersion, + targetSearchQuery + ]) + useEffect(() => { return () => { historyLoaderRef.current.runId += 1 clearInitialScrollTimers() + clearSearchTargetMarker() + clearSearchTargetScrollTimers() clearCoverageCheckTimer() clearFailureRetryTimer() + clearSearchTargetRetryTimer() settlePendingLoad('transient-stop') } - }, [clearInitialScrollTimers, clearCoverageCheckTimer, clearFailureRetryTimer, settlePendingLoad]) + }, [ + clearInitialScrollTimers, + clearSearchTargetMarker, + clearSearchTargetRetryTimer, + clearSearchTargetScrollTimers, + clearCoverageCheckTimer, + clearFailureRetryTimer, + settlePendingLoad + ]) useEffect(() => { if (forceScrollTokenRef.current === props.forceScrollToken) { @@ -1334,7 +2059,7 @@ export function HappyThread(props: { const delay = getHistoryCoverageRetryDelay(initialScrollDeadlineRef.current, Date.now()) coverageCheckTimerRef.current = window.setTimeout(() => { coverageCheckTimerRef.current = null - if (needsViewportCoverage()) { + if (!searchTargetHistoryLockRef.current && needsViewportCoverage()) { void requestOlderRef.current('coverage') } }, delay) @@ -1572,6 +2297,10 @@ export function HappyThread(props: { }, [loadOlderForOutline, props.onOutlineItemClick, props.onOutlineOpenChange]) useEffect(() => { + if (searchTargetHistoryLockRef.current) { + clearCoverageCheckTimer() + return + } if ( !props.hasMoreMessages || props.isSyncingTail @@ -1616,6 +2345,7 @@ export function HappyThread(props: { } else if ( autoScrollEnabledRef.current && atBottomRef.current + && !searchTargetHistoryLockRef.current && !pendingScrollRef.current ) { scrollToBottomInstant() @@ -1623,7 +2353,11 @@ export function HappyThread(props: { // Late content growth can leave the viewport near the top without // a scroll event. Submit demand through the same controller; an // in-flight load, backoff, or paused run remains exclusive. - if (!pendingScrollRef.current && needsViewportCoverage()) { + if ( + !searchTargetHistoryLockRef.current + && !pendingScrollRef.current + && needsViewportCoverage() + ) { if (isInitialScrollSettling()) { scheduleCoverageAfterSettling() } else { @@ -1641,6 +2375,17 @@ export function HappyThread(props: { scheduleCoverageAfterSettling ]) + useLayoutEffect(() => { + const forwardAnchor = pendingForwardAnchorRef.current + if (forwardAnchor) { + const forwardViewport = viewportRef.current + if (forwardViewport && restoreScrollAnchor(forwardViewport, forwardAnchor)) { + lastScrollTopRef.current = forwardViewport.scrollTop + } + pendingForwardAnchorRef.current = null + } + }, [appliedHistoryVersion]) + useLayoutEffect(() => { const pending = pendingScrollRef.current const viewport = viewportRef.current @@ -1680,7 +2425,11 @@ export function HappyThread(props: { settlePendingLoad('loaded') return } - if (atBottomRef.current && autoScrollEnabledRef.current) { + if ( + !searchTargetHistoryLockRef.current + && atBottomRef.current + && autoScrollEnabledRef.current + ) { scrollToBottomInstant() } }, [ @@ -1786,13 +2535,69 @@ export function HappyThread(props: { loadOlderMessagesPreservingScroll: loadOlderFromConsumer }}> - {!props.isSyncingTail && ( + {hasSearchTarget ? ( +
+
+ + “{targetSearchQuery}” + + + {t('misc.searchMatches', { + current: searchMatchCurrentLabel, + total: searchMatchTotalLabel + })} + + + + +
+
+ ) : null} + {isLocatingSearchTarget ? ( +
+ + {t('misc.locatingMessage')} +
+ ) : !props.isSyncingTail && ( props.isLoadingMoreMessages || pullToLoadState !== 'idle' ) ? (
{props.isLoadingMoreMessages ? ( diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 9bd8fbf3..7e1340be 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -27,6 +27,10 @@ export function HappyAssistantMessage() { const showSessionSummaryInChat = useSessionSummaryInChat() const messageId = useAuiState((s) => s.message.id) const elementId = getConversationMessageAnchorId(messageId) + const sourceMessageIds = useAuiState((s) => { + const custom = s.message.metadata.custom as Partial | undefined + return custom?.sourceMessageIds + }) const isCliOutput = useAuiState((s) => { const custom = s.message.metadata.custom as Partial | undefined return custom?.kind === 'cli-output' @@ -76,6 +80,7 @@ export function HappyAssistantMessage() { {isCliOutput diff --git a/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx b/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx index c66f1023..f9f0e372 100644 --- a/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx +++ b/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' +import type { ComponentType } from 'react' +import type { TextMessagePartProps } from '@assistant-ui/react' import { I18nProvider } from '@/lib/i18n-context' import { NotifySummaryText } from './NotifySummaryText' @@ -25,10 +27,23 @@ vi.mock('@/components/MarkdownRenderer', () => ({ ) })) -function renderText(text: string, statusType: 'complete' | 'running' = 'complete') { +const RenderNotifySummaryText = NotifySummaryText as unknown as ComponentType< + TextMessagePartProps & { sourceMessageId?: string } +> + +function renderText( + text: string, + statusType: 'complete' | 'running' = 'complete', + sourceMessageId?: string +) { return render( - + ) } @@ -102,6 +117,15 @@ describe('NotifySummaryText', () => { expect(screen.queryByTestId('notify-summary-footer')).toBeNull() }) + it('marks the individual rendered part for content-search navigation', () => { + renderText('Plain assistant prose.', 'complete', 'message-42') + + expect(screen.getByTestId('raw-markdown').parentElement).toHaveAttribute( + 'data-hapi-source-message-id', + 'message-42' + ) + }) + it('hides a recognized footer with no displayable fields instead of raw JSON', () => { renderText('Did the work.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"agent":"codex"}') diff --git a/web/src/components/AssistantChat/messages/NotifySummaryText.tsx b/web/src/components/AssistantChat/messages/NotifySummaryText.tsx index 87253076..b3b6fd08 100644 --- a/web/src/components/AssistantChat/messages/NotifySummaryText.tsx +++ b/web/src/components/AssistantChat/messages/NotifySummaryText.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react' -import type { TextMessagePartComponent } from '@assistant-ui/react' +import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react' +import type { ReactNode } from 'react' import { splitNotifySummary, stripNotifySummaryFooter, type NotifySummary } from '@hapi/protocol/messages' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { MarkdownText } from '@/components/assistant-ui/markdown-text' @@ -101,8 +102,36 @@ export function NotifySummaryFooter({ summary }: { summary: NotifySummary }) { ) } +type SourceAwareTextMessagePartProps = TextMessagePartProps & { + sourceMessageId?: string + sourceMessageIds?: string[] +} + +type SearchSourceMarker = { id?: string; ids?: string[] } + +function withSearchSourceMarker(source: SearchSourceMarker, content: ReactNode): ReactNode { + if (!source.id || content === null || content === undefined) return content + return ( +
0 ? source.ids.join(' ') : undefined + } + > + {content} +
+ ) +} + /** Render the machine footer as a compact row when display is on; otherwise strip it. */ -export const NotifySummaryText: TextMessagePartComponent = ({ text, status }) => { +export const NotifySummaryText: TextMessagePartComponent = (props) => { + const { text, status } = props + const sourceMessageId = (props as SourceAwareTextMessagePartProps).sourceMessageId + const sourceMessageIds = (props as SourceAwareTextMessagePartProps).sourceMessageIds + const searchSource: SearchSourceMarker = { id: sourceMessageId, ids: sourceMessageIds } const showInChat = useSessionSummaryInChat() const completedTextRef = useRef(null) const previousTextRef = useRef(text) @@ -139,16 +168,16 @@ export const NotifySummaryText: TextMessagePartComponent = ({ text, status }) => if (!showInChat) { const stripped = stripNotifySummaryFooter(text) if (!stripped) return null - if (stripped === text) return - return + if (stripped === text) return withSearchSourceMarker(searchSource, ) + return withSearchSourceMarker(searchSource, ) } if (status.type !== 'complete' && !(status.type === 'running' && completedTextRef.current === text)) { - return + return withSearchSourceMarker(searchSource, ) } const display = splitNotifySummary(text) - if (!display) return + if (!display) return withSearchSourceMarker(searchSource, ) const hasDisplayableSummary = Boolean( display.summary.summary?.trim() @@ -157,13 +186,13 @@ export const NotifySummaryText: TextMessagePartComponent = ({ text, status }) => ) if (!hasDisplayableSummary) { if (!display.visibleText) return null - return + return withSearchSourceMarker(searchSource, ) } - return ( + return withSearchSourceMarker(searchSource, ( <> {display.visibleText ? : null} - ) + )) } diff --git a/web/src/components/AssistantChat/messages/SystemMessage.tsx b/web/src/components/AssistantChat/messages/SystemMessage.tsx index 83f6dfdf..1b76dedd 100644 --- a/web/src/components/AssistantChat/messages/SystemMessage.tsx +++ b/web/src/components/AssistantChat/messages/SystemMessage.tsx @@ -80,6 +80,10 @@ export function HappySystemMessage() { const { t } = useTranslation() const role = useAuiState((s) => s.message.role) const messageId = useAuiState((s) => s.message.id) + const sourceMessageIds = useAuiState((s) => { + const custom = s.message.metadata.custom as Partial | undefined + return custom?.sourceMessageIds + }) const text = useAuiState((s) => { if (s.message.role !== 'system') return '' return s.message.content[0]?.type === 'text' ? s.message.content[0].text : '' @@ -130,7 +134,11 @@ export function HappySystemMessage() { } return ( - + ) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index e5b59cdf..534a0038 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -16,6 +16,10 @@ export function HappyUserMessage() { const messageId = useAuiState((s) => s.message.id) const threadIsRunning = useAuiState((s) => s.thread.isRunning) const elementId = getConversationMessageAnchorId(messageId) + const sourceMessageIds = useAuiState((s) => { + const custom = s.message.metadata.custom as Partial | undefined + return custom?.sourceMessageIds + }) const text = useAuiState((s) => { if (s.message.role !== 'user') return '' return s.message.content.find((part): part is TextMessagePart => part.type === 'text')?.text ?? '' @@ -90,6 +94,7 @@ export function HappyUserMessage() {
@@ -107,6 +112,7 @@ export function HappyUserMessage() {
diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 76b56069..4d943944 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -596,6 +596,7 @@ type SessionChatProps = { messages: DecryptedMessage[] messagesWarning: string | null hasMoreMessages: boolean + hasMoreNewerMessages?: boolean isSyncingTail: boolean isLoadingMoreMessages: boolean isSending: boolean @@ -606,6 +607,7 @@ type SessionChatProps = { onBack: () => void onRefresh: () => void | Promise onLoadMore: (onBeforeApply?: (historyVersion: number) => boolean) => Promise + onLoadNewer?: (resolvePreserveMessageId?: () => string | null) => Promise onCancelLoadMore: () => void // Returns the accepted mutation's attempt id, or false when // pre-mutation guards (no-api / no-session / pending) rejected the call OR async @@ -624,6 +626,7 @@ type SessionChatProps = { /** Navigate/seed after a voice direct-send resumed an inactive session. */ onVoiceSessionResolved?: (sessionId: string) => void onViewModeChange: (mode: 'tail' | 'history') => void + onJumpToTail: () => void onRetryMessage?: (localId: string) => void autocompleteSuggestions?: SuggestionHandler availableSlashCommands?: readonly SlashCommand[] @@ -636,6 +639,12 @@ type SessionChatProps = { onSuppressSendErrorRestore?: (id: number) => void initialOutlineOpen?: boolean onInitialOutlineConsumed?: () => void + initialTargetMessageId?: string + initialTargetMessageQuery?: string + searchRequestId?: number + onLoadMessageContext?: (messageId: string) => Promise + onInitialTargetConsumed?: () => void + onSearchTargetDismissed?: () => void // Called when an `abort-restore` event arrives and the composer is not empty, // so the caller can surface the aborted text via the existing sendError path. onAbortRestore?: (text: string) => void @@ -2098,12 +2107,15 @@ function SessionChatInner(props: SessionChatProps) { onRewindConversation={controlledByUser ? undefined : onRewindConversation} isLatestCompletedBoundary={isLatestCompletedBoundary} onViewModeChange={props.onViewModeChange} + onJumpToTail={props.onJumpToTail} readThroughRevision={!terminalVisible && props.viewMode === 'tail' ? props.readThroughRevision : undefined} isSyncingTail={props.isSyncingTail} messagesWarning={props.messagesWarning} hasMoreMessages={props.hasMoreMessages} + hasMoreNewerMessages={props.hasMoreNewerMessages} isLoadingMoreMessages={props.isLoadingMoreMessages} onLoadMore={props.onLoadMore} + onLoadNewer={props.onLoadNewer} onCancelLoadMore={props.onCancelLoadMore} unseenCount={unseenCount} rawMessagesCount={visibleMessages.length} @@ -2114,6 +2126,12 @@ function SessionChatInner(props: SessionChatProps) { outlineOpen={outlineOpen} outlineItems={outlineItems} onOutlineOpenChange={setOutlineOpen} + initialTargetMessageId={props.initialTargetMessageId} + initialTargetMessageQuery={props.initialTargetMessageQuery} + searchRequestId={props.searchRequestId} + onLoadMessageContext={props.onLoadMessageContext} + onInitialTargetConsumed={props.onInitialTargetConsumed} + onSearchTargetDismissed={props.onSearchTargetDismissed} />
diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 8ca1ad02..b18b4eb3 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -1,11 +1,12 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ReactNode } from 'react' -import type { SessionSummary } from '@/types/api' import type { ApiClient } from '@/api/client' +import type { SessionSummary } from '@/types/api' import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' +import { markSessionSeen, markSessionUnread } from '@/lib/sessionLastSeen' import { SessionList } from './SessionList' const SEARCH_LABEL = 'Search sessions (title, path, Agent, machine name, ID, and more)' @@ -1363,6 +1364,437 @@ describe('SessionList search toggle', () => { expect(screen.queryByRole('button', { name: /Other task/ })).toBeNull() }) + it('clears stale content results before the next query debounce finishes', async () => { + vi.useFakeTimers() + try { + const matchingSession = makeSession({ + id: 'content-match', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Content match', flavor: 'codex' }, + }) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ + results: [{ + session: matchingSession, + match: { + messageId: 'message-1', + role: 'user' as const, + seq: 1, + createdAt: 1, + snippet: 'old query result', + }, + }], + }), + } as unknown as ApiClient + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + const input = screen.getByRole('searchbox') + fireEvent.change(input, { target: { value: 'old query' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: /Content match/ })).toBeInTheDocument() + + fireEvent.change(input, { target: { value: 'new query' } }) + + expect(screen.queryByRole('button', { name: /Content match/ })).toBeNull() + expect(screen.getByText('Searching message content…')).toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + + it('applies the active-only sidebar filter to content-search results', async () => { + localStorage.setItem('hapi-show-active-sessions-only', 'true') + vi.useFakeTimers() + try { + const inactiveMatch = makeSession({ + id: 'inactive-content-match', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Inactive content match', flavor: 'codex' }, + }) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ + results: [{ + session: inactiveMatch, + match: { + messageId: 'message-1', + role: 'user' as const, + seq: 1, + createdAt: 1, + snippet: 'content result', + }, + }], + }), + } as unknown as ApiClient + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'content' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + + expect(screen.queryByRole('button', { name: /Inactive content match/ })).toBeNull() + } finally { + vi.useRealTimers() + localStorage.removeItem('hapi-show-active-sessions-only') + } + }) + + it('passes the filtered sidebar session scope before applying the content result limit', async () => { + localStorage.setItem('hapi-show-active-sessions-only', 'true') + vi.useFakeTimers() + try { + const activeMatch = makeSession({ + id: 'active-content-match', + active: true, + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Active content match', flavor: 'codex' }, + }) + const inactiveMatch = makeSession({ + id: 'inactive-content-match', + updatedAt: 90, + metadata: { path: '/work/hapi', name: 'Inactive content match', flavor: 'codex' }, + }) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ results: [] }), + } as unknown as ApiClient + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'content' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + }) + + expect(api.searchSessionContent).toHaveBeenCalledWith( + 'content', + 50, + expect.any(AbortSignal), + ['active-content-match'] + ) + } finally { + vi.useRealTimers() + localStorage.removeItem('hapi-show-active-sessions-only') + } + }) + + it('refreshes the content-search scope when a session becomes unread', async () => { + vi.useFakeTimers() + const unreadSession = makeSession({ + id: 'initially-unread-content-match', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Initially unread', flavor: 'codex' }, + }) + const newlyUnreadSession = makeSession({ + id: 'newly-unread-content-match', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Newly unread', flavor: 'codex' }, + }) + markSessionUnread(unreadSession.id, { readRevision: 0, manualUnread: false, version: 0 }) + markSessionSeen(newlyUnreadSession.id, newlyUnreadSession.updatedAt) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ results: [] }), + } as unknown as ApiClient + + try { + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Unread only' })) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'content' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + expect(api.searchSessionContent).toHaveBeenLastCalledWith( + 'content', + 50, + expect.any(AbortSignal), + [unreadSession.id] + ) + + act(() => markSessionUnread(newlyUnreadSession.id, { readRevision: 0, manualUnread: false, version: 0 })) + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + + expect(api.searchSessionContent).toHaveBeenLastCalledWith( + 'content', + 50, + expect.any(AbortSignal), + [unreadSession.id, newlyUnreadSession.id] + ) + } finally { + vi.useRealTimers() + localStorage.removeItem('hapi.sessionLastSeen.v1') + localStorage.removeItem('hapi.sessionManualUnread.v1') + localStorage.removeItem('hapi.sessionLastSeenBaseline.v1') + } + }) + + it('includes a manually unread running session in the content-search scope', async () => { + vi.useFakeTimers() + const runningUnread = makeSession({ + id: 'manually-unread-running', + active: true, + thinking: true, + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Running but marked unread', flavor: 'codex' }, + }) + const seenSession = makeSession({ + id: 'seen-content-match', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Seen', flavor: 'codex' }, + }) + markSessionUnread(runningUnread.id, { readRevision: 0, manualUnread: false, version: 0 }) + markSessionSeen(seenSession.id, seenSession.updatedAt) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ results: [] }), + } as unknown as ApiClient + + try { + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Unread only' })) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'content' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + + expect(api.searchSessionContent).toHaveBeenLastCalledWith( + 'content', + 50, + expect.any(AbortSignal), + [runningUnread.id] + ) + } finally { + vi.useRealTimers() + localStorage.removeItem('hapi.sessionLastSeen.v1') + localStorage.removeItem('hapi.sessionManualUnread.v1') + localStorage.removeItem('hapi.sessionLastSeenBaseline.v1') + } + }) + + it('explains when no content result may be incomplete because long messages were truncated', async () => { + vi.useFakeTimers() + try { + const session = makeSession({ + id: 'content-truncated', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Content truncated', flavor: 'codex' }, + }) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ + results: [], + hasPotentiallyIncompleteResults: true, + }), + } as unknown as ApiClient + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'missing' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + + expect(screen.getByText(/Some long messages were truncated/)).toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + + it('shows incomplete coverage when content results are nonempty', async () => { + vi.useFakeTimers() + try { + const session = makeSession({ + id: 'content-partial', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Content partial', flavor: 'codex' }, + }) + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ + results: [{ + session, + match: { + messageId: 'message-1', + role: 'user' as const, + seq: 1, + createdAt: 1, + snippet: 'partial result', + truncated: false, + }, + }], + hasPotentiallyIncompleteResults: true, + }), + } as unknown as ApiClient + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'partial' } }) + + await act(async () => { + vi.advanceTimersByTime(180) + await Promise.resolve() + await Promise.resolve() + }) + + expect(screen.getByRole('button', { name: /Content partial/ })).toBeInTheDocument() + expect(screen.getByText(/Some long messages were truncated/)).toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + + it('does not issue a corpus search for a one-character content query', async () => { + vi.useFakeTimers() + try { + const api = { + searchSessionContent: vi.fn().mockResolvedValue({ results: [] }), + } as unknown as ApiClient + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.click(screen.getByTestId('session-list-search-scope')) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: '你' } }) + + await act(async () => { + vi.advanceTimersByTime(500) + await Promise.resolve() + }) + + expect(api.searchSessionContent).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('shows truncated query text on the collapsed search control when a text filter is active', () => { const sessions = [ makeSession({ diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 4256c0d8..7e94e102 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -406,6 +406,16 @@ describe('session list search helpers', () => { expect(sessionMatchesQuery(session, normalizeSearch('hapi-worktrees'), 'desktop')).toBe(true) }) + it('keeps metadata search independent from message content', () => { + const session = makeSession({ + id: 'metadata-session', + metadata: { path: '/work/hapi', name: 'Session title' } + }) + + expect(sessionMatchesQuery(session, normalizeSearch('session title'), 'desktop')).toBe(true) + expect(sessionMatchesQuery(session, normalizeSearch('message-only phrase'), 'desktop')).toBe(false) + }) + it('supports complete wildcard patterns without changing plain text matching', () => { const session = makeSession({ id: 'session-123', diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index b6c9daa4..4c00af9e 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,7 +1,7 @@ import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import * as Popover from '@radix-ui/react-popover' import { SessionListScrollAnchor } from './SessionListScrollAnchor' -import type { SessionSummary } from '@/types/api' +import type { SessionContentSearchResponse, SessionSummary } from '@/types/api' import { SESSION_LIFECYCLE_IDLE } from '@hapi/protocol' import type { ApiClient } from '@/api/client' import { @@ -20,7 +20,7 @@ import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { ArchiveSessionDialog } from '@/components/ArchiveSessionDialog' import { DeleteSessionDialog } from '@/components/DeleteSessionDialog' -import { CopyIcon, CheckIcon, MarkAllReadIcon } from '@/components/icons' +import { CheckIcon, CopyIcon, InfoIcon, MarkAllReadIcon } from '@/components/icons' function PinnedSectionIcon(props: { className?: string }) { return ( @@ -161,6 +161,8 @@ type SessionGroup = { hasPinnedSession: boolean } +const MIN_CONTENT_SEARCH_QUERY_LENGTH = 2 + const RUNNING_BUCKETS = [ { key: 'working', labelKey: 'session.item.running', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true }, { key: 'pending', labelKey: 'session.item.pending', colorClass: 'text-[var(--app-badge-warning-text)]', pulse: true }, @@ -595,6 +597,52 @@ function XIcon(props: { className?: string }) { ) } +/** Metadata scope: an ID card, for title/summary/path/Agent/machine/ID. */ +function ScopeMetadataIcon(props: { className?: string }) { + return ( + + ) +} + +/** Content scope: a speech bubble with text, for the message full-text index. */ +function ScopeContentIcon(props: { className?: string }) { + return ( + + ) +} + function PlusIcon(props: { className?: string }) { return ( void expanded: boolean onExpandedChange: (expanded: boolean) => void + searchMode?: 'metadata' | 'content' + onSearchModeChange?: (mode: 'metadata' | 'content') => void /** Minimum width of icon + clear + calendar. Query text may shrink; these may not. */ chromeMinPx?: number /** @@ -867,11 +917,6 @@ export function SessionListSearch(props: { dateButtonRef.current?.focus() } - // Layout A (#2019 / Quest): calendar sits INSIDE the expanded field, - // immediately left of the furthest-right 48px clear end-cap. Both hit - // targets are min 48x48 so the clear is no longer a ~14px absolute X. - // Date button only — picker mounts on the search-group container so - // left-0 / right-0 stay viewport-safe (not a 48px button containing block). const renderDateButton = (variant: 'standalone' | 'embedded') => (
+ ) : null if (!props.expanded) { const hasTextQuery = props.value.length > 0 - const collapsedLabel = hasTextQuery ? `${searchLabel}: ${props.value}` : searchLabel + const collapsedLabel = hasTextQuery ? searchLabel + ': ' + props.value : searchLabel // Idle: shrink-0 so the icon does not crush under neighbors (#2039). // Active query: maxWidth reserves machine menu + More before the 9rem // chip can take ~240px and shove More off a 320px / 130% row (#2040). @@ -1002,8 +1082,20 @@ export function SessionListSearch(props: { title={collapsedLabel} aria-label={collapsedLabel} > - - + + {/* + * The applying chip keeps the scope glyph so a content-scoped + * query stays readable once the field collapses. Idle (no + * query) stays a magnifier: there is no scope in effect yet, + * and this is also the button that opens search. + */} + {hasTextQuery && searchMode === 'content' + ? + : } {hasTextQuery ? ( {props.value} @@ -1014,8 +1106,6 @@ export function SessionListSearch(props: { type="button" onClick={() => { props.onChange('') - // The clear button unmounts with the query; keep focus on - // the collapsed search trigger instead of dropping to body. collapsedButtonRef.current?.focus() }} data-toolbar-chrome="" @@ -1045,15 +1135,18 @@ export function SessionListSearch(props: { }} >
-
- +
+
+ {renderSearchMode()} props.onChange(event.target.value)} - placeholder={t('sessions.search.placeholder')} + placeholder={searchMode === 'content' + ? t('sessions.search.content.placeholder') + : t('sessions.search.placeholder')} aria-label={searchLabel} title={searchLabel} className="min-w-0 flex-1 appearance-none bg-transparent px-2 py-1.5 text-sm text-[var(--app-fg)] outline-none placeholder:text-[var(--app-hint)] [text-overflow:ellipsis] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden" @@ -1064,8 +1157,6 @@ export function SessionListSearch(props: { type="button" onClick={() => { props.onChange('') - // The clear button unmounts with the query; keep focus off - // so a later outside click still routes blur through the wrapper. inputRef.current?.focus() }} className="session-list-search-clear-endcap flex min-h-[48px] min-w-[48px] shrink-0 items-center justify-center border-l border-[var(--app-border)] bg-[var(--app-subtle-bg)] text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] focus-visible:ring-inset" @@ -1114,7 +1205,7 @@ function CollapsiblePanel(props: { open: boolean; children: ReactNode }) { const SessionItem = memo(function SessionItem(props: { session: SessionSummary - onSelect: (sessionId: string) => void + onSelect: (sessionId: string, targetMessageId?: string, targetMessageQuery?: string) => void showPath?: boolean api: ApiClient | null titleSuggestionAvailable?: boolean @@ -1123,6 +1214,10 @@ const SessionItem = memo(function SessionItem(props: { inRunningSection?: boolean projectLabel?: string machineLabel?: string + contentSnippet?: string + contentSearchTruncated?: boolean + targetMessageId?: string + targetMessageQuery?: string lastSeenVersion: number }) { const { t } = useTranslation() @@ -1138,6 +1233,10 @@ const SessionItem = memo(function SessionItem(props: { inRunningSection = false, projectLabel, machineLabel, + contentSnippet, + contentSearchTruncated, + targetMessageId, + targetMessageQuery, lastSeenVersion } = props const { haptic } = usePlatform() @@ -1235,7 +1334,7 @@ const SessionItem = memo(function SessionItem(props: { }, onClick: () => { if (!menuOpen) { - onSelect(s.id) + onSelect(s.id, targetMessageId, targetMessageQuery) } }, threshold: 500 @@ -1280,6 +1379,8 @@ const SessionItem = memo(function SessionItem(props: { inRunningSection={inRunningSection} projectLabel={projectLabel} machineLabel={machineLabel} + contentSnippet={contentSnippet} + contentSearchTruncated={contentSearchTruncated} /> @@ -1429,7 +1530,7 @@ function PullRefreshIcon(props: { rotation: number }) { export function SessionList(props: { sessions: SessionSummary[] - onSelect: (sessionId: string) => void + onSelect: (sessionId: string, targetMessageId?: string, targetMessageQuery?: string) => void onNewSession: () => void onNewSessionInDirectory?: (args: { machineId: string | null; directory: string }) => void onBrowse?: () => void @@ -1465,6 +1566,10 @@ export function SessionList(props: { const { machineFilter, setMachineFilter } = useSessionListMachineFilter() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') + const [searchMode, setSearchMode] = useState<'metadata' | 'content'>('metadata') + const [contentSearchResponse, setContentSearchResponse] = useState(null) + const [contentSearchLoading, setContentSearchLoading] = useState(false) + const [contentSearchError, setContentSearchError] = useState(false) const [searchExpanded, setSearchExpanded] = useState(false) const [customStart, setCustomStart] = useState('') const [customEnd, setCustomEnd] = useState('') @@ -1526,6 +1631,14 @@ export function SessionList(props: { const normalizedQuery = normalizeSearch(searchQuery) const timeRange = getSessionTimeRange(customStart, customEnd) const isFiltering = normalizedQuery.length > 0 || timeRange !== null + const contentSearchActive = searchMode === 'content' && normalizedQuery.length > 0 + const contentSearchReady = contentSearchActive + && Array.from(normalizedQuery).length >= MIN_CONTENT_SEARCH_QUERY_LENGTH + const contentSearchHasIncompleteResults = contentSearchActive + && contentSearchReady + && !contentSearchLoading + && !contentSearchError + && contentSearchResponse?.hasPotentiallyIncompleteResults === true const resolveMachineLabel = (machineId: string | null): string => { if (machineId && machineLabelsById[machineId]) { @@ -1559,7 +1672,7 @@ export function SessionList(props: { () => new Set(allSessions.map(session => formatDateValue(new Date(session.updatedAt)))), [allSessions] ) - const hasTextQuery = normalizedQuery.length > 0 + const hasTextQuery = normalizedQuery.length > 0 && !contentSearchActive const timeScopedSessions = useMemo( () => timeRange === null ? allSessions @@ -1574,6 +1687,14 @@ export function SessionList(props: { ) const visibleSessions = useMemo( () => { + if (contentSearchActive) { + const results = contentSearchResponse?.results.map((result) => result.session) ?? [] + const prepared = prepareSidebarSessions(results, selectedSessionId) + const sidebarSessions = showActiveSessionsOnly + ? filterActiveSessionsOnly(prepared, selectedSessionId) + : prepared + return sidebarSessions.filter(session => sessionMatchesTimeRange(session, timeRange)) + } if (!isFiltering) return allSessions const matched = hasTextQuery && searchScoreIndex ? timeScopedSessions.filter(session => searchScoreIndex.matchedIds.has(session.id)) @@ -1589,8 +1710,29 @@ export function SessionList(props: { } return matched }, - [allSessions, hasTextQuery, isFiltering, normalizedQuery, searchScoreIndex, timeScopedSessions, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps + [allSessions, contentSearchActive, contentSearchResponse, hasTextQuery, isFiltering, normalizedQuery, searchScoreIndex, selectedSessionId, showActiveSessionsOnly, timeScopedSessions, timeRange?.start, timeRange?.end, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps ) + const contentSnippetBySessionId = useMemo(() => { + const snippets = new Map() + for (const result of contentSearchResponse?.results ?? []) { + snippets.set(result.session.id, result.match.snippet) + } + return snippets + }, [contentSearchResponse]) + const contentTargetMessageIdBySessionId = useMemo(() => { + const messageIds = new Map() + for (const result of contentSearchResponse?.results ?? []) { + messageIds.set(result.session.id, result.match.messageId) + } + return messageIds + }, [contentSearchResponse]) + const contentSearchTruncatedBySessionId = useMemo(() => { + const truncated = new Set() + for (const result of contentSearchResponse?.results ?? []) { + if (result.match.truncated) truncated.add(result.session.id) + } + return truncated + }, [contentSearchResponse]) const allGroups = useMemo( () => groupSessionsByDirectory(allSessions), [allSessions] @@ -1643,6 +1785,76 @@ export function SessionList(props: { && machineFilters.some(mg => (mg.machineId ?? UNKNOWN_MACHINE_ID) === machineFilter) ? machineFilter : null + const contentSearchCandidateSessions = useMemo(() => { + const timeFiltered = allSessions.filter(session => sessionMatchesTimeRange(session, timeRange)) + const lastSeenById = showUnreadOnly ? getSessionLastSeenSnapshot() : null + const unreadFiltered = showUnreadOnly + ? filterUnreadSessionsOnly( + timeFiltered, + selectedSessionId, + id => lastSeenById?.[id] ?? 0, + getSessionManualUnreadAt + ) + : timeFiltered + return activeMachineFilter === null + ? unreadFiltered + : unreadFiltered.filter(session => + (session.metadata?.machineId ?? UNKNOWN_MACHINE_ID) === activeMachineFilter + ) + }, [allSessions, activeMachineFilter, selectedSessionId, showUnreadOnly, lastSeenVersion, timeRange?.start, timeRange?.end]) + const contentSearchSessionIds = useMemo( + () => contentSearchCandidateSessions.map(session => session.id), + [contentSearchCandidateSessions] + ) + + useEffect(() => { + if (!contentSearchReady || !api) { + setContentSearchResponse(null) + setContentSearchLoading(false) + setContentSearchError(false) + return + } + + // Do not leave the previous query's targets clickable while the new + // request is waiting for its debounce window. + setContentSearchResponse(null) + setContentSearchError(false) + if (contentSearchSessionIds.length === 0) { + setContentSearchResponse({ results: [], hasPotentiallyIncompleteResults: false }) + setContentSearchLoading(false) + return + } + + const controller = new AbortController() + setContentSearchLoading(true) + const timer = window.setTimeout(() => { + const limit = Math.min(100, Math.max(50, contentSearchSessionIds.length)) + void api.searchSessionContent( + normalizedQuery, + limit, + controller.signal, + contentSearchSessionIds + ) + .then((response) => { + if (controller.signal.aborted) return + setContentSearchResponse(response) + }) + .catch(() => { + if (controller.signal.aborted) return + setContentSearchResponse({ results: [], hasPotentiallyIncompleteResults: false }) + setContentSearchError(true) + }) + .finally(() => { + if (!controller.signal.aborted) setContentSearchLoading(false) + }) + }, 180) + + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [api, contentSearchReady, contentSearchSessionIds, normalizedQuery]) + // Unread after search/time, before machine scope — so machineFilters (from allSessions) // stay stable. Filtering unread into allSessions would drop machines with zero unread // and clear a persisted machine selection (showing other machines' unread instead). @@ -1886,6 +2098,10 @@ export function SessionList(props: { inRunningSection projectLabel={getSessionProjectLabel(getSessionProjectPath(s.metadata) ?? 'Other')} machineLabel={resolveMachineLabel(s.metadata?.machineId ?? null)} + contentSnippet={contentSnippetBySessionId.get(s.id)} + contentSearchTruncated={contentSearchTruncatedBySessionId.has(s.id)} + targetMessageId={contentTargetMessageIdBySessionId.get(s.id)} + targetMessageQuery={contentSearchActive ? normalizedQuery : undefined} lastSeenVersion={lastSeenVersion} /> ))} @@ -2012,6 +2228,10 @@ export function SessionList(props: { titleSuggestionAvailable={titleSuggestionAvailable} selected={s.id === selectedSessionId} showDetailedStatus={showDetailedStatus} + contentSnippet={contentSnippetBySessionId.get(s.id)} + contentSearchTruncated={contentSearchTruncatedBySessionId.has(s.id)} + targetMessageId={contentTargetMessageIdBySessionId.get(s.id)} + targetMessageQuery={contentSearchActive ? normalizedQuery : undefined} lastSeenVersion={lastSeenVersion} />
@@ -2297,6 +2517,8 @@ export function SessionList(props: { }} expanded={searchExpanded} onExpandedChange={setSearchExpanded} + searchMode={searchMode} + onSearchModeChange={setSearchMode} chromeMinPx={searchQuery.length > 0 ? searchChromePx : 0} maxWidthPx={searchQuery.length > 0 ? searchMaxPx : 0} /> @@ -2515,9 +2737,42 @@ export function SessionList(props: { /> ) : null} - {props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null || showUnreadOnly) && groups.length === 0 && runningSessionTotal === 0 && activeSessionTotal === 0 && globalPinnedSessions.length === 0 ? ( + {props.sessions.length > 0 && contentSearchLoading ? (
- {t('sessions.search.noResults')} + {t('sessions.search.content.loading')} +
+ ) : null} + + {props.sessions.length > 0 && contentSearchError ? ( +
+ {t('sessions.search.content.error')} +
+ ) : null} + + {props.sessions.length > 0 && contentSearchHasIncompleteResults ? ( +
+ + + {contentSearchResponse?.results.length + ? t('sessions.search.content.incomplete') + : t('sessions.search.content.noResultsIncomplete')} + +
+ ) : null} + + {props.sessions.length > 0 && !contentSearchLoading && !contentSearchError && !contentSearchHasIncompleteResults && (isFiltering || activeMachineFilter !== null || showUnreadOnly) && groups.length === 0 && runningSessionTotal === 0 && activeSessionTotal === 0 && globalPinnedSessions.length === 0 ? ( +
+ {contentSearchActive + ? contentSearchReady + ? contentSearchResponse?.hasPotentiallyIncompleteResults + ? t('sessions.search.content.noResultsIncomplete') + : t('sessions.search.content.noResults') + : t('sessions.search.content.minQuery') + : t('sessions.search.noResults')}
) : null} @@ -2564,6 +2819,10 @@ export function SessionList(props: { inRunningSection projectLabel={getSessionProjectLabel(getSessionProjectPath(s.metadata) ?? 'Other')} machineLabel={resolveMachineLabel(s.metadata?.machineId ?? null)} + contentSnippet={contentSnippetBySessionId.get(s.id)} + contentSearchTruncated={contentSearchTruncatedBySessionId.has(s.id)} + targetMessageId={contentTargetMessageIdBySessionId.get(s.id)} + targetMessageQuery={contentSearchActive ? normalizedQuery : undefined} lastSeenVersion={lastSeenVersion} /> ))} diff --git a/web/src/components/SessionListSearch.test.tsx b/web/src/components/SessionListSearch.test.tsx new file mode 100644 index 00000000..6c827441 --- /dev/null +++ b/web/src/components/SessionListSearch.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { SessionListSearch } from './SessionList' + +function renderSearch(overrides: Partial[0]> = {}) { + return render( + + + + ) +} + +describe('SessionListSearch content scope', () => { + it('flips to content scope in one tap without changing the default scope', () => { + const onSearchModeChange = vi.fn() + renderSearch({ onSearchModeChange }) + + expect(screen.getByRole('searchbox').getAttribute('placeholder')).toContain('title/path') + const scopeButton = screen.getByTestId('session-list-search-scope') + expect(scopeButton).toHaveAttribute('data-search-scope', 'metadata') + fireEvent.click(scopeButton) + expect(onSearchModeChange).toHaveBeenCalledWith('content') + }) + + it('flips back to metadata scope from content scope', () => { + const onSearchModeChange = vi.fn() + renderSearch({ searchMode: 'content', onSearchModeChange }) + + fireEvent.click(screen.getByTestId('session-list-search-scope')) + expect(onSearchModeChange).toHaveBeenCalledWith('metadata') + }) + + it('names the real scopes instead of Default/Content chrome labels', () => { + renderSearch() + + const scopeButton = screen.getByTestId('session-list-search-scope') + // No chrome label text: the glyph plus tooltip carry the mode. + expect(scopeButton).toHaveTextContent('') + expect(scopeButton.getAttribute('title')).toBe( + 'Searching session details: title, summary, path, Agent, machine, ID. Switch to Message text' + ) + expect(scopeButton.getAttribute('aria-label')).toBe(scopeButton.getAttribute('title')) + + renderSearch({ searchMode: 'content' }) + const contentScope = screen.getAllByTestId('session-list-search-scope')[1]! + expect(contentScope.getAttribute('title')).toBe( + 'Searching message text inside sessions. Switch to Session details' + ) + }) + + it('gives the scope toggle the same hit target as the clear endcap', () => { + renderSearch({ value: 'cache' }) + + const scopeButton = screen.getByTestId('session-list-search-scope') + const clearButton = screen.getByRole('button', { name: 'Clear search' }) + for (const size of ['min-h-[48px]', 'min-w-[48px]']) { + expect(scopeButton.className).toContain(size) + expect(clearButton.className).toContain(size) + } + }) + + it('keeps the content-scope glyph on the collapsed applying chip', () => { + renderSearch({ expanded: false, value: 'cache', searchMode: 'content' }) + + expect(screen.getByText('cache')).toBeInTheDocument() + const chip = document.querySelector('[data-toolbar-chrome][data-search-scope]') + expect(chip).toHaveAttribute('data-search-scope', 'content') + }) + + it('leaves the idle collapsed button a plain magnifier with no query applied', () => { + renderSearch({ expanded: false, value: '', searchMode: 'content' }) + + // No scope is in effect yet, and this button is what opens search. + expect(document.querySelector('[data-toolbar-chrome][data-search-scope]')).toBeNull() + }) + + it('uses the message-content placeholder after selecting content scope', () => { + renderSearch({ searchMode: 'content' }) + + expect(screen.getByRole('searchbox').getAttribute('placeholder')).toBe('Search message content') + }) +}) diff --git a/web/src/components/SessionRowSummary.test.tsx b/web/src/components/SessionRowSummary.test.tsx index a6785474..bc337df4 100644 --- a/web/src/components/SessionRowSummary.test.tsx +++ b/web/src/components/SessionRowSummary.test.tsx @@ -224,6 +224,21 @@ describe('SessionRowSummary background status', () => { expect(container.querySelector('.text-\\[var\\(--app-badge-warning-text\\)\\] svg')).not.toBeNull() expect(container.querySelectorAll('.rounded-full')).toHaveLength(1) }) + + it('marks a content-search snippet whose source message was truncated', () => { + render( + + + + ) + + expect(screen.getByLabelText('This message was truncated for search; some content may be missing.')) + .toBeInTheDocument() + }) }) function expectUnread() { diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index d442dc19..434d90fd 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -1,7 +1,7 @@ import { useMemo, useSyncExternalStore } from 'react' import type { SessionSummary } from '@/types/api' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' -import { ScheduleIcon } from '@/components/icons' +import { InfoIcon, ScheduleIcon } from '@/components/icons' import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip' import { getAttentionLabel, SessionAttentionIndicator, SessionRequestIcon } from '@/components/SessionAttentionIndicator' import { classifySessionAttention } from '@/lib/sessionAttention' @@ -98,6 +98,10 @@ export function SessionRowSummary(props: { projectLabel?: string /** Machine label shown next to the project name (pinned "in progress" rows). */ machineLabel?: string + /** Context returned by the opt-in session message-content search. */ + contentSnippet?: string + /** The indexed message exceeded the search index budget. */ + contentSearchTruncated?: boolean }) { const { session: s, @@ -112,6 +116,8 @@ export function SessionRowSummary(props: { inRunningSection = false, projectLabel, machineLabel, + contentSnippet, + contentSearchTruncated = false, } = props const { t } = useTranslation() const sessionName = getSessionTitle(s) @@ -256,6 +262,25 @@ export function SessionRowSummary(props: { ) : null}
+ {contentSnippet ? ( +
+ {contentSnippet} + {contentSearchTruncated ? ( + + + + ) : null} +
+ ) : null} {projectLabel || machineLabel ? (
{[projectLabel, machineLabel].filter(Boolean).join(' · ')} diff --git a/web/src/hooks/queries/useMessages.test.tsx b/web/src/hooks/queries/useMessages.test.tsx new file mode 100644 index 00000000..e0376d91 --- /dev/null +++ b/web/src/hooks/queries/useMessages.test.tsx @@ -0,0 +1,47 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' +import type { DecryptedMessage } from '@/types/api' +import { clearMessageWindow } from '@/lib/message-window-store' +import { useMessages } from './useMessages' + +const sessionId = 'use-messages-search-target-cache' + +afterEach(() => { + clearMessageWindow(sessionId) + sessionStorage.clear() + vi.restoreAllMocks() +}) + +describe('useMessages search-target initialization', () => { + it('enters history mode before skipping the initial tail for a legacy cached window', async () => { + const cachedMessage = { + id: 'cached-message', + role: 'agent', + content: { type: 'text', text: 'cached message' }, + createdAt: 1_000, + seq: 1, + invokedAt: 1_000, + } as unknown as DecryptedMessage + sessionStorage.setItem(`hapi:message-window:v2:${sessionId}`, JSON.stringify({ + messages: [cachedMessage], + hasMore: true, + oldestPositionAt: 1_000, + oldestPositionSeq: 1, + newestPositionAt: 1_000, + newestPositionSeq: 1, + epoch: 1, + requiresLatestReset: true, + })) + const api = { getMessages: vi.fn() } as unknown as ApiClient + + const { result } = renderHook(() => useMessages(api, sessionId, { + skipInitialTailSync: true, + })) + + await waitFor(() => expect(result.current.isSyncingTail).toBe(false)) + expect(result.current.viewMode).toBe('history') + expect(result.current.messages).toEqual([cachedMessage]) + expect(api.getMessages).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/hooks/queries/useMessages.ts b/web/src/hooks/queries/useMessages.ts index 0fbd5313..b6b1c451 100644 --- a/web/src/hooks/queries/useMessages.ts +++ b/web/src/hooks/queries/useMessages.ts @@ -1,16 +1,20 @@ -import { useCallback, useEffect, useLayoutEffect, useSyncExternalStore } from 'react' +import { useCallback, useLayoutEffect, useState, useSyncExternalStore } from 'react' import type { ApiClient } from '@/api/client' import type { DecryptedMessage } from '@/types/api' import { activateMessageWindow, cancelOlderMessageLoad, + fetchNewerMessages, fetchOlderMessages, getMessageWindowState, + loadMessageContext, + setMessageWindowTargetLock, setMessageViewMode, subscribeMessageWindow, syncTailMessages, type MessageViewMode, type MessageWindowState, + type NewerLoadOutcome, type OlderLoadOutcome, } from '@/lib/message-window-store' @@ -25,27 +29,39 @@ export const EMPTY_STATE: MessageWindowState = { isLoadingMore: false, warning: null, viewMode: 'tail', + requiresLatestReset: false, + hasMoreNewer: false, messagesVersion: 0, historyVersion: 0, tailRevision: 0, } -export function useMessages(api: ApiClient | null, sessionId: string | null): { +export function useMessages( + api: ApiClient | null, + sessionId: string | null, + options: { skipInitialTailSync?: boolean } = {} +): { messages: DecryptedMessage[] readThroughRevision?: number warning: string | null isSyncingTail: boolean isLoadingMore: boolean hasMore: boolean + hasMoreNewer: boolean viewMode: MessageViewMode messagesVersion: number historyVersion: number tailRevision: number loadMore: (onBeforeApply?: (historyVersion: number) => boolean) => Promise + loadNewer: (resolvePreserveMessageId?: () => string | null) => Promise + loadMessageContext: (messageId: string) => Promise cancelLoadMore: () => void refetch: () => Promise setViewMode: (mode: MessageViewMode) => void + jumpToTail: () => void } { + const initialSyncKey = api && sessionId ? sessionId : null + const [initialSyncReadyKey, setInitialSyncReadyKey] = useState(null) const state = useSyncExternalStore( useCallback((listener) => { if (!sessionId) return () => {} @@ -54,18 +70,41 @@ export function useMessages(api: ApiClient | null, sessionId: string | null): { useCallback(() => sessionId ? getMessageWindowState(sessionId) : EMPTY_STATE, [sessionId]), () => EMPTY_STATE ) - useLayoutEffect(() => { - if (sessionId) { + if (sessionId && !options.skipInitialTailSync) { activateMessageWindow(sessionId) } - }, [sessionId]) + }, [options.skipInitialTailSync, sessionId]) - useEffect(() => { - if (api && sessionId) { - void syncTailMessages(api, sessionId) + // Start the initial tail reconciliation in the layout phase so a search + // target cannot begin a context load in the gap before the tail request + // invalidates it. HappyThread waits for this state to settle before it + // attempts DOM anchoring. + useLayoutEffect(() => { + if (!api || !sessionId) return + setMessageWindowTargetLock(sessionId, Boolean(options.skipInitialTailSync)) + if (options.skipInitialTailSync) { + // A search result already identifies the exact message to load. + // Avoid fetching the latest 200 messages first: inactive sessions + // can contain very large tool payloads, and that request can delay + // the much smaller, targeted context request for a long time. + setMessageViewMode(sessionId, 'history') + setInitialSyncReadyKey(sessionId) + return () => { + setMessageWindowTargetLock(sessionId, false) + } } - }, [api, sessionId]) + let active = true + void syncTailMessages(api, sessionId).then(() => { + if (active) { + setInitialSyncReadyKey(sessionId) + } + }) + return () => { + active = false + setMessageWindowTargetLock(sessionId, false) + } + }, [api, sessionId, options.skipInitialTailSync]) const loadMore = useCallback(async (onBeforeApply?: (historyVersion: number) => boolean) => { if (!api || !sessionId) { @@ -74,12 +113,24 @@ export function useMessages(api: ApiClient | null, sessionId: string | null): { return await fetchOlderMessages(api, sessionId, { onBeforeApply }) }, [api, sessionId]) + const loadNewer = useCallback(async (resolvePreserveMessageId?: () => string | null) => { + if (!api || !sessionId) { + return { kind: 'stopped', reason: 'unavailable' } as const + } + return await fetchNewerMessages(api, sessionId, { resolvePreserveMessageId }) + }, [api, sessionId]) + const cancelLoadMore = useCallback(() => { if (sessionId) { cancelOlderMessageLoad(sessionId) } }, [sessionId]) + const loadMessageContextForSession = useCallback(async (messageId: string) => { + if (!api || !sessionId) return false + return await loadMessageContext(api, sessionId, messageId) + }, [api, sessionId]) + const refetch = useCallback(async () => { if (!api || !sessionId) return await syncTailMessages(api, sessionId, { ensureAfterCurrent: true }) @@ -87,9 +138,17 @@ export function useMessages(api: ApiClient | null, sessionId: string | null): { const setViewMode = useCallback((mode: MessageViewMode) => { if (!sessionId) return - const previousMode = getMessageWindowState(sessionId).viewMode + const previous = getMessageWindowState(sessionId) setMessageViewMode(sessionId, mode) - if (mode === 'tail' && previousMode !== 'tail' && api) { + if (mode === 'tail' && api && (previous.viewMode !== 'tail' || previous.requiresLatestReset)) { + void syncTailMessages(api, sessionId, { ensureAfterCurrent: true }) + } + }, [api, sessionId]) + + const jumpToTail = useCallback(() => { + if (!sessionId) return + setMessageViewMode(sessionId, 'tail') + if (api) { void syncTailMessages(api, sessionId, { ensureAfterCurrent: true }) } }, [api, sessionId]) @@ -98,16 +157,31 @@ export function useMessages(api: ApiClient | null, sessionId: string | null): { messages: state.messages, readThroughRevision: state.readThroughRevision, warning: state.warning, - isSyncingTail: state.isSyncingTail, + // The store's first isSyncingTail publication can occur after child + // layout effects. Keep search-target anchoring blocked until the + // initial reconciliation promise has settled as well, so a persisted + // historical window cannot be mistaken for the latest tail. + isSyncingTail: state.isSyncingTail + // `requiresLatestReset` is expected while a retained history + // window is being re-activated into tail mode. Once a search + // context is applied it remains true as a marker for the next + // tail activation, but the current history view must still be + // renderable and searchable. + || (state.requiresLatestReset && state.viewMode === 'tail') + || (initialSyncKey !== null && initialSyncReadyKey !== initialSyncKey), isLoadingMore: state.isLoadingMore, hasMore: state.hasMore, + hasMoreNewer: state.hasMoreNewer, viewMode: state.viewMode, messagesVersion: state.messagesVersion, historyVersion: state.historyVersion, tailRevision: state.tailRevision, loadMore, + loadNewer, + loadMessageContext: loadMessageContextForSession, cancelLoadMore, refetch, setViewMode, + jumpToTail, } } diff --git a/web/src/index.css b/web/src/index.css index b90f8a93..84b0a681 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -46,6 +46,9 @@ --app-link-muted: rgba(17, 24, 39, 0.18); --app-scrollbar-thumb: rgba(107, 114, 128, 0.34); --app-scrollbar-thumb-hover: rgba(107, 114, 128, 0.5); + --app-search-match-bg: #fef3c7; + --app-search-match-fg: #78350f; + --app-search-match-outline: #d97706; /* Theme-aware colors (light mode defaults) */ --app-border: rgba(15, 23, 42, 0.08); @@ -129,6 +132,9 @@ --app-link-muted: rgba(255, 255, 255, 0.2); --app-scrollbar-thumb: rgba(196, 203, 214, 0.28); --app-scrollbar-thumb-hover: rgba(196, 203, 214, 0.42); + --app-search-match-bg: rgba(250, 204, 21, 0.3); + --app-search-match-fg: #fef08a; + --app-search-match-outline: #facc15; --app-border: rgba(255, 255, 255, 0.08); --app-divider: rgba(255, 255, 255, 0.08); @@ -210,6 +216,9 @@ --app-link-muted: rgba(255, 255, 255, 0.22); --app-scrollbar-thumb: rgba(196, 203, 214, 0.24); --app-scrollbar-thumb-hover: rgba(196, 203, 214, 0.4); + --app-search-match-bg: rgba(250, 204, 21, 0.28); + --app-search-match-fg: #fef08a; + --app-search-match-outline: #facc15; --app-border: rgba(255, 255, 255, 0.12); --app-divider: rgba(255, 255, 255, 0.1); @@ -585,6 +594,23 @@ html:has(.chat-scroll-y) #root { overflow-anchor: none; } +/* Keep the message selected from a session-content search result identifiable + * until the in-chat search navigation is dismissed or another match is chosen. */ +.hapi-message-search-target { + background-color: var(--app-chat-user-chip-bg); + border-radius: 0.35rem; +} + +mark.hapi-message-search-target { + color: var(--app-search-match-fg); + background-color: var(--app-search-match-bg); + border-radius: 0.2em; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + box-shadow: none; + animation: none; +} + /* Markdown styles */ .markdown-content a { color: var(--app-link); text-decoration: underline; } .markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; } diff --git a/web/src/lib/assistant-runtime.test.ts b/web/src/lib/assistant-runtime.test.ts index c0cd9e81..92e73238 100644 --- a/web/src/lib/assistant-runtime.test.ts +++ b/web/src/lib/assistant-runtime.test.ts @@ -4,6 +4,7 @@ import { aggregateResponseGroups, assignThreadMessageIds, assignThreadMessageIdsWithStableWrappers, + buildSourceMessageIdsByBlock, findLatestCompletedBoundaryId, getBlockPresentationTimestamp, getResponseGroupTimestamps @@ -136,6 +137,21 @@ describe('assignThreadMessageIds', () => { }) }) +describe('buildSourceMessageIdsByBlock', () => { + it('keeps a raw search-hit id on the first card of a joined assistant group', () => { + const firstTool = toolCall('tool-1') + const target = agentText('raw-target:0', { + sourceMessageIds: ['raw-target', 'raw-target-stream-update'] + }) + const user = userText('user-1') + const sourceIdsByBlock = buildSourceMessageIdsByBlock([firstTool, target, user]) + + expect(sourceIdsByBlock.get(firstTool)).toEqual(['raw-target', 'raw-target-stream-update']) + expect(sourceIdsByBlock.get(target)).toEqual(['raw-target', 'raw-target-stream-update']) + expect(sourceIdsByBlock.get(user)).toEqual(['user-1']) + }) +}) + describe('findLatestCompletedBoundaryId', () => { it('keeps the fork boundary before the active turn while streaming blocks change', () => { const completedUser = userText('u1', { invokedAt: 10 }) diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index f95f1105..55ae65d7 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -53,6 +53,8 @@ export type HappyChatMessageMetadata = { * per-message footer is rendered unchanged. */ turnCount?: number + /** Raw HAPI message ids represented by this rendered assistant-ui card. */ + sourceMessageIds?: string[] } export type HappyRuntimeExtras = Readonly<{ @@ -398,6 +400,120 @@ export function assignThreadMessageIds( return assignThreadMessageIdsWithStableWrappers(blocks, new WeakMap()) } +function getSourceMessageIdsForBlock(block: VisibleChatBlock): string[] { + if ( + block.kind === 'agent-text' + || block.kind === 'agent-reasoning' + ) { + if (block.sourceMessageIds && block.sourceMessageIds.length > 0) { + return block.sourceMessageIds + } + const separator = block.id.lastIndexOf(':') + return [separator > 0 ? block.id.slice(0, separator) : block.id] + } + + if (block.kind === 'codex-review' || block.kind === 'generated-image') { + const separator = block.id.lastIndexOf(':') + return [separator > 0 ? block.id.slice(0, separator) : block.id] + } + + if ( + block.kind === 'user-text' + || block.kind === 'agent-event' + || block.kind === 'cli-output' + ) { + return [block.id] + } + + // Tool-call ids are native tool ids rather than HAPI message ids. A + // neighboring text/reasoning block still contributes its source id to an + // assistant-ui joined card, which covers the common search-hit path. + return [] +} + +type SourceAwareTextMessagePart = { + type: 'text' + text: string + sourceMessageId?: string + sourceMessageIds?: string[] +} + +type SourceAwareReasoningMessagePart = { + type: 'reasoning' + text: string + sourceMessageId?: string + sourceMessageIds?: string[] +} + +// A block that kept both its live and terminal rows carries several source +// ids. Exposing only the first leaves a search hit on any later id to fall +// back to the group-wide marker, which then matches the first occurrence +// anywhere in the joined reply (tiann/hapi#1598 review). +function createTextMessagePart(block: VisibleChatBlock, text: string): SourceAwareTextMessagePart { + const sourceMessageIds = getSourceMessageIdsForBlock(block) + const sourceMessageId = sourceMessageIds[0] + return { + type: 'text', + text, + ...(sourceMessageId ? { sourceMessageId } : {}), + ...(sourceMessageIds.length > 0 ? { sourceMessageIds } : {}) + } +} + +function createReasoningMessagePart(block: VisibleChatBlock, text: string): SourceAwareReasoningMessagePart { + const sourceMessageIds = getSourceMessageIdsForBlock(block) + const sourceMessageId = sourceMessageIds[0] + return { + type: 'reasoning', + text, + ...(sourceMessageId ? { sourceMessageId } : {}), + ...(sourceMessageIds.length > 0 ? { sourceMessageIds } : {}) + } +} + +function mergeUniqueMessageIds(target: string[], ids: readonly string[]): void { + for (const id of ids) { + if (id && !target.includes(id)) target.push(id) + } +} + +/** + * Assistant-ui joins adjacent assistant messages into one rendered card. + * Preserve the raw ids represented by that whole group on the first card so + * a search hit can still locate the card after the join. + */ +export function buildSourceMessageIdsByBlock( + blocks: readonly VisibleChatBlock[] +): Map { + const sourceIdsByBlock = new Map() + + for (let index = 0; index < blocks.length; index += 1) { + const block = blocks[index] + if (visibleBlockRole(block) !== 'assistant') { + sourceIdsByBlock.set(block, getSourceMessageIdsForBlock(block)) + continue + } + + const group: VisibleChatBlock[] = [block] + let cursor = index + 1 + while (cursor < blocks.length && visibleBlockRole(blocks[cursor]!) === 'assistant') { + group.push(blocks[cursor]!) + cursor += 1 + } + + const sourceIds: string[] = [] + for (const groupBlock of group) { + mergeUniqueMessageIds(sourceIds, getSourceMessageIdsForBlock(groupBlock)) + } + for (const groupBlock of group) { + sourceIdsByBlock.set(groupBlock, sourceIds) + } + index = cursor - 1 + } + + return sourceIdsByBlock +} + /** * Finds the latest conversation-history boundary that is safe to fork. * While the main agent is running, every block from the latest invoked user @@ -500,7 +616,7 @@ function toThreadMessageLike( role: 'user', id: threadMessageId, createdAt: new Date(timestamp), - content: [{ type: 'text', text: block.text }], + content: [createTextMessagePart(block, block.text)], metadata: { custom: { kind: 'user', @@ -520,7 +636,7 @@ function toThreadMessageLike( role: 'assistant', id: threadMessageId, createdAt: new Date(timestamp), - content: [{ type: 'text', text: block.text }], + content: [createTextMessagePart(block, block.text)], metadata: { custom: { kind: 'assistant', @@ -560,7 +676,7 @@ function toThreadMessageLike( role: 'assistant', id: threadMessageId, createdAt: new Date(timestamp), - content: [{ type: 'reasoning', text: block.text }], + content: [createReasoningMessagePart(block, block.text)], metadata: { custom: { kind: 'assistant', @@ -578,7 +694,7 @@ function toThreadMessageLike( role: 'assistant', id: threadMessageId, createdAt: new Date(timestamp), - content: [{ type: 'text', text: formatCodexReviewText(block.review) }], + content: [createTextMessagePart(block, formatCodexReviewText(block.review))], metadata: { custom: { kind: 'codex-review', @@ -616,7 +732,7 @@ function toThreadMessageLike( role: 'system', id: threadMessageId, createdAt: new Date(timestamp), - content: [{ type: 'text', text: renderEventLabel(block.event) }], + content: [createTextMessagePart(block, renderEventLabel(block.event))], metadata: { custom: { kind: 'event', @@ -633,7 +749,7 @@ function toThreadMessageLike( role: block.source === 'user' ? 'user' : 'assistant', id: threadMessageId, createdAt: new Date(timestamp), - content: [{ type: 'text', text: block.text }], + content: [createTextMessagePart(block, block.text)], metadata: { custom: { kind: 'cli-output', @@ -894,6 +1010,10 @@ export function useHappyRuntime(props: { ), [props.blocks] ) + const sourceMessageIdsByBlock = useMemo( + () => buildSourceMessageIdsByBlock(props.blocks), + [props.blocks] + ) const aggregates = useMemo( () => aggregateResponseGroups(props.blocks), @@ -912,7 +1032,8 @@ export function useHappyRuntime(props: { responseGroupTimestamps.get(block) ?? getBlockPresentationTimestamp(block) ) const aggregate = aggregates.get(block.id) - if (!aggregate) return message + const sourceMessageIds = sourceMessageIdsByBlock.get(block) + if (!aggregate && (!sourceMessageIds || sourceMessageIds.length === 0)) return message const existing = message.metadata?.custom as HappyChatMessageMetadata | undefined return { ...message, @@ -920,17 +1041,20 @@ export function useHappyRuntime(props: { ...message.metadata, custom: { ...(existing ?? { kind: 'assistant' }), - usage: aggregate.usage, - model: aggregate.model, - invokedAt: aggregate.invokedAt, - durationMs: aggregate.durationMs, - turnCount: aggregate.turnCount, - roundSummary: aggregate.roundSummary + ...(aggregate ? { + usage: aggregate.usage, + model: aggregate.model, + invokedAt: aggregate.invokedAt, + durationMs: aggregate.durationMs, + turnCount: aggregate.turnCount, + roundSummary: aggregate.roundSummary + } : {}), + ...(sourceMessageIds && sourceMessageIds.length > 0 ? { sourceMessageIds } : {}) } satisfies HappyChatMessageMetadata } } }, - [aggregates, responseGroupTimestamps] + [aggregates, responseGroupTimestamps, sourceMessageIdsByBlock] ) // Use cached message converter for performance optimization diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7893088e..de977994 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -78,6 +78,19 @@ export default { 'sessions.search.open': 'Search sessions (title, path, Agent, machine name, ID, and more)', 'sessions.search.clear': 'Clear search', 'sessions.search.noResults': 'No sessions match your filters.', + 'sessions.search.scope.metadata.name': 'Session details', + 'sessions.search.scope.content.name': 'Message text', + 'sessions.search.scope.metadata.description': 'Searching session details: title, summary, path, Agent, machine, ID', + 'sessions.search.scope.content.description': 'Searching message text inside sessions', + 'sessions.search.scope.switch': 'Switch to {scope}', + 'sessions.search.content.placeholder': 'Search message content', + 'sessions.search.content.loading': 'Searching message content…', + 'sessions.search.content.error': 'Message content search failed.', + 'sessions.search.content.minQuery': 'Enter at least 2 characters to search message content.', + 'sessions.search.content.noResults': 'No messages match your search.', + 'sessions.search.content.noResultsIncomplete': 'No indexed messages match your search. Some long messages were truncated, so results may be incomplete.', + 'sessions.search.content.incomplete': 'Some long messages were truncated, so results may be incomplete.', + 'sessions.search.content.truncated': 'This message was truncated for search; some content may be missing.', 'sessions.unreadFilter.toggle': 'Unread only', 'sessions.toolbar.more': 'More session list actions', 'sessions.markAllRead.button': 'Mark all as read ({count})', @@ -1222,6 +1235,12 @@ export default { 'misc.newMessage': '{n} new message{s}', 'misc.scrollToBottom': 'Scroll to bottom', 'misc.loadingMessages': 'Loading messages…', + 'misc.locatingMessage': 'Locating message…', + 'misc.searchMatchNavigation': 'Search match navigation', + 'misc.searchMatches': '{current}/{total}', + 'misc.previousSearchMatch': 'Previous match', + 'misc.nextSearchMatch': 'Next match', + 'misc.closeSearch': 'Close content search', 'misc.pullToLoadOlder': 'Keep pulling to load earlier messages', 'misc.releaseToLoadOlder': 'Release to load earlier messages', 'misc.typeMessage': "Type 'continue' to resume...", diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 34b2154e..517ff7dd 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -78,6 +78,19 @@ export default { 'sessions.search.open': '搜索会话(支持标题、路径、Agent、机器名、ID 等信息)', 'sessions.search.clear': '清除搜索', 'sessions.search.noResults': '没有符合筛选条件的会话。', + 'sessions.search.scope.metadata.name': '会话信息', + 'sessions.search.scope.content.name': '消息正文', + 'sessions.search.scope.metadata.description': '搜索会话信息:标题、摘要、路径、Agent、机器名、ID', + 'sessions.search.scope.content.description': '搜索会话内的消息正文', + 'sessions.search.scope.switch': '切换到{scope}', + 'sessions.search.content.placeholder': '搜索消息内容', + 'sessions.search.content.loading': '正在搜索消息内容…', + 'sessions.search.content.error': '消息内容搜索失败。', + 'sessions.search.content.minQuery': '搜索消息内容至少需要输入 2 个字符。', + 'sessions.search.content.noResults': '没有消息匹配搜索内容。', + 'sessions.search.content.noResultsIncomplete': '没有已索引的消息匹配搜索内容。部分长消息被截断,结果可能不完整。', + 'sessions.search.content.incomplete': '部分长消息被截断,搜索结果可能不完整。', + 'sessions.search.content.truncated': '此消息过长,搜索索引已截断,部分内容可能未被搜索。', 'sessions.unreadFilter.toggle': '仅显示未读', 'sessions.toolbar.more': '更多会话列表操作', 'sessions.markAllRead.button': '全部标为已读({count})', @@ -1220,6 +1233,12 @@ export default { 'misc.newMessage': '{n} 条新消息', 'misc.scrollToBottom': '跳到底部', 'misc.loadingMessages': '加载消息中…', + 'misc.locatingMessage': '定位消息中…', + 'misc.searchMatchNavigation': '搜索匹配项导航', + 'misc.searchMatches': '{current}/{total}', + 'misc.previousSearchMatch': '上一个匹配项', + 'misc.nextSearchMatch': '下一个匹配项', + 'misc.closeSearch': '关闭内容搜索', 'misc.pullToLoadOlder': '继续下拉加载更早消息', 'misc.releaseToLoadOlder': '松开加载', 'misc.typeMessage': "输入 'continue' 继续...", diff --git a/web/src/lib/message-window-store.test.ts b/web/src/lib/message-window-store.test.ts index 620b4659..c413e733 100644 --- a/web/src/lib/message-window-store.test.ts +++ b/web/src/lib/message-window-store.test.ts @@ -5,7 +5,7 @@ import type { NormalizedMessage } from '@/chat/types' import { aggregateResponseGroups } from '@/lib/assistant-runtime' import { buildVisibleChatBlocks } from '@/chat/toolGroups' import type { ApiClient } from '@/api/client' -import type { DecryptedMessage, MessagesResponse } from '@/types/api' +import type { DecryptedMessage, MessageContextResponse, MessagesResponse } from '@/types/api' import { HISTORY_WINDOW_SIZE, INITIAL_PAGE_SIZE, @@ -14,14 +14,18 @@ import { appendOptimisticMessage, clearMessageWindow, fetchOlderMessages, + OLDER_LOAD_WINDOW_SIZE, getMessageWindowState, getQueuedReconcileCandidateLocalIds, ingestIncomingMessages, invalidateMessageWindow, + fetchNewerMessages, + loadMessageContext, markMessagesConsumed, reconcileQueuedLocalIds, removeOptimisticMessage, rewindMessageWindow, + setMessageWindowTargetLock, setMessageViewMode, syncTailMessages, updateMessageStatus, @@ -233,7 +237,33 @@ function beforeResponse( } function createApi(getMessages: ApiClient['getMessages']): ApiClient { - return { getMessages } as ApiClient + return { getMessages, getMessageContext: async () => null } as unknown as ApiClient +} + +function contextResponse( + messages: DecryptedMessage[], + options: { + epoch?: number + hasMore?: boolean + nextBeforeAt: number + nextBeforeSeq: number + snapshotHeadAt: number + snapshotHeadSeq: number + targetMessageId: string + } +): MessageContextResponse { + return { + messages, + targetMessageId: options.targetMessageId, + page: { + epoch: options.epoch ?? 0, + hasMore: options.hasMore ?? false, + nextBeforeAt: options.nextBeforeAt, + nextBeforeSeq: options.nextBeforeSeq, + snapshotHeadAt: options.snapshotHeadAt, + snapshotHeadSeq: options.snapshotHeadSeq + } + } } function deferred() { @@ -248,6 +278,7 @@ function deferred() { afterEach(() => { for (const id of touchedSessions) { + setMessageWindowTargetLock(id, false) clearMessageWindow(id) } touchedSessions.clear() @@ -279,7 +310,42 @@ function makeToolMessages(count: number, firstSeq: number): DecryptedMessage[] { } describe('message tail synchronization', () => { - it('fetches one older context page before first paint when the cold page is tool-only', async () => { + + it('lets a historical target cancel and block background tail synchronization', async () => { + const id = sessionId('target-lock') + const response = deferred() + const getMessages = vi.fn(async () => await response.promise) + const syncing = syncTailMessages(createApi(getMessages), id) + + setMessageWindowTargetLock(id, true) + expect(getMessageWindowState(id).isSyncingTail).toBe(false) + await syncTailMessages(createApi(vi.fn(async () => latestResponse([]))), id) + expect(getMessages).toHaveBeenCalledTimes(1) + + response.resolve(latestResponse([ + makeAgentMessage({ id: 'stale-tail', seq: 1, at: 1_000 }) + ])) + await syncing + expect(getMessageWindowState(id).messages).toEqual([]) + + setMessageWindowTargetLock(id, false) + }) + + it('does not reactivate a retained historical window into tail mode while locked', () => { + const id = sessionId('target-lock-preserves-history') + ingestIncomingMessages(id, [ + makeAgentMessage({ id: 'historical', seq: 1, at: 1_000 }) + ]) + setMessageViewMode(id, 'history') + setMessageWindowTargetLock(id, true) + + activateMessageWindow(id) + + expect(getMessageWindowState(id).viewMode).toBe('history') + setMessageWindowTargetLock(id, false) + }) + +it('fetches one older context page before first paint when the cold page is tool-only', async () => { const id = sessionId('cold-tool-only-context') const tools = makeToolMessages(INITIAL_PAGE_SIZE, 10) const context = makeAgentMessage({ id: 'context', seq: 9, at: 9_000 }) @@ -402,6 +468,7 @@ describe('message tail synchronization', () => { expect(getMessages).toHaveBeenCalledTimes(1) expect(getMessageWindowState(id).messages).toHaveLength(INITIAL_PAGE_SIZE) + }) it('uses a small cold latest page while keeping older loads at full page size', async () => { @@ -468,6 +535,61 @@ describe('message tail synchronization', () => { expect(getMessageWindowState(id).isSyncingTail).toBe(true) }) + it('lets an authoritative rewind refresh through a historical target lock', async () => { + const id = sessionId('rewind-target-lock') + const prefix = makeAgentMessage({ id: 'prefix', seq: 1, at: 1_000 }) + const target = makeUserMessage({ + id: 'target', + seq: 2, + localId: 'rewind-target-lock-local-id', + createdAt: 2_000, + invokedAt: 2_000 + }) + const suffix = makeAgentMessage({ id: 'suffix', seq: 3, at: 3_000 }) + const getMessages = vi.fn() + .mockResolvedValueOnce(latestResponse([prefix, target, suffix], { epoch: 1 })) + .mockResolvedValueOnce(latestResponse([prefix], { epoch: 2 })) + const api = createApi(getMessages) + + await syncTailMessages(api, id) + setMessageWindowTargetLock(id, true) + rewindMessageWindow(id, 'rewind-target-lock-local-id') + await syncTailMessages(api, id) + + expect(getMessages).toHaveBeenCalledTimes(2) + expect(getMessageWindowState(id).messages.map((message) => message.id)).toEqual(['prefix']) + expect(getMessageWindowState(id).isSyncingTail).toBe(false) + }) + + it('lets an epoch-change refresh through a historical target lock', async () => { + const id = sessionId('epoch-target-lock') + const current = makeAgentMessage({ id: 'current', seq: 10, at: 10_000 }) + const fresh = makeAgentMessage({ id: 'fresh', seq: 20, at: 20_000 }) + const getMessages = vi.fn() + .mockResolvedValueOnce(latestResponse([current], { + epoch: 1, + hasMore: true, + nextBeforeAt: 10_000, + nextBeforeSeq: 10 + })) + .mockResolvedValueOnce(beforeResponse([], { + epoch: 2, + hasMore: false, + nextBeforeAt: null, + nextBeforeSeq: null + })) + .mockResolvedValueOnce(latestResponse([fresh], { epoch: 2 })) + const api = createApi(getMessages) + + await syncTailMessages(api, id) + setMessageWindowTargetLock(id, true) + await fetchOlderMessages(api, id) + + expect(getMessages).toHaveBeenCalledTimes(3) + expect(getMessageWindowState(id).messages.map((message) => message.id)).toEqual(['fresh']) + expect(getMessageWindowState(id).isSyncingTail).toBe(false) + }) + it('clears the window when the rewind boundary is outside the loaded page', async () => { const id = sessionId('rewind-boundary-not-loaded') const current = makeAgentMessage({ id: 'current', seq: 40, at: 40_000 }) @@ -547,7 +669,8 @@ describe('message tail synchronization', () => { oldestPositionSeq: 40, newestPositionAt: 40_000, newestPositionSeq: 40, - epoch: 3 + epoch: 3, + requiresLatestReset: false })) expect(getMessageWindowState(id).messages.map((message) => message.id)).toEqual(['cached']) @@ -559,7 +682,7 @@ describe('message tail synchronization', () => { const getMessages = vi.fn(async () => await response.promise) const syncing = syncTailMessages(createApi(getMessages), id) - expect(getMessages).toHaveBeenCalledWith(id, { limit: 20 }) + expect(getMessages).toHaveBeenCalledWith(id, { limit: INITIAL_PAGE_SIZE }) expect(getMessageWindowState(id).messages.map((message) => message.id)).toEqual(['cached']) expect(getMessageWindowState(id).isSyncingTail).toBe(true) @@ -668,7 +791,7 @@ describe('message tail synchronization', () => { const older = makeAgentMessage({ id: 'older', seq: 2_038, at: 2_038_000 }) const getMessages = vi.fn() .mockResolvedValueOnce(latestResponse(latest, { - limit: 20, + limit: INITIAL_PAGE_SIZE, epoch: 3, hasMore: true, nextBeforeAt: 2_039_000, @@ -685,7 +808,7 @@ describe('message tail synchronization', () => { await syncTailMessages(api, id) await fetchOlderMessages(api, id) - expect(getMessages.mock.calls[0]?.[1]).toEqual({ limit: 20 }) + expect(getMessages.mock.calls[0]?.[1]).toEqual({ limit: INITIAL_PAGE_SIZE }) expect(getMessages.mock.calls[1]?.[1]).toEqual({ beforeAt: 2_039_000, beforeSeq: 2_039, @@ -718,7 +841,7 @@ describe('message tail synchronization', () => { await syncTailMessages(createApi(getMessages), id) - expect(getMessages).toHaveBeenCalledWith(id, { limit: 20 }) + expect(getMessages).toHaveBeenCalledWith(id, { limit: INITIAL_PAGE_SIZE }) expect(getMessageWindowState(id)).toMatchObject({ messages: [cached], isSyncingTail: false, @@ -743,7 +866,7 @@ describe('message tail synchronization', () => { const response = deferred() const getMessages = vi.fn(async () => await response.promise) const syncing = syncTailMessages(createApi(getMessages), id) - await vi.waitFor(() => expect(getMessages).toHaveBeenCalledWith(id, { limit: 20 })) + await vi.waitFor(() => expect(getMessages).toHaveBeenCalledWith(id, { limit: INITIAL_PAGE_SIZE })) const optimistic = makeUserMessage({ id: 'local-1', @@ -758,7 +881,7 @@ describe('message tail synchronization', () => { response.resolve(latestResponse([ makeAgentMessage({ id: 'latest', seq: 2_040, at: 2_040_000 }) - ], { limit: 20, epoch: 3 })) + ], { limit: INITIAL_PAGE_SIZE, epoch: 3 })) await syncing expect(getMessageWindowState(id).messages.map((message) => message.id)).toEqual([ @@ -778,7 +901,8 @@ describe('message tail synchronization', () => { oldestPositionSeq: 40, newestPositionAt: 40_000, newestPositionSeq: 40, - epoch: 3 + epoch: 3, + requiresLatestReset: false })) const latest = makeAgentMessage({ id: 'latest', seq: 41, at: 41_000 }) @@ -1007,7 +1131,8 @@ describe('message tail synchronization', () => { oldestPositionSeq: 40, newestPositionAt: 40_000, newestPositionSeq: 40, - epoch: 3 + epoch: 3, + requiresLatestReset: false })) const staleResponse = deferred() @@ -1263,6 +1388,22 @@ describe('message tail synchronization', () => { expect(getMessageWindowState(id).warning).toBeNull() }) + it('releases a historical target lock when the window is cleared', async () => { + const id = sessionId('clear-target-lock') + const getMessages = vi.fn(async () => latestResponse([ + makeAgentMessage({ id: 'fresh-after-clear', seq: 1, at: 1_000 }) + ])) + const api = createApi(getMessages) + + setMessageWindowTargetLock(id, true) + clearMessageWindow(id) + await syncTailMessages(api, id) + + expect(getMessages).toHaveBeenCalledTimes(1) + expect(getMessageWindowState(id).messages.map((message) => message.id)) + .toEqual(['fresh-after-clear']) + }) + it('does not backfill older pages during the latest-tail request', async () => { const id = sessionId('no-cold-backfill') const traceRows = Array.from({ length: 200 }, (_, index) => @@ -1736,6 +1877,718 @@ describe('history view and older pagination', () => { }) }) +describe('reading forward from a context window', () => { + it('reports more newer messages when the context stops short of the head', async () => { + const id = sessionId('context-forward-flag') + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const api = createApi(vi.fn(async () => latestResponse([]))) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // The head is recorded for tail sync, but the window itself ends at 40. + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + }) + + it('does not offer forward loading when the context already reaches the head', async () => { + const id = sessionId('context-forward-at-head') + const target = makeAgentMessage({ id: 'target', seq: 100, at: 100_000 }) + const api = createApi(vi.fn(async () => latestResponse([]))) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 100_000, + nextBeforeSeq: 100, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + expect(getMessageWindowState(id).hasMoreNewer).toBe(false) + }) + + it('loads a bounded newer page and keeps going until the head is reached', async () => { + const id = sessionId('context-forward-paging') + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // First forward page stops mid-gap: still short of seq 100. + getMessages.mockResolvedValueOnce(afterResponse([ + makeAgentMessage({ id: 'mid', seq: 60, at: 60_000 }) + ], { + epoch: 7, + hasMore: true, + nextAfterAt: 60_000, + nextAfterSeq: 60, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100 + })) + const first = await fetchNewerMessages(api, id) + + expect(getMessages.mock.calls[0]?.[1]).toMatchObject({ afterAt: 40_000, afterSeq: 40 }) + expect(first.kind).toBe('loaded') + expect(getMessageWindowState(id).messages.map((m) => m.id)).toEqual(['target', 'mid']) + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + + // Second page reaches the head: forward loading is done. + getMessages.mockResolvedValueOnce(afterResponse([ + makeAgentMessage({ id: 'head', seq: 100, at: 100_000 }) + ], { + epoch: 7, + hasMore: false, + nextAfterAt: 100_000, + nextAfterSeq: 100, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100 + })) + const second = await fetchNewerMessages(api, id) + + expect(getMessages.mock.calls[1]?.[1]).toMatchObject({ afterAt: 60_000, afterSeq: 60 }) + expect(second.kind).toBe('caught-up') + expect(getMessageWindowState(id).hasMoreNewer).toBe(false) + expect(getMessageWindowState(id).messages.map((m) => m.id)).toEqual(['target', 'mid', 'head']) + }) + + it('ignores a concurrently streamed head row when advancing the cursor', async () => { + const id = sessionId('context-forward-sse-race') + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // A live row lands at the head while the reader is still mid-history. + ingestIncomingMessages(id, [ + makeAgentMessage({ id: 'live', seq: 1_001, at: 1_001_000 }) + ]) + + getMessages.mockResolvedValueOnce(afterResponse([ + makeAgentMessage({ id: 'mid', seq: 60, at: 60_000 }) + ], { + epoch: 7, + hasMore: true, + nextAfterAt: 60_000, + nextAfterSeq: 60, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100 + })) + const outcome = await fetchNewerMessages(api, id) + + // The cursor follows the page boundary, not the streamed row, so the + // gap between 60 and the head is still reachable. + expect(outcome.kind).toBe('loaded') + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + getMessages.mockResolvedValueOnce(afterResponse([], { + epoch: 7, + hasMore: true, + nextAfterAt: 80_000, + nextAfterSeq: 80, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100 + })) + await fetchNewerMessages(api, id) + expect(getMessages.mock.calls[1]?.[1]).toMatchObject({ afterAt: 60_000, afterSeq: 60 }) + }) + + it('keeps forward loading available when the context carries a queued prompt', async () => { + const id = sessionId('context-forward-queued') + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + // The hub appends pending prompts to context responses out-of-band; + // they carry a real seq at the session head and must not be mistaken + // for the bounded window's own pagination boundary. + const queued = makeUserMessage({ + id: 'queued-server', + localId: 'queued-server', + seq: 1_001, + createdAt: 1_001_000, + invokedAt: null, + status: 'queued' + }) + const api = createApi(vi.fn(async () => latestResponse([]))) + api.getMessageContext = vi.fn(async () => contextResponse([target, queued], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // Without excluding the queued row the cursor would jump to 1001 and + // report caught-up, stranding messages 41-100. + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + }) + + it('clamps the forward cursor when older loading evicts newer rows', async () => { + const id = sessionId('context-forward-evicted') + const target = makeAgentMessage({ id: 'target', seq: 1_000, at: 1_000_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([ + target, + makeAgentMessage({ id: 'ctx-newest', seq: 1_020, at: 1_020_000 }) + ], { + epoch: 7, + hasMore: true, + nextBeforeAt: 1_000_000, + nextBeforeSeq: 1_000, + snapshotHeadAt: 2_000_000, + snapshotHeadSeq: 2_000, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + + // An older page large enough to push the context rows out of the window. + const older = Array.from({ length: OLDER_LOAD_WINDOW_SIZE }, (_, index) => ( + makeAgentMessage({ + id: `older-${index}`, + seq: index + 1, + at: (index + 1) * 1_000 + }) + )) + getMessages.mockResolvedValueOnce(beforeResponse(older, { + epoch: 7, + hasMore: true, + nextBeforeAt: 1_000, + nextBeforeSeq: 1 + })) + await fetchOlderMessages(api, id) + + const state = getMessageWindowState(id) + expect(state.messages.some((message) => message.id === 'ctx-newest')).toBe(false) + expect(state.hasMoreNewer).toBe(true) + + // Forward loading must resume from what is retained, not from 1020. + getMessages.mockResolvedValueOnce(afterResponse([], { + epoch: 7, + hasMore: true, + nextAfterAt: 1_500_000, + nextAfterSeq: 1_500, + snapshotHeadAt: 2_000_000, + snapshotHeadSeq: 2_000 + })) + await fetchNewerMessages(api, id) + const forwardCall = getMessages.mock.calls.at(-1)?.[1] as { afterSeq?: number } + expect(forwardCall?.afterSeq).toBeLessThan(1_020) + }) + + it('keeps the session head when live updates trim the history window', async () => { + const id = sessionId('context-forward-sse-trim') + const target = makeAgentMessage({ id: 'target', seq: 800, at: 800_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: true, + nextBeforeAt: 800_000, + nextBeforeSeq: 800, + snapshotHeadAt: 2_000_000, + snapshotHeadSeq: 2_000, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // Enough live rows to overflow the window and force a prepend trim, + // which used to replace the session head with the retained boundary. + ingestIncomingMessages(id, Array.from({ length: 620 }, (_, index) => ( + makeAgentMessage({ + id: `live-${index}`, + seq: 900 + index, + at: (900 + index) * 1_000 + }) + ))) + + // The real head is 2000, so a page that reports more remaining must + // not be treated as caught up. + // The trim leaves a retained boundary near seq 1498. A forward page + // landing past that but short of the real head at 2000 is exactly + // where a clobbered head would wrongly report caught-up. + getMessages.mockResolvedValueOnce(afterResponse([], { + epoch: 7, + hasMore: true, + nextAfterAt: 1_600_000, + nextAfterSeq: 1_600, + snapshotHeadAt: 2_000_000, + snapshotHeadSeq: 2_000 + })) + const outcome = await fetchNewerMessages(api, id) + + expect(outcome.kind).toBe('loaded') + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + }) + + it('follows the head the response reports when the session grew', async () => { + const id = sessionId('context-forward-grown-head') + const target = makeAgentMessage({ id: 'target', seq: 1_800, at: 1_800_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: true, + nextBeforeAt: 1_800_000, + nextBeforeSeq: 1_800, + snapshotHeadAt: 2_000_000, + snapshotHeadSeq: 2_000, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // The session grew past the head recorded at context load, and the + // page says so. Reaching the old head must not end forward reading. + getMessages.mockResolvedValueOnce(afterResponse([], { + epoch: 7, + hasMore: true, + nextAfterAt: 2_000_000, + nextAfterSeq: 2_000, + snapshotHeadAt: 2_001_000, + snapshotHeadSeq: 2_001 + })) + const outcome = await fetchNewerMessages(api, id) + + expect(outcome.kind).toBe('loaded') + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + }) + + it('keeps the anchored row when a forward page would trim it away', async () => { + const id = sessionId('context-forward-anchor-kept') + const target = makeAgentMessage({ id: 'target', seq: 1, at: 1_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([ + target, + ...Array.from({ length: 599 }, (_, index) => makeAgentMessage({ + id: `ctx-${index}`, + seq: index + 2, + at: (index + 2) * 1_000 + })) + ], { + epoch: 7, + hasMore: true, + nextBeforeAt: 1_000, + nextBeforeSeq: 1, + snapshotHeadAt: 9_000_000, + snapshotHeadSeq: 9_000, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // A full page arrives while the reader sits on the oldest row. + getMessages.mockResolvedValueOnce(afterResponse( + Array.from({ length: 200 }, (_, index) => makeAgentMessage({ + id: `fwd-${index}`, + seq: 1_000 + index, + at: (1_000 + index) * 1_000 + })), + { + epoch: 7, + hasMore: true, + nextAfterAt: 1_199_000, + nextAfterSeq: 1_199, + snapshotHeadAt: 9_000_000, + snapshotHeadSeq: 9_000 + } + )) + await fetchNewerMessages(api, id, { resolvePreserveMessageId: () => 'target' }) + + // Without the guard the oldest rows are trimmed and the reader is + // dropped somewhere else entirely. + expect(getMessageWindowState(id).messages.some((m) => m.id === 'target')).toBe(true) + }) + + it('protects the row the reader moved to, not the one they started from', async () => { + const id = sessionId('context-forward-anchor-moves') + const target = makeAgentMessage({ id: 'target', seq: 1, at: 1_000 }) + const getMessages = vi.fn() + const api = createApi(getMessages) + api.getMessageContext = vi.fn(async () => contextResponse([ + target, + ...Array.from({ length: 599 }, (_, i) => makeAgentMessage({ + id: `ctx-${i}`, seq: i + 2, at: (i + 2) * 1_000 + })) + ], { + epoch: 7, + hasMore: true, + nextBeforeAt: 1_000, + nextBeforeSeq: 1, + snapshotHeadAt: 9_000_000, + snapshotHeadSeq: 9_000, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + // The reader starts at the bottom, then scrolls back to the oldest row + // while the page is still in flight. + let anchored = 'ctx-598' + getMessages.mockImplementationOnce(async () => { + anchored = 'target' + return afterResponse( + Array.from({ length: 200 }, (_, i) => makeAgentMessage({ + id: `fwd-${i}`, seq: 1_000 + i, at: (1_000 + i) * 1_000 + })), + { + epoch: 7, + hasMore: true, + nextAfterAt: 1_199_000, + nextAfterSeq: 1_199, + snapshotHeadAt: 9_000_000, + snapshotHeadSeq: 9_000 + } + ) + }) + await fetchNewerMessages(api, id, { resolvePreserveMessageId: () => anchored }) + + // A value captured at request time would have protected 'ctx-598' and + // trimmed 'target' out from under the reader. + expect(getMessageWindowState(id).messages.some((m) => m.id === 'target')).toBe(true) + }) + + it('drops the forward cursor when the window returns to the tail', async () => { + const id = sessionId('context-forward-tail-return') + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const api = createApi(vi.fn(async () => latestResponse([]))) + api.getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: true, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + + setMessageViewMode(id, 'tail') + + // Otherwise a later hit already inside the latest window would page + // from the stale historical cursor and splice in disconnected rows. + expect(getMessageWindowState(id).hasMoreNewer).toBe(false) + expect(await fetchNewerMessages(api, id)) + .toEqual({ kind: 'stopped', reason: 'exhausted' }) + }) + + it('seeds the forward cursor when older paging trims a search-locked window', async () => { + const id = sessionId('context-forward-already-loaded') + // A hit already inside the rendered window: loadMessageContext is + // skipped, so there is no cursor, but the search lock is held. + sessionStorage.setItem(`hapi:message-window:v2:${id}`, JSON.stringify({ + messages: Array.from({ length: 700 }, (_, i) => makeAgentMessage({ + id: `row-${i}`, seq: i + 100, at: (i + 100) * 1_000 + })), + hasMore: true, + oldestPositionAt: 100_000, + oldestPositionSeq: 100, + newestPositionAt: 799_000, + newestPositionSeq: 799, + epoch: 7, + requiresLatestReset: false + })) + setMessageViewMode(id, 'history') + setMessageWindowTargetLock(id, true) + expect(getMessageWindowState(id).hasMoreNewer).toBe(false) + + // Older paging prepends enough rows to push the newer end out. + const getMessages = vi.fn().mockResolvedValueOnce(beforeResponse( + Array.from({ length: 400 }, (_, i) => makeAgentMessage({ + id: `older-${i}`, seq: i + 1, at: (i + 1) * 100 + })), + { epoch: 7, hasMore: true, nextBeforeAt: 100, nextBeforeSeq: 1 } + )) + await fetchOlderMessages(createApi(getMessages), id) + + // The trimmed-away newer rows must stay reachable by reading forward. + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + setMessageWindowTargetLock(id, false) + }) + + it('refuses to page forward when there is nothing ahead', async () => { + const id = sessionId('context-forward-exhausted') + const getMessages = vi.fn() + const api = createApi(getMessages) + + const outcome = await fetchNewerMessages(api, id) + + expect(outcome).toEqual({ kind: 'stopped', reason: 'exhausted' }) + expect(getMessages).not.toHaveBeenCalled() + }) + + it('restores the forward cursor from a persisted context window', () => { + const id = sessionId('context-forward-persist') + sessionStorage.setItem(`hapi:message-window:v2:${id}`, JSON.stringify({ + messages: [makeAgentMessage({ id: 'target', seq: 40, at: 40_000 })], + hasMore: false, + oldestPositionAt: 40_000, + oldestPositionSeq: 40, + newestPositionAt: 100_000, + newestPositionSeq: 100, + contextNewestAt: 40_000, + contextNewestSeq: 40, + epoch: 7, + requiresLatestReset: true + })) + + // Reading forward survives the reload instead of dead-ending again. + expect(getMessageWindowState(id).hasMoreNewer).toBe(true) + }) + + it('does not resume forward loading for a legacy window with no context cursor', () => { + const id = sessionId('context-forward-legacy') + sessionStorage.setItem(`hapi:message-window:v2:${id}`, JSON.stringify({ + messages: [makeAgentMessage({ id: 'cached', seq: 40, at: 40_000 })], + hasMore: false, + oldestPositionAt: 40_000, + oldestPositionSeq: 40, + newestPositionAt: 100_000, + newestPositionSeq: 100, + epoch: 7, + requiresLatestReset: true + })) + + expect(getMessageWindowState(id).hasMoreNewer).toBe(false) + }) +}) + +describe('search-target message context', () => { + it('lets an initial tail sync register before requesting search context', async () => { + const id = sessionId('search-context-initial-tail-race') + const latest = makeAgentMessage({ id: 'latest', seq: 100, at: 100_000 }) + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const tailResponse = deferred() + const getMessages = vi.fn(async () => await tailResponse.promise) + const getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + const api = createApi(getMessages) + api.getMessageContext = getMessageContext + + const loading = loadMessageContext(api, id, 'target') + const syncing = syncTailMessages(api, id) + await Promise.resolve() + expect(getMessageContext).not.toHaveBeenCalled() + + tailResponse.resolve(latestResponse([latest], { epoch: 7 })) + await syncing + await expect(loading).resolves.toBe(true) + expect(getMessageContext).toHaveBeenCalledWith(id, 'target') + }) + + it('waits for an active tail reconciliation before loading the searched context', async () => { + const id = sessionId('search-context-tail-race') + const latest = makeAgentMessage({ id: 'latest', seq: 100, at: 100_000 }) + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const tailResponse = deferred() + const getMessages = vi.fn(async () => await tailResponse.promise) + const getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + const api = createApi(getMessages) + api.getMessageContext = getMessageContext + + const syncing = syncTailMessages(api, id) + await vi.waitFor(() => expect(getMessageWindowState(id).isSyncingTail).toBe(true)) + const loading = loadMessageContext(api, id, 'target') + await Promise.resolve() + expect(getMessageContext).not.toHaveBeenCalled() + + tailResponse.resolve(latestResponse([latest], { epoch: 7 })) + await syncing + await expect(loading).resolves.toBe(true) + expect(getMessageContext).toHaveBeenCalledWith(id, 'target') + }) + + it('loads a bounded context, enters history mode, and preserves the latest reset boundary', async () => { + const id = sessionId('search-context') + const latest = makeAgentMessage({ id: 'latest', seq: 100, at: 100_000 }) + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const before = makeAgentMessage({ id: 'before', seq: 39, at: 39_000 }) + const after = makeAgentMessage({ id: 'after', seq: 41, at: 41_000 }) + const getMessages = vi.fn(async () => latestResponse([latest], { epoch: 7 })) + const getMessageContext = vi.fn(async () => contextResponse([before, target, after], { + epoch: 7, + hasMore: true, + nextBeforeAt: 39_000, + nextBeforeSeq: 39, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: 'target' + })) + const api = createApi(getMessages) + api.getMessageContext = getMessageContext + + await syncTailMessages(api, id) + const historyVersion = getMessageWindowState(id).historyVersion + + await expect(loadMessageContext(api, id, 'target')).resolves.toBe(true) + + expect(getMessageContext).toHaveBeenCalledWith(id, 'target') + expect(getMessageWindowState(id)).toMatchObject({ + messages: [before, target, after], + hasMore: true, + viewMode: 'history', + isLoadingMore: false, + historyVersion: historyVersion + 1 + }) + + setMessageViewMode(id, 'tail') + expect(getMessageWindowState(id)).toMatchObject({ + viewMode: 'tail', + epoch: null + }) + }) + + it('preserves acknowledgement and SSE updates received during a context request', async () => { + const id = sessionId('search-context-concurrent-updates') + const queued = makeUserMessage({ + id: 'queued', + seq: 1, + localId: 'queued-local', + createdAt: 1_000, + invokedAt: null, + status: 'queued' + }) + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const concurrent = makeAgentMessage({ id: 'concurrent', seq: 41, at: 41_000 }) + ingestIncomingMessages(id, [queued]) + + const response = deferred() + const getMessageContext = vi.fn(async () => await response.promise) + const api = createApi(vi.fn(async () => latestResponse([]))) + api.getMessageContext = getMessageContext + + const loading = loadMessageContext(api, id, target.id) + await vi.waitFor(() => expect(getMessageContext).toHaveBeenCalledWith(id, target.id)) + + markMessagesConsumed(id, ['queued-local'], 5_000) + ingestIncomingMessages(id, [concurrent]) + + response.resolve(contextResponse([target], { + epoch: 7, + hasMore: false, + nextBeforeAt: 40_000, + nextBeforeSeq: 40, + snapshotHeadAt: 41_000, + snapshotHeadSeq: 41, + targetMessageId: target.id + })) + + await expect(loading).resolves.toBe(true) + + expect(getMessageWindowState(id).messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: queued.id, status: 'sent', invokedAt: 5_000 }), + expect.objectContaining({ id: concurrent.id }), + expect.objectContaining({ id: target.id }) + ])) + }) + + it('does not persist a stale latest-reset boundary for historical contexts', async () => { + const id = sessionId('search-context-persistence') + const target = makeAgentMessage({ id: 'target', seq: 40, at: 40_000 }) + const getMessageContext = vi.fn(async () => contextResponse([target], { + epoch: 7, + hasMore: true, + nextBeforeAt: 39_000, + nextBeforeSeq: 39, + snapshotHeadAt: 100_000, + snapshotHeadSeq: 100, + targetMessageId: target.id + })) + const api = createApi(vi.fn(async () => latestResponse([]))) + api.getMessageContext = getMessageContext + + await expect(loadMessageContext(api, id, target.id)).resolves.toBe(true) + await new Promise((resolve) => setTimeout(resolve, 250)) + + const raw = sessionStorage.getItem(`hapi:message-window:v2:${id}`) + expect(raw).toBeNull() + + const reloadedId = sessionId('search-context-persistence-reloaded') + expect(getMessageWindowState(reloadedId).messages).toEqual([]) + expect(getMessageWindowState(reloadedId).requiresLatestReset).toBe(false) + }) + + it('forces a latest reset for legacy cached windows without the persisted flag', () => { + const id = sessionId('legacy-history-cache') + sessionStorage.setItem(`hapi:message-window:v2:${id}`, JSON.stringify({ + messages: [makeAgentMessage({ id: 'legacy', seq: 40, at: 40_000 })], + hasMore: true, + oldestPositionAt: 40_000, + oldestPositionSeq: 40, + })) + + expect(getMessageWindowState(id).requiresLatestReset).toBe(true) + }) + + it('does not replace the current window when the target disappeared', async () => { + const id = sessionId('search-context-missing') + const latest = makeAgentMessage({ id: 'latest', seq: 1, at: 1_000 }) + const getMessages = vi.fn(async () => latestResponse([latest], { epoch: 1 })) + const getMessageContext = vi.fn(async () => contextResponse([latest], { + epoch: 1, + hasMore: false, + nextBeforeAt: 1_000, + nextBeforeSeq: 1, + snapshotHeadAt: 1_000, + snapshotHeadSeq: 1, + targetMessageId: 'missing' + })) + const api = createApi(getMessages) + api.getMessageContext = getMessageContext + + await syncTailMessages(api, id) + await expect(loadMessageContext(api, id, 'missing')).resolves.toBe(false) + + expect(getMessageWindowState(id)).toMatchObject({ + messages: [latest], + isLoadingMore: false, + viewMode: 'tail' + }) + }) +}) + describe('optimistic and queued-message operations', () => { it('replaces an optimistic row by localId and updates status in the canonical collection', () => { const id = sessionId('optimistic-replace') diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index dde0b823..a9c216ca 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -25,6 +25,16 @@ export type OlderLoadOutcome = error: Error } +/** Result of walking a context window forward toward the session head. */ +export type NewerLoadOutcome = + | { kind: 'loaded'; added: number } + /** The window reached the head; tail mode is now a valid transition. */ + | { kind: 'caught-up'; added: number } + | { + kind: 'stopped' + reason: 'unavailable' | 'busy' | 'invalidated' | 'epoch-reset' | 'exhausted' | 'failed' + } + export type MessageWindowState = { readThroughRevision?: number sessionId: string @@ -37,6 +47,16 @@ export type MessageWindowState = { isLoadingMore: boolean warning: string | null viewMode: MessageViewMode + // A history window may be intentionally retained while its latest-tail + // cursor is invalid. Consumers must treat that window as provisional + // until the next tail reconciliation completes. + requiresLatestReset: boolean + /** + * A search/context window stops short of the session head. True while + * newer messages exist between the loaded window and that head, so the + * reader can keep going forward instead of only paging backward. + */ + hasMoreNewer: boolean messagesVersion: number historyVersion: number tailRevision: number @@ -46,7 +66,7 @@ export const VISIBLE_WINDOW_SIZE = 400 export const HISTORY_WINDOW_SIZE = 600 export const INITIAL_PAGE_SIZE = 20 const AGENT_RUN_WINDOW_SIZE = 800 -const OLDER_LOAD_WINDOW_SIZE = 800 +export const OLDER_LOAD_WINDOW_SIZE = 800 const PAGE_SIZE = 200 const CACHED_REENTRY_PAGE_SIZE = 20 // ponytail: fixed budget suggested in #2045, not field-measured; tune from load timings. @@ -62,6 +82,11 @@ type InternalState = MessageWindowState & { oldestPositionSeq: number | null newestPositionAt: number | null newestPositionSeq: number | null + // Newest row actually loaded by a context jump. newestPosition* holds the + // session head so tail sync stays correct; this is the forward-paging + // cursor for the gap between the two. + contextNewestAt: number | null + contextNewestSeq: number | null requiresLatestReset: boolean preferLatestOnActivation: boolean syncGeneration: number @@ -75,7 +100,10 @@ type PersistedMessageWindowState = { oldestPositionSeq: number | null newestPositionAt: number | null newestPositionSeq: number | null + contextNewestAt: number | null + contextNewestSeq: number | null epoch: number | null + requiresLatestReset: boolean } type TailSyncController = { @@ -88,6 +116,7 @@ type TailSyncController = { const states = new Map() const listeners = new Map void>>() const tailSyncControllers = new Map() +const messageWindowTargetLocks = new Set() const appliedRewindLocalIds = new Map>() const NOTIFY_THROTTLE_MS = 150 @@ -189,7 +218,10 @@ function persistState(sessionId: string, state: InternalState): void { oldestPositionSeq: state.oldestPositionSeq, newestPositionAt: state.newestPositionAt, newestPositionSeq: state.newestPositionSeq, - epoch: state.epoch + contextNewestAt: state.contextNewestAt, + contextNewestSeq: state.contextNewestSeq, + epoch: state.epoch, + requiresLatestReset: state.requiresLatestReset } sessionStorage.setItem(getStorageKey(sessionId), JSON.stringify(persisted)) } catch { @@ -250,6 +282,9 @@ function createState(sessionId: string): InternalState { oldestPositionSeq: null, newestPositionAt: null, newestPositionSeq: null, + contextNewestAt: null, + contextNewestSeq: null, + hasMoreNewer: false, requiresLatestReset: false, preferLatestOnActivation: false, syncGeneration: 0, @@ -282,9 +317,16 @@ function hydrateState(sessionId: string): InternalState | null { } const oldest = readPosition(parsed.oldestPositionAt, parsed.oldestPositionSeq) const newest = readPosition(parsed.newestPositionAt, parsed.newestPositionSeq) + const contextNewest = readPosition(parsed.contextNewestAt, parsed.contextNewestSeq) const epoch = typeof parsed.epoch === 'number' && Number.isInteger(parsed.epoch) && parsed.epoch >= 0 ? parsed.epoch : null + // Older v2 entries did not persist this bit. Treat non-empty legacy + // windows as provisional once so a historical context cannot be + // mistaken for the current tail after a reload. + const requiresLatestReset = typeof parsed.requiresLatestReset === 'boolean' + ? parsed.requiresLatestReset + : parsed.messages.length > 0 && (newest === null || epoch === null) return buildState(createState(sessionId), { messages: mergeMessages([], parsed.messages.map(restoreMessage)), hasMore: parsed.hasMore === true, @@ -292,8 +334,15 @@ function hydrateState(sessionId: string): InternalState | null { oldestPositionSeq: oldest?.seq ?? null, newestPositionAt: newest?.at ?? null, newestPositionSeq: newest?.seq ?? null, + contextNewestAt: contextNewest?.at ?? null, + contextNewestSeq: contextNewest?.seq ?? null, + // Reading forward resumes after a reload only while the restored + // window still stops short of the head. + hasMoreNewer: contextNewest !== null + && newest !== null + && comparePosition(contextNewest, newest) < 0, epoch, - requiresLatestReset: parsed.messages.length > 0 && (newest === null || epoch === null) + requiresLatestReset }) } catch { clearPersistedState(sessionId) @@ -408,6 +457,9 @@ function buildState( | 'oldestPositionSeq' | 'newestPositionAt' | 'newestPositionSeq' + | 'contextNewestAt' + | 'contextNewestSeq' + | 'hasMoreNewer' | 'requiresLatestReset' | 'preferLatestOnActivation' | 'syncGeneration' @@ -612,6 +664,40 @@ function mergeIntoWindow( oldestPositionSeq: oldestKept?.seq ?? next.oldestPositionSeq }) } + const forwardCursor = readPosition(previous.contextNewestAt, previous.contextNewestSeq) + // A hit already inside the rendered window skips loadMessageContext, so it + // has no cursor yet. Once older paging trims newer rows off such a + // search-locked window those messages become unreachable, so seed the + // cursor here from what is retained. + const searchLocked = messageWindowTargetLocks.has(previous.sessionId) + if (forwardCursor || searchLocked) { + // A context window deliberately keeps the authoritative session head + // in newestPosition*. Replacing it with the retained boundary (as the + // ordinary tail case does below) would make forward reading believe it + // had caught up with itself. Clamp the forward cursor instead, so any + // caller that trims from the newest end — older pages and live SSE + // alike — stays consistent. + const contiguousNewest = derivePosition( + kept.filter((message) => ( + !isQueuedForInvocation(message) && !optimisticMessage(message) + )), + 'newest' + ) + const clamped = !forwardCursor + ? contiguousNewest + : contiguousNewest && comparePosition(contiguousNewest, forwardCursor) < 0 + ? contiguousNewest + : forwardCursor + const head = readPosition(previous.newestPositionAt, previous.newestPositionSeq) + return buildState(next, { + requiresLatestReset: true, + contextNewestAt: clamped?.at ?? previous.contextNewestAt, + contextNewestSeq: clamped?.seq ?? previous.contextNewestSeq, + hasMoreNewer: clamped !== null + && head !== null + && comparePosition(clamped, head) < 0 + }) + } const newest = derivePosition(kept, 'newest') next = buildState(next, { requiresLatestReset: true, @@ -685,6 +771,10 @@ function applyLatestResponse( newestPositionSeq: newest?.seq ?? null, tailRevision: previous.tailRevision + 1, requiresLatestReset: false, + // An authoritative latest window replaces the context entirely. + ...(options.replaceServerRows + ? { contextNewestAt: null, contextNewestSeq: null, hasMoreNewer: false } + : {}), isLoadingMore: options.replaceServerRows ? false : previous.isLoadingMore, olderGeneration: options.replaceServerRows ? previous.olderGeneration + 1 @@ -956,11 +1046,21 @@ function enterTailMode(previous: InternalState): InternalState { oldestPositionAt: oldest?.at ?? null, oldestPositionSeq: oldest?.seq ?? null, newestPositionAt: forceLatest ? null : previous.newestPositionAt, - newestPositionSeq: forceLatest ? null : previous.newestPositionSeq + newestPositionSeq: forceLatest ? null : previous.newestPositionSeq, + // The context window is being left behind; its forward cursor must not + // outlive it, or a later hit that is already inside the latest window + // would page from the old historical position and splice disconnected + // messages in ahead of the tail. + contextNewestAt: null, + contextNewestSeq: null, + hasMoreNewer: false }) } export function activateMessageWindow(sessionId: string): void { + if (messageWindowTargetLocks.has(sessionId)) { + return + } let requestedLatest = false updateState(sessionId, (previous) => { const { kept } = trimPreservingQueued(previous.messages, VISIBLE_WINDOW_SIZE, 'append') @@ -1013,6 +1113,9 @@ export function syncTailMessages( sessionId: string, options: { ensureAfterCurrent?: boolean } = {} ): Promise { + if (messageWindowTargetLocks.has(sessionId)) { + return Promise.resolve() + } let controller = tailSyncControllers.get(sessionId) if (!controller) { controller = { @@ -1042,6 +1145,40 @@ export function syncTailMessages( return waitForTailSyncDrain(sessionId, controller, observed) } +/** + * Mark a session as being opened for a specific historical search target. + * Background reconnect/SSE refreshes must not reclaim that window before the + * targeted context has rendered. + */ +export function setMessageWindowTargetLock(sessionId: string, locked: boolean): void { + if (locked) { + messageWindowTargetLocks.add(sessionId) + cancelTailSync(sessionId) + return + } + messageWindowTargetLocks.delete(sessionId) +} + +/** + * Drop an in-flight latest-page reconciliation when a targeted history load + * takes ownership of the window. The request itself cannot always be aborted + * after fetch has started, but invalidating its generation prevents its large + * response from replacing the historical context when it eventually arrives. + */ +export function cancelTailSync(sessionId: string): void { + const controller = tailSyncControllers.get(sessionId) + const state = getState(sessionId) + if (!controller?.running && !state.isSyncingTail) { + return + } + tailSyncControllers.delete(sessionId) + updateState(sessionId, (previous) => buildState(previous, { + syncGeneration: previous.syncGeneration + 1, + isSyncingTail: false, + warning: null + }), true) +} + export async function fetchOlderMessages( api: ApiClient, sessionId: string, @@ -1078,16 +1215,11 @@ export async function fetchOlderMessages( } if (initial.epoch !== null && response.page.epoch !== initial.epoch) { - updateState(sessionId, (previous) => { - if (previous.olderGeneration !== generation) return previous - return buildState(previous, { - isLoadingMore: false, - epoch: null, - newestPositionAt: null, - newestPositionSeq: null, - requiresLatestReset: true - }) - }) + const current = getState(sessionId) + if (current.olderGeneration !== generation) { + return { kind: 'stopped', reason: 'invalidated' } + } + markMessageWindowForLatestReset(sessionId, current.messages) await syncTailMessages(api, sessionId, { ensureAfterCurrent: true }) return { kind: 'stopped', reason: 'epoch-reset' } } @@ -1115,6 +1247,9 @@ export async function fetchOlderMessages( regularLimit: OLDER_LOAD_WINDOW_SIZE }) historyVersion = nextHistoryVersion + // Cursor clamping and head preservation now happen inside + // mergeIntoWindow, so every path that trims from the newest end + // behaves the same. return buildState(merged, { hasMore: response.page.hasMore, epoch: response.page.epoch, @@ -1152,6 +1287,284 @@ export async function fetchOlderMessages( } } +/** + * Replace the server portion of the window with a bounded context around a + * search hit. This avoids walking hundreds of older pages just to reveal one + * message, while keeping queued/optimistic local rows visible. + */ +export async function loadMessageContext( + api: ApiClient, + sessionId: string, + messageId: string +): Promise { + // HappyThread may call this from a child layout effect. Yield to the next + // task so the parent useMessages layout effect can register the initial + // tail sync before we inspect the window and start a competing context + // request. + await new Promise((resolve) => setTimeout(resolve, 0)) + let initial = getState(sessionId) + const activeTailSync = tailSyncControllers.get(sessionId) + if (initial.isSyncingTail || activeTailSync?.running) { + if (activeTailSync?.running) { + await waitForTailSyncDrain(sessionId, activeTailSync, activeTailSync.running) + } + initial = getState(sessionId) + } + if (initial.isSyncingTail || initial.isLoadingMore) { + return false + } + + const requestBaseline = new Map(initial.messages.map((message) => [message.id, message])) + const generation = initial.olderGeneration + 1 + updateState(sessionId, (previous) => buildState(previous, { + olderGeneration: generation, + isLoadingMore: true, + warning: null + }), true) + + try { + const response = await api.getMessageContext(sessionId, messageId) + if (getState(sessionId).olderGeneration !== generation) { + return false + } + + if (!response) { + updateState(sessionId, (previous) => { + if (previous.olderGeneration !== generation) return previous + return buildState(previous, { + // The cached window may predate appends or a rewind. Force + // the next tail sync to replace it instead of leaving a + // stale suffix rendered behind the dismissed jump. + requiresLatestReset: previous.messages.length > 0 || previous.requiresLatestReset, + isLoadingMore: false, + warning: null + }) + }, true) + return false + } + + const retained = response.messages.filter(shouldRetainWindowMessage) + if (!retained.some((message) => message.id === messageId)) { + updateState(sessionId, (previous) => { + if (previous.olderGeneration !== generation) return previous + return buildState(previous, { + requiresLatestReset: previous.messages.length > 0 || previous.requiresLatestReset, + isLoadingMore: false, + warning: 'The searched message is no longer available' + }) + }, true) + return false + } + + let applied = false + updateState(sessionId, (previous) => { + if (previous.olderGeneration !== generation) return previous + + const preservedLocalMessages = previous.messages.filter((message) => ( + optimisticMessage(message) || isQueuedForInvocation(message) + )) + const concurrentMessages = previous.messages.filter((message) => ( + requestBaseline.get(message.id) !== message + )) + const merged = mergeMessages( + mergeMessages(preservedLocalMessages, retained), + concurrentMessages + ) + const { kept } = trimPreservingQueued(merged, OLDER_LOAD_WINDOW_SIZE, 'prepend') + applied = true + // The window stops well short of the head. Keep newestPosition* on + // the head for tail sync, and track the window's own newest row + // separately so the reader can page forward across the gap. + // Derive from the contiguous context rows only. `kept` also holds + // rows that arrived over SSE while the request was in flight, and + // the hub deliberately appends pending prompts to the context + // response out-of-band ("pending rows must not change the bounded + // context's pagination cursor", messageService.ts). Either would + // push the cursor past the window and strand the history between. + const contiguous = retained.filter((message) => ( + !isQueuedForInvocation(message) && !optimisticMessage(message) + )) + const contextNewest = derivePosition(contiguous, 'newest') + const snapshotHead = pagePosition( + response.page.snapshotHeadAt, + response.page.snapshotHeadSeq + ) + return buildState(previous, { + messages: kept, + hasMore: response.page.hasMore, + epoch: response.page.epoch, + viewMode: 'history', + oldestPositionAt: response.page.nextBeforeAt, + oldestPositionSeq: response.page.nextBeforeSeq, + newestPositionAt: response.page.snapshotHeadAt, + newestPositionSeq: response.page.snapshotHeadSeq, + contextNewestAt: contextNewest?.at ?? null, + contextNewestSeq: contextNewest?.seq ?? null, + hasMoreNewer: contextNewest !== null + && snapshotHead !== null + && comparePosition(contextNewest, snapshotHead) < 0, + requiresLatestReset: true, + isLoadingMore: false, + historyVersion: previous.historyVersion + 1, + warning: null + }) + }, true) + return applied + } catch (error) { + if (getState(sessionId).olderGeneration !== generation) { + return false + } + updateState(sessionId, (previous) => { + if (previous.olderGeneration !== generation) return previous + return buildState(previous, { + isLoadingMore: false, + warning: error instanceof Error ? error.message : 'Failed to load the searched message' + }) + }, true) + return false + } +} + +/** + * Load one bounded page of messages after a context window's newest row. + * + * A search jump leaves the window stranded in the middle of the session: the + * head is recorded for tail sync, but nothing fills the gap between the two, + * so scrolling down used to dead-end. This walks that gap forward a page at a + * time, and reports when the window has finally caught up with the head. + */ +export async function fetchNewerMessages( + api: ApiClient, + sessionId: string, + options: { + /** + * Resolves the row the reader is anchored to, read at apply time + * rather than request time. Forward paging trims from the old end, and + * the reader can scroll during a slow page — a value captured when the + * request started would protect the row they have already left. + */ + resolvePreserveMessageId?: () => string | null + } = {} +): Promise { + const initial = getState(sessionId) + if (initial.isSyncingTail || initial.isLoadingMore) { + return { kind: 'stopped', reason: 'busy' } + } + if (!initial.hasMoreNewer) { + return { kind: 'stopped', reason: 'exhausted' } + } + const after = readPosition(initial.contextNewestAt, initial.contextNewestSeq) + if (!after) { + return { kind: 'stopped', reason: 'unavailable' } + } + + const generation = initial.olderGeneration + 1 + updateState(sessionId, (previous) => buildState(previous, { + olderGeneration: generation, + isLoadingMore: true, + warning: null + })) + + try { + const response = await api.getMessages(sessionId, { + afterAt: after.at, + afterSeq: after.seq, + limit: PAGE_SIZE + }) + if (getState(sessionId).olderGeneration !== generation) { + return { kind: 'stopped', reason: 'invalidated' } + } + if (initial.epoch !== null && response.page.epoch !== initial.epoch) { + // The history changed under us; the same authoritative refresh the + // older path uses also releases the search lock. + const current = getState(sessionId) + markMessageWindowForLatestReset(sessionId, current.messages) + await syncTailMessages(api, sessionId, { ensureAfterCurrent: true }) + return { kind: 'stopped', reason: 'epoch-reset' } + } + + const retained = response.messages.filter(shouldRetainWindowMessage) + let added = 0 + updateState(sessionId, (previous) => { + if (previous.olderGeneration !== generation) return previous + const merged = mergeMessages(previous.messages, retained) + // Trim from the old end: the reader is moving forward, so the + // newly appended rows are the ones that must survive — unless the + // reader is anchored in the rows that would go, in which case keep + // the window longer rather than moving them. + const preserveMessageId = options.resolvePreserveMessageId?.() ?? null + const anchorIndex = preserveMessageId + ? merged.findIndex((message) => ( + message.id === preserveMessageId + || message.localId === preserveMessageId + )) + : -1 + const trimLimit = anchorIndex >= 0 + ? Math.max(HISTORY_WINDOW_SIZE, merged.length - anchorIndex) + : HISTORY_WINDOW_SIZE + const { kept } = trimPreservingQueued(merged, trimLimit, 'append') + added = kept.length - previous.messages.length + // Advance from the page's own boundary rather than the merged + // window: an SSE row appended at the live head would otherwise + // look like we had caught up, skipping everything in between. + const contextNewest = pagePosition( + response.page.nextAfterAt, + response.page.nextAfterSeq + ) ?? derivePosition(retained, 'newest') ?? after + const oldest = derivePosition(kept, 'oldest') + // The session can grow after the context was loaded, so trust the + // head this response reports over the one recorded back then. A + // page that says there is more is never "caught up", whatever an + // older head would imply. + const responseHead = pagePosition( + response.page.snapshotHeadAt, + response.page.snapshotHeadSeq + ) + const recordedHead = readPosition( + previous.newestPositionAt, + previous.newestPositionSeq + ) + const head = responseHead && recordedHead + ? (comparePosition(responseHead, recordedHead) > 0 ? responseHead : recordedHead) + : responseHead ?? recordedHead + const caughtUp = !response.page.hasMore + return buildState(previous, { + messages: kept, + isLoadingMore: false, + // Trimming the old end means older paging is live again even if + // the context page had already reached the start. + hasMore: previous.hasMore || kept.length < merged.length, + oldestPositionAt: oldest?.at ?? previous.oldestPositionAt, + oldestPositionSeq: oldest?.seq ?? previous.oldestPositionSeq, + contextNewestAt: contextNewest?.at ?? previous.contextNewestAt, + contextNewestSeq: contextNewest?.seq ?? previous.contextNewestSeq, + // Keep the freshest head so later comparisons do not regress. + ...(head ? { newestPositionAt: head.at, newestPositionSeq: head.seq } : {}), + hasMoreNewer: !caughtUp, + historyVersion: previous.historyVersion + 1, + warning: null + }) + }, true) + + const settled = getState(sessionId) + return settled.hasMoreNewer + ? { kind: 'loaded', added } + : { kind: 'caught-up', added } + } catch (error) { + if (getState(sessionId).olderGeneration !== generation) { + return { kind: 'stopped', reason: 'invalidated' } + } + updateState(sessionId, (previous) => { + if (previous.olderGeneration !== generation) return previous + return buildState(previous, { + isLoadingMore: false, + warning: error instanceof Error ? error.message : 'Failed to load newer messages' + }) + }, true) + return { kind: 'stopped', reason: 'failed' } + } +} + export function cancelOlderMessageLoad(sessionId: string): void { updateState(sessionId, (previous) => { if (!previous.isLoadingMore) { @@ -1166,6 +1579,9 @@ export function cancelOlderMessageLoad(sessionId: string): void { } export function setMessageViewMode(sessionId: string, mode: MessageViewMode): void { + if (mode === 'tail') { + messageWindowTargetLocks.delete(sessionId) + } updateState(sessionId, (previous) => { if (previous.viewMode === mode) { return previous @@ -1220,6 +1636,7 @@ export function subscribeMessageWindow(sessionId: string, listener: () => void): } export function clearMessageWindow(sessionId: string): void { + messageWindowTargetLocks.delete(sessionId) tailSyncControllers.delete(sessionId) clearPersistedState(sessionId) const previous = states.get(sessionId) @@ -1235,11 +1652,17 @@ function markMessageWindowForLatestReset(sessionId: string, messages: DecryptedM const previous = states.get(sessionId) if (!previous) return + // A rewind/invalidation is authoritative and must refresh even when a + // historical search target previously paused background tail sync. + messageWindowTargetLocks.delete(sessionId) tailSyncControllers.delete(sessionId) clearPersistedState(sessionId) setState(sessionId, buildState(previous, { messages, epoch: null, + contextNewestAt: null, + contextNewestSeq: null, + hasMoreNewer: false, oldestPositionAt: null, oldestPositionSeq: null, newestPositionAt: null, diff --git a/web/src/lib/messageSearchTarget.test.ts b/web/src/lib/messageSearchTarget.test.ts new file mode 100644 index 00000000..2fed23b5 --- /dev/null +++ b/web/src/lib/messageSearchTarget.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { normalizeMessageSearchTarget } from './messageSearchTarget' + +describe('normalizeMessageSearchTarget', () => { + it('keeps a complete content-search deep link', () => { + expect(normalizeMessageSearchTarget(' message-1 ', ' hello ')).toEqual({ + messageId: 'message-1', + messageQuery: 'hello', + }) + }) + + it('drops incomplete deep links instead of locking the message window', () => { + expect(normalizeMessageSearchTarget('message-1', undefined)).toEqual({}) + expect(normalizeMessageSearchTarget(undefined, 'hello')).toEqual({}) + expect(normalizeMessageSearchTarget(' ', 'hello')).toEqual({}) + }) +}) diff --git a/web/src/lib/messageSearchTarget.ts b/web/src/lib/messageSearchTarget.ts new file mode 100644 index 00000000..c1a8f0be --- /dev/null +++ b/web/src/lib/messageSearchTarget.ts @@ -0,0 +1,19 @@ +export type MessageSearchTarget = { + messageId?: string + messageQuery?: string +} + +/** Search-result deep links are valid only when both target fields are present. */ +export function normalizeMessageSearchTarget( + messageId: unknown, + messageQuery: unknown +): MessageSearchTarget { + const normalizedMessageId = typeof messageId === 'string' ? messageId.trim() : '' + const normalizedMessageQuery = typeof messageQuery === 'string' ? messageQuery.trim() : '' + if (!normalizedMessageId || !normalizedMessageQuery) return {} + + return { + messageId: normalizedMessageId, + messageQuery: normalizedMessageQuery, + } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 1624ba81..90cac448 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -2,6 +2,7 @@ export const queryKeys = { sessions: ['sessions'] as const, session: (sessionId: string) => ['session', sessionId] as const, messages: (sessionId: string) => ['messages', sessionId] as const, + sessionContentSearch: (sessionId: string, query: string) => ['session-content-search', sessionId, query] as const, machines: ['machines'] as const, sqliteStorage: ['sqlite-storage'] as const, hubSettings: ['hub-settings'] as const, diff --git a/web/src/lib/useConsumedMessageTarget.test.tsx b/web/src/lib/useConsumedMessageTarget.test.tsx new file mode 100644 index 00000000..c451ed0c --- /dev/null +++ b/web/src/lib/useConsumedMessageTarget.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useConsumedMessageTarget } from './useConsumedMessageTarget' + +function Harness(props: { + sessionId: string + messageId?: string +}) { + const target = useConsumedMessageTarget(props.sessionId, props.messageId, 'search text') + return ( + <> + {target.effectiveMessageId ?? ''} + {target.searchRequestId} + + + ) +} + +describe('useConsumedMessageTarget', () => { + it('clears a consumed target when navigating away before returning', () => { + const view = render() + + fireEvent.click(screen.getByRole('button', { name: 'consume' })) + view.rerender() + expect(screen.getByTestId('target')).toHaveTextContent('message-a') + + view.rerender() + view.rerender() + expect(screen.getByTestId('target')).toHaveTextContent('') + }) + + it('increments the request id when the same result is selected again', () => { + const view = render() + const firstRequestId = Number(screen.getByTestId('request-id').textContent) + + fireEvent.click(screen.getByRole('button', { name: 'consume' })) + view.rerender() + view.rerender() + + expect(Number(screen.getByTestId('request-id').textContent)).toBeGreaterThan(firstRequestId) + }) +}) diff --git a/web/src/lib/useConsumedMessageTarget.ts b/web/src/lib/useConsumedMessageTarget.ts new file mode 100644 index 00000000..57c544be --- /dev/null +++ b/web/src/lib/useConsumedMessageTarget.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export type ConsumedMessageTarget = { + sessionId: string + messageId: string + messageQuery?: string +} + +export function useConsumedMessageTarget( + sessionId: string, + messageId?: string, + messageQuery?: string +): { + effectiveMessageId?: string + effectiveMessageQuery?: string + searchRequestId: number + consume: () => void + clear: () => void +} { + const [consumedTarget, setConsumedTarget] = useState(null) + const previousInputKeyRef = useRef(null) + const searchRequestIdRef = useRef(0) + + const inputKey = messageId + ? `${sessionId}\u0000${messageId}\u0000${messageQuery ?? ''}` + : null + if (inputKey !== previousInputKeyRef.current) { + previousInputKeyRef.current = inputKey + if (inputKey !== null) searchRequestIdRef.current += 1 + } + + useEffect(() => { + setConsumedTarget(null) + }, [sessionId]) + + const retainedTarget = consumedTarget?.sessionId === sessionId + ? consumedTarget + : null + const effectiveMessageId = messageId ?? retainedTarget?.messageId + const effectiveMessageQuery = messageId + ? messageQuery + : retainedTarget?.messageQuery + const consume = useCallback(() => { + if (messageId) { + setConsumedTarget({ sessionId, messageId, messageQuery }) + } + }, [messageId, messageQuery, sessionId]) + const clear = useCallback(() => { + setConsumedTarget(null) + }, []) + + return { + effectiveMessageId, + effectiveMessageQuery, + searchRequestId: searchRequestIdRef.current, + consume, + clear + } +} diff --git a/web/src/router.hooks.test.ts b/web/src/router.hooks.test.ts new file mode 100644 index 00000000..e8ce1fee --- /dev/null +++ b/web/src/router.hooks.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * SessionPage returns early while session data is still loading. A hook + * declared after that branch only runs once the data resolves, which changes + * the hook count between renders and makes React throw "Rendered more hooks + * than during the previous render" — breaking ordinary session opening, not + * just search deep links (tiann/hapi#1598 review). + */ +describe('SessionPage hook order', () => { + const source = readFileSync(resolve(__dirname, 'router.tsx'), 'utf8') + const lines = source.split('\n') + + it('declares every hook before the session loading early return', () => { + const guardIndex = lines.findIndex((line) => line.trim() === 'if (!session) {') + expect(guardIndex).toBeGreaterThan(-1) + + // Where SessionPage's body ends: the next top-level function. + const endIndex = lines.findIndex( + (line, index) => index > guardIndex && /^(function|const) \w+/.test(line) + ) + const tail = lines.slice(guardIndex, endIndex === -1 ? undefined : endIndex) + + const offenders = tail + .map((line, index) => ({ line: line.trim(), at: guardIndex + index + 1 })) + .filter(({ line }) => /^\s*const .*=\s*use(Callback|Memo|Effect|State|Ref|LayoutEffect)\(/.test(line) + || /^\s*use(Effect|LayoutEffect)\(/.test(line)) + + expect(offenders).toEqual([]) + }) +}) diff --git a/web/src/router.tsx b/web/src/router.tsx index f7d89d01..3f229007 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -62,6 +62,8 @@ import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession } from '@/lib/codexImportedSessions' import { getSupersedingSessionId, prepareFollowSupersedingSession, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' import { migrateSuppressedSendError } from '@/lib/suppressed-send-error' +import { useConsumedMessageTarget } from '@/lib/useConsumedMessageTarget' +import { normalizeMessageSearchTarget } from '@/lib/messageSearchTarget' import { buildFileMentionSearchQuery } from '@/lib/file-search-query' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' @@ -232,7 +234,26 @@ function SessionsPage() { ) }, [isSessionsIndex, pathname, router, sidebarHidden, sidebarSearch]) const sidebar = useSidebarResize() - const handleSelectSession = useCallback((sessionId: string) => { + const handleSelectSession = useCallback(( + sessionId: string, + targetMessageId?: string, + targetMessageQuery?: string + ) => { + // A content-search hit carries the message it matched, so the session + // opens on that message rather than the tail. + if (targetMessageId) { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + search: { + ...(isSessionSidebarHidden(sidebarSearch) ? { sidebar: 'hidden' as const } : {}), + messageId: targetMessageId, + ...(targetMessageQuery ? { messageQuery: targetMessageQuery } : {}) + }, + ...PRESERVE_SESSION_SIDEBAR_SCROLL, + }) + return + } navigate(getSessionListSelectionNavigation( sessionId, isSessionSidebarHidden(sidebarSearch) ? { sidebar: 'hidden' } : undefined, @@ -388,7 +409,17 @@ function SessionPage() { const queryClient = useQueryClient() const { addToast } = useToast() const { sessionId } = useParams({ from: '/sessions/$sessionId' }) - const { outline } = useSearch({ from: '/sessions/$sessionId' }) + const { outline, messageId, messageQuery } = useSearch({ from: '/sessions/$sessionId' }) + const { + effectiveMessageId: effectiveInitialMessageId, + effectiveMessageQuery: effectiveInitialMessageQuery, + searchRequestId, + consume: consumeMessageTarget, + clear: clearConsumedMessageTarget, + } = useConsumedMessageTarget(sessionId, messageId, messageQuery) + const hasCompleteInitialMessageTarget = Boolean( + effectiveInitialMessageId?.trim() && effectiveInitialMessageQuery?.trim() + ) const { session, error: sessionError, @@ -406,16 +437,31 @@ function SessionPage() { isSyncingTail: messagesSyncingTail, isLoadingMore: messagesLoadingMore, hasMore: messagesHasMore, + hasMoreNewer: messagesHasMoreNewer, loadMore: loadMoreMessages, + loadNewer: loadNewerMessages, cancelLoadMore: cancelLoadMoreMessages, refetch: refetchMessages, + loadMessageContext: loadMessageContextForSession, viewMode: messagesViewMode, messagesVersion, historyVersion, tailRevision, readThroughRevision, setViewMode, - } = useMessages(api, sessionId) + jumpToTail, + } = useMessages(api, sessionId, { + skipInitialTailSync: hasCompleteInitialMessageTarget + }) + + // Must stay above the `if (!session)` early return below: declaring it + // after that branch changes the hook count once session data resolves. + const handleLoadNewerMessages = useCallback(async ( + resolvePreserveMessageId?: () => string | null + ): Promise => { + const outcome = await loadNewerMessages(resolvePreserveMessageId) + return outcome.kind === 'caught-up' + }, [loadNewerMessages]) // Tracks the most recent send the hub rejected (4xx/5xx/network), keyed // by the session the failed POST actually targeted (post-resolveSessionId). @@ -828,6 +874,28 @@ function SessionPage() { }) }, [navigate, sessionId]) + const handleInitialMessageConsumed = useCallback(() => { + if (messageId) { + consumeMessageTarget() + } + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + replace: true, + ...PRESERVE_SESSION_SIDEBAR_SCROLL, + }) + }, [consumeMessageTarget, messageId, navigate, sessionId]) + + const handleSearchTargetDismissed = useCallback(() => { + clearConsumedMessageTarget() + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + replace: true, + ...PRESERVE_SESSION_SIDEBAR_SCROLL, + }) + }, [clearConsumedMessageTarget, navigate, sessionId]) + if (!session) { if (sessionError) { return ( @@ -872,6 +940,7 @@ function SessionPage() { messages={messages} messagesWarning={messagesWarning} hasMoreMessages={messagesHasMore} + hasMoreNewerMessages={messagesHasMoreNewer} isSyncingTail={messagesSyncingTail} isLoadingMoreMessages={messagesLoadingMore} isSending={isSending} @@ -882,6 +951,7 @@ function SessionPage() { onBack={goBack} onRefresh={refreshSelectedSession} onLoadMore={loadMoreMessages} + onLoadNewer={handleLoadNewerMessages} onCancelLoadMore={cancelLoadMoreMessages} onSend={sendMessage} resolveSessionIdForUpload={async (id) => (await resolveSessionId(id)).sessionId} @@ -889,6 +959,7 @@ function SessionPage() { resolveSessionIdForVoice={resolveVoiceSessionId} onVoiceSessionResolved={handleVoiceSessionResolved} onViewModeChange={setViewMode} + onJumpToTail={jumpToTail} onRetryMessage={retryMessage} autocompleteSuggestions={getAutocompleteSuggestions} availableSlashCommands={slashCommands} @@ -897,6 +968,12 @@ function SessionPage() { onSuppressSendErrorRestore={suppressSendErrorRestore} initialOutlineOpen={outline} onInitialOutlineConsumed={handleInitialOutlineConsumed} + initialTargetMessageId={hasCompleteInitialMessageTarget ? effectiveInitialMessageId : undefined} + initialTargetMessageQuery={hasCompleteInitialMessageTarget ? effectiveInitialMessageQuery : undefined} + searchRequestId={searchRequestId} + onLoadMessageContext={loadMessageContextForSession} + onInitialTargetConsumed={handleInitialMessageConsumed} + onSearchTargetDismissed={handleSearchTargetDismissed} onAbortRestore={(text) => { sendErrorIdRef.current += 1 setSendErrors((prev) => ({ @@ -1136,12 +1213,14 @@ const sessionsIndexRoute = createRoute({ const sessionDetailRoute = createRoute({ getParentRoute: () => sessionsRoute, path: '$sessionId', - validateSearch: (search: Record): { outline?: boolean; sidebar?: 'hidden' } => { + validateSearch: (search: Record): { outline?: boolean; sidebar?: 'hidden'; messageId?: string; messageQuery?: string } => { const outline = search.outline === true || search.outline === 'true' const sidebar = search.sidebar === 'hidden' ? 'hidden' : undefined + const messageTarget = normalizeMessageSearchTarget(search.messageId, search.messageQuery) return { ...(outline ? { outline: true } : {}), ...(sidebar ? { sidebar } : {}), + ...messageTarget, } }, component: SessionDetailRoute, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index eb373e20..0fc5e130 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -43,6 +43,7 @@ export type { ClaudeModelSummary, ClaudeModelsResponse, MachinesResponse, + MessageContextResponse, MessagesResponse, OpencodeModelsResponse, OpencodeModelSummary, @@ -54,8 +55,11 @@ export type { SlashCommand, SlashCommandsResponse, SessionResponse, + SessionContentMatch, + SessionContentMatchesResponse, SessionTitleSuggestionResponse, SessionsResponse, + SessionContentSearchResponse, SpawnResponse, UploadFileResponse } from '@hapi/protocol/apiTypes'