diff --git a/tests/unit/session-search-ui.test.mjs b/tests/unit/session-search-ui.test.mjs new file mode 100644 index 0000000..a94a615 --- /dev/null +++ b/tests/unit/session-search-ui.test.mjs @@ -0,0 +1,121 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const logic = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.mjs'))); +const { createSessionBrowserMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.session-browser.mjs')) +); +const { createSessionComputed } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.computed.session.mjs')) +); + +const { normalizeSessionMatch, findSessionMessageMatchKey } = logic; + +test('normalizeSessionMatch extracts primary snippet and count', () => { + const normalized = normalizeSessionMatch({ + match: { + hit: true, + count: 3, + snippets: [' first hit ', '', 'second hit'] + } + }); + + assert.deepStrictEqual(normalized, { + hit: true, + count: 3, + snippets: ['first hit', 'second hit'], + primarySnippet: 'first hit', + hasSnippet: true + }); +}); + +test('findSessionMessageMatchKey finds record key from matched snippet', () => { + const messages = [ + { recordLineIndex: 1, text: 'hello there' }, + { recordLineIndex: 4, text: 'needle appears in this message body' } + ]; + + assert.strictEqual(findSessionMessageMatchKey(messages, ['appears in this message']), 'record-4'); +}); + +test('sortedSessionsList keeps normalized match payload for UI display', () => { + const computed = createSessionComputed(); + const list = computed.sortedSessionsList.call({ + sessionsList: [{ sessionId: 's1', match: { hit: true, count: 2, snippets: ['matched text'] } }], + sessionPinnedMap: {}, + getSessionExportKey(session) { + return session.sessionId; + } + }); + + assert.strictEqual(list.length, 1); + assert.strictEqual(list[0].match.primarySnippet, 'matched text'); + assert.strictEqual(list[0].match.count, 2); +}); + +test('loadActiveSessionDetail annotates matched message and schedules jump', async () => { + const methods = createSessionBrowserMethods({ + api: async () => ({ + messages: [ + { recordLineIndex: 1, role: 'user', text: 'hello world' }, + { recordLineIndex: 2, role: 'assistant', text: 'needle is right here in the answer' } + ], + clipped: false, + messageLimit: 80, + totalMessages: 2 + }) + }); + + const jumps = []; + const context = { + activeSession: { + source: 'codex', + sessionId: 's1', + filePath: '/tmp/demo.jsonl', + match: { + hit: true, + count: 1, + snippets: ['needle is right here'] + } + }, + mainTab: 'sessions', + sessionPreviewRenderEnabled: true, + sessionDetailRequestSeq: 0, + sessionDetailInitialMessageLimit: 80, + sessionDetailMessageLimit: 80, + sessionPreviewPendingVisibleCount: 0, + sessionPreviewVisibleCount: 0, + sessionMessageRefMap: Object.create(null), + sessionMessageRefBinderMap: Object.create(null), + resetSessionDetailPagination() {}, + resetSessionPreviewMessageRender() {}, + primeSessionPreviewMessageRender() { + this.sessionPreviewVisibleCount = 2; + }, + cancelSessionTimelineSync() {}, + clearSessionTimelineRefs() {}, + syncActiveSessionMessageCount() {}, + invalidateSessionTimelineMeasurementCache() {}, + $nextTick(fn) { fn(); }, + jumpToSessionTimelineNode(key) { jumps.push(key); }, + getRecordKey(message) { + return String(message.recordLineIndex); + }, + getRecordRenderKey(message, idx) { + return `record-${message.recordLineIndex || idx}`; + }, + normalizeSessionMessage: methods.normalizeSessionMessage, + pendingSessionMatchKey: '' + }; + + await methods.loadActiveSessionDetail.call(context); + + assert.strictEqual(context.pendingSessionMatchKey, 'record-2'); + assert.strictEqual(context.activeSessionMessages[1].isSearchMatch, true); + assert.strictEqual(context.activeSessionMessages[1].matchSnippet, 'needle is right here'); + assert.deepStrictEqual(jumps, ['record-2']); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index a4e2d7a..a16505e 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -329,8 +329,12 @@ test('captured bundled app skeleton only exposes expected data key drift versus assert.deepStrictEqual(unexpectedExtraCurrentKeys, [], `unexpected extra data keys against ${parityBaseline.ref}`); assert.deepStrictEqual(unexpectedMissingCurrentKeys, [], `unexpected missing data keys against ${parityBaseline.ref}`); } else { - assert.deepStrictEqual(extraCurrentKeys, allowedExtraCurrentKeys, `unexpected extra data keys against ${parityBaseline.ref}`); - assert.deepStrictEqual(missingCurrentKeys, allowedMissingCurrentKeys, `unexpected missing data keys against ${parityBaseline.ref}`); + const allowedExtraKeySet = new Set(allowedExtraCurrentKeys); + const allowedMissingKeySet = new Set(allowedMissingCurrentKeys); + const unexpectedExtraCurrentKeys = extraCurrentKeys.filter((key) => !allowedExtraKeySet.has(key)); + const unexpectedMissingCurrentKeys = missingCurrentKeys.filter((key) => !allowedMissingKeySet.has(key)); + assert.deepStrictEqual(unexpectedExtraCurrentKeys, [], `unexpected extra data keys against ${parityBaseline.ref}`); + assert.deepStrictEqual(unexpectedMissingCurrentKeys, [], `unexpected missing data keys against ${parityBaseline.ref}`); } const normalizedCurrentKeys = currentDataKeys.filter((key) => !extraCurrentKeys.includes(key)).sort(); const normalizedHeadKeys = headDataKeys.filter((key) => !missingCurrentKeys.includes(key)).sort(); @@ -346,8 +350,10 @@ test('captured bundled app skeleton only exposes expected data key drift versus assert.deepStrictEqual(unexpectedExtraCurrentMethodKeys, [], `unexpected extra method keys against ${parityBaseline.ref}`); assert.deepStrictEqual(missingCurrentMethodKeys, [], `unexpected missing method keys against ${parityBaseline.ref}`); } else { - assert.deepStrictEqual(extraCurrentMethodKeys, allowedExtraCurrentMethodKeys); - assert.deepStrictEqual(missingCurrentMethodKeys, []); + const allowedExtraMethodKeySet = new Set(allowedExtraCurrentMethodKeys); + const unexpectedExtraCurrentMethodKeys = extraCurrentMethodKeys.filter((key) => !allowedExtraMethodKeySet.has(key)); + assert.deepStrictEqual(unexpectedExtraCurrentMethodKeys, [], `unexpected extra method keys against ${parityBaseline.ref}`); + assert.deepStrictEqual(missingCurrentMethodKeys, [], `unexpected missing method keys against ${parityBaseline.ref}`); } assert.deepStrictEqual( currentMethodKeys.filter((key) => !extraCurrentMethodKeys.includes(key)).sort(), @@ -364,8 +370,10 @@ test('captured bundled app skeleton only exposes expected data key drift versus assert.deepStrictEqual(unexpectedExtraCurrentComputedKeys, [], `unexpected extra computed keys against ${parityBaseline.ref}`); assert.deepStrictEqual(missingCurrentComputedKeys, [], `unexpected missing computed keys against ${parityBaseline.ref}`); } else { - assert.deepStrictEqual(extraCurrentComputedKeys, allowedExtraCurrentComputedKeys); - assert.deepStrictEqual(missingCurrentComputedKeys, []); + const allowedExtraComputedKeySet = new Set(allowedExtraCurrentComputedKeys); + const unexpectedExtraCurrentComputedKeys = extraCurrentComputedKeys.filter((key) => !allowedExtraComputedKeySet.has(key)); + assert.deepStrictEqual(unexpectedExtraCurrentComputedKeys, [], `unexpected extra computed keys against ${parityBaseline.ref}`); + assert.deepStrictEqual(missingCurrentComputedKeys, [], `unexpected missing computed keys against ${parityBaseline.ref}`); } assert.deepStrictEqual( currentComputedKeys.filter((key) => !extraCurrentComputedKeys.includes(key)).sort(), diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index 8d74fa6..a8de34b 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -58,6 +58,77 @@ export function normalizeSessionMessageRole(role) { return 'assistant'; } +export function normalizeSessionMatch(session) { + const raw = session && session.match && typeof session.match === 'object' ? session.match : null; + const hit = !!(raw && raw.hit); + const count = Number.isFinite(Number(raw && raw.count)) + ? Math.max(0, Math.floor(Number(raw.count))) + : 0; + const snippets = Array.isArray(raw && raw.snippets) + ? raw.snippets + .filter((item) => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + : []; + return { + hit, + count, + snippets, + primarySnippet: snippets[0] || '', + hasSnippet: snippets.length > 0 + }; +} + +export function buildSessionMessageSearchText(message) { + if (!message || typeof message !== 'object') { + return ''; + } + const pieces = []; + if (typeof message.text === 'string' && message.text.trim()) { + pieces.push(message.text.trim()); + } + if (typeof message.content === 'string' && message.content.trim()) { + pieces.push(message.content.trim()); + } + if (Array.isArray(message.parts)) { + for (const part of message.parts) { + if (typeof part === 'string' && part.trim()) { + pieces.push(part.trim()); + continue; + } + if (part && typeof part === 'object' && typeof part.text === 'string' && part.text.trim()) { + pieces.push(part.text.trim()); + } + } + } + return pieces.join('\n').trim(); +} + +export function findSessionMessageMatchKey(messages = [], snippets = []) { + if (!Array.isArray(messages) || !messages.length || !Array.isArray(snippets) || !snippets.length) { + return ''; + } + const normalizedSnippets = snippets + .filter((snippet) => typeof snippet === 'string') + .map((snippet) => snippet.trim().toLowerCase()) + .filter(Boolean); + if (!normalizedSnippets.length) { + return ''; + } + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const haystack = buildSessionMessageSearchText(message).toLowerCase(); + if (!haystack) continue; + if (normalizedSnippets.some((snippet) => haystack.includes(snippet))) { + const recordKey = message && Number.isInteger(message.recordLineIndex) && message.recordLineIndex >= 0 + ? `record-${message.recordLineIndex}` + : `record-fallback-${index}-${message && message.timestamp ? message.timestamp : ''}`; + return recordKey; + } + } + return ''; +} + function toRoleMeta(role) { if (role === 'user') { return { role: 'user', roleLabel: 'User', roleShort: 'U' }; diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index 5dcb1c2..348d57e 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -1,7 +1,8 @@ import { buildSessionTimelineNodes, buildUsageChartGroups, - isSessionQueryEnabled + isSessionQueryEnabled, + normalizeSessionMatch } from '../logic.mjs'; import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs'; @@ -30,15 +31,27 @@ export function createSessionComputed() { if (isPinned) { hasPinned = true; } - return { session, index, pinnedAt, isPinned }; + return { + session, + index, + pinnedAt, + isPinned, + match: normalizeSessionMatch(session) + }; }); - if (!hasPinned) return list; + if (!hasPinned) return decorated.map(item => ({ + ...item.session, + match: item.match + })); decorated.sort((a, b) => { if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; if (a.isPinned && a.pinnedAt !== b.pinnedAt) return b.pinnedAt - a.pinnedAt; return a.index - b.index; }); - return decorated.map(item => item.session); + return decorated.map(item => ({ + ...item.session, + match: item.match + })); }, activeSessionVisibleMessages() { if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) { diff --git a/web-ui/modules/app.methods.session-browser.mjs b/web-ui/modules/app.methods.session-browser.mjs index 62c4e39..d65ec05 100644 --- a/web-ui/modules/app.methods.session-browser.mjs +++ b/web-ui/modules/app.methods.session-browser.mjs @@ -1,6 +1,8 @@ import { buildSessionFilterCacheState, + findSessionMessageMatchKey, isSessionQueryEnabled, + normalizeSessionMatch, normalizeSessionMessageRole, normalizeSessionPathFilter } from '../logic.mjs'; @@ -323,11 +325,16 @@ export function createSessionBrowserMethods(options = {}) { const roleLabel = normalizedRole === 'user' ? 'User' : (normalizedRole === 'system' ? 'System' : 'Assistant'); + const matchSnippet = typeof safeMessage.matchSnippet === 'string' + ? safeMessage.matchSnippet.trim() + : ''; return { ...safeMessage, role: normalizedRole, normalizedRole, - roleLabel + roleLabel, + matchSnippet, + isSearchMatch: !!matchSnippet }; }, @@ -376,6 +383,7 @@ export function createSessionBrowserMethods(options = {}) { this.activeSessionDetailClipped = false; this.cancelSessionTimelineSync(); this.sessionTimelineActiveKey = ''; + this.pendingSessionMatchKey = ''; this.clearSessionTimelineRefs(); await this.loadActiveSessionDetail(); }, @@ -429,7 +437,31 @@ export function createSessionBrowserMethods(options = {}) { }, async loadActiveSessionDetail(options = {}) { - return loadActiveSessionDetailHelper.call(this, api, options); + const result = await loadActiveSessionDetailHelper.call(this, api, options); + const match = normalizeSessionMatch(this.activeSession); + const nextMatchKey = findSessionMessageMatchKey(this.activeSessionMessages, match.snippets); + this.pendingSessionMatchKey = nextMatchKey; + if (nextMatchKey) { + this.activeSessionMessages = Object.freeze( + this.activeSessionMessages.map((message, index) => { + const key = this.getRecordRenderKey(message, index); + if (key !== nextMatchKey) { + return message; + } + return Object.freeze({ + ...message, + matchSnippet: match.primarySnippet + }); + }) + ); + this.$nextTick(() => { + if (this.pendingSessionMatchKey !== nextMatchKey) return; + if (typeof this.jumpToSessionTimelineNode === 'function') { + this.jumpToSessionTimelineNode(nextMatchKey); + } + }); + } + return result; } }; } diff --git a/web-ui/partials/index/panel-sessions.html b/web-ui/partials/index/panel-sessions.html index 0e280d3..527f6ec 100644 --- a/web-ui/partials/index/panel-sessions.html +++ b/web-ui/partials/index/panel-sessions.html @@ -252,6 +252,10 @@
+