Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions tests/unit/session-search-ui.test.mjs
Original file line number Diff line number Diff line change
@@ -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']);
});
Comment on lines +60 to +121
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify if the test actually runs successfully
cd tests/unit
node --test session-search-ui.test.mjs 2>&1 || echo "Test execution failed"

Repository: SakuraByteCore/codexmate

Length of output: 620


🏁 Script executed:

cat -n tests/unit/session-search-ui.test.mjs | head -70

Repository: SakuraByteCore/codexmate

Length of output: 3112


🏁 Script executed:

# Find where createSessionBrowserMethods is defined
rg -l "createSessionBrowserMethods" --type js --type mjs

Repository: SakuraByteCore/codexmate

Length of output: 95


🏁 Script executed:

# Look for loadActiveSessionDetail in production code
rg -l "loadActiveSessionDetail" --type js --type mjs

Repository: SakuraByteCore/codexmate

Length of output: 95


🏁 Script executed:

# Find the actual file without mjs type filter
find . -name "app.methods.session-browser.mjs" -type f

Repository: SakuraByteCore/codexmate

Length of output: 116


🏁 Script executed:

# Search for loadActiveSessionDetailHelper without mjs type
rg "loadActiveSessionDetailHelper" -A 5

Repository: SakuraByteCore/codexmate

Length of output: 2121


🏁 Script executed:

# Check if there's a test setup file or package.json with test runner config
cat package.json | head -50

Repository: SakuraByteCore/codexmate

Length of output: 1461


🏁 Script executed:

# Read the test runner to understand how tests work
cat tests/unit/run.mjs

Repository: SakuraByteCore/codexmate

Length of output: 3842


🏁 Script executed:

# Read createSessionBrowserMethods in detail
sed -n '1,50p' web-ui/modules/app.methods.session-browser.mjs

Repository: SakuraByteCore/codexmate

Length of output: 1630


🏁 Script executed:

# Look for the function definition and what it returns
rg "export.*createSessionBrowserMethods|function createSessionBrowserMethods" -A 30

Repository: SakuraByteCore/codexmate

Length of output: 2512


🏁 Script executed:

# Verify session-search-ui.test.mjs is not in the test runner imports
grep "session-search-ui" tests/unit/run.mjs

Repository: SakuraByteCore/codexmate

Length of output: 50


🏁 Script executed:

# List all test files that ARE being run
grep "await import" tests/unit/run.mjs | grep -oE "'[^']+\.test\.mjs'" | sort

Repository: SakuraByteCore/codexmate

Length of output: 1218


Test file is not registered in the test suite and will never execute.

The file session-search-ui.test.mjs is not imported in tests/unit/run.mjs, so this test is dead code. Either add it to the test runner or remove the file. Additionally, if this test were to run, it would fail because createSessionBrowserMethods destructures loadActiveSessionDetailHelper from options (line 12 of app.methods.session-browser.mjs), but the test only passes api on line 61. To fix: add the test to tests/unit/run.mjs and provide the missing helper in the mock setup, or delete the orphaned test file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/session-search-ui.test.mjs` around lines 60 - 121, The test file
is orphaned and will never run and also misses a required mock helper: when
calling createSessionBrowserMethods you must provide
loadActiveSessionDetailHelper (the implementation expected by
loadActiveSessionDetail) in the test's mock options; to fix either (A) register
the test file in the test runner entry so it executes and update the test's mock
creation to pass loadActiveSessionDetailHelper (implement a simple stub matching
the real helper signature) alongside api, or (B) if the test is no longer
desired, remove the orphaned test file; ensure the test that remains calling
methods.loadActiveSessionDetail uses createSessionBrowserMethods with the
loadActiveSessionDetailHelper stub and then verify pendingSessionMatchKey,
activeSessionMessages matchSnippet/isSearchMatch and jumpToSessionTimelineNode
behavior.

20 changes: 14 additions & 6 deletions tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
71 changes: 71 additions & 0 deletions web-ui/logic.sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
21 changes: 17 additions & 4 deletions web-ui/modules/app.computed.session.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
buildSessionTimelineNodes,
buildUsageChartGroups,
isSessionQueryEnabled
isSessionQueryEnabled,
normalizeSessionMatch
} from '../logic.mjs';
import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs';

Expand Down Expand Up @@ -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) {
Expand Down
36 changes: 34 additions & 2 deletions web-ui/modules/app.methods.session-browser.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
buildSessionFilterCacheState,
findSessionMessageMatchKey,
isSessionQueryEnabled,
normalizeSessionMatch,
normalizeSessionMessageRole,
normalizeSessionPathFilter
} from '../logic.mjs';
Expand Down Expand Up @@ -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
};
},

Expand Down Expand Up @@ -376,6 +383,7 @@ export function createSessionBrowserMethods(options = {}) {
this.activeSessionDetailClipped = false;
this.cancelSessionTimelineSync();
this.sessionTimelineActiveKey = '';
this.pendingSessionMatchKey = '';
this.clearSessionTimelineRefs();
await this.loadActiveSessionDetail();
},
Expand Down Expand Up @@ -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;
}
Comment on lines 439 to 465
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential race condition between match computation and async helper result.

The match is computed from this.activeSession after loadActiveSessionDetailHelper completes, but this.activeSession may have changed if the user selected a different session while the async call was in-flight. The helper uses sessionDetailRequestSeq to guard its writes, but this code path doesn't verify that the active session is still the same one that was loaded.

Consider capturing this.activeSession before the await and comparing after, or checking sessionDetailRequestSeq consistency before applying match annotations.

🛡️ Suggested guard
 async loadActiveSessionDetail(options = {}) {
+    const sessionSnapshot = this.activeSession;
     const result = await loadActiveSessionDetailHelper.call(this, api, options);
+    // Guard against session change during async load
+    if (this.activeSession !== sessionSnapshot) {
+        return result;
+    }
     const match = normalizeSessionMatch(this.activeSession);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
async loadActiveSessionDetail(options = {}) {
const sessionSnapshot = this.activeSession;
const result = await loadActiveSessionDetailHelper.call(this, api, options);
// Guard against session change during async load
if (this.activeSession !== sessionSnapshot) {
return result;
}
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;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/modules/app.methods.session-browser.mjs` around lines 439 - 465,
Before awaiting loadActiveSessionDetailHelper in loadActiveSessionDetail,
capture the identity/state used to compute the match (e.g., const currentSession
= this.activeSession or const seq = this.sessionDetailRequestSeq) and after the
await, verify that the captured value still matches (compare currentSession ===
this.activeSession or seq === this.sessionDetailRequestSeq) before computing
match and mutating
this.activeSessionMessages/pendingSessionMatchKey/jumpToSessionTimelineNode; if
the check fails, skip applying the match annotations to avoid the race. Ensure
you reference loadActiveSessionDetail, loadActiveSessionDetailHelper,
this.activeSession, and sessionDetailRequestSeq when implementing the guard.

};
}
7 changes: 6 additions & 1 deletion web-ui/partials/index/panel-sessions.html
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@
<div class="session-item-meta">
<span class="session-source">{{ session.sourceLabel }}</span>
<span class="session-item-time">{{ session.updatedAt || 'unknown time' }}</span>
<span v-if="session.match && session.match.hit" class="session-match-count">命中 {{ session.match.count || 1 }}</span>
</div>
<div v-if="session.match && session.match.primarySnippet" class="session-item-sub session-item-match" :title="session.match.primarySnippet">
{{ session.match.primarySnippet }}
</div>
</div>
</div>
Expand Down Expand Up @@ -352,7 +356,8 @@
<span class="session-msg-time">{{ msg.timestamp || '' }}</span>
</div>
</div>
<div class="session-msg-content">{{ msg.text || '' }}</div>
<div class="session-msg-content" :class="{ 'session-msg-content-match': msg.isSearchMatch }">{{ msg.text || '' }}</div>
<div v-if="msg.matchSnippet" class="session-msg-match-chip">搜索命中</div>
</div>
</div>
</div>
Expand Down
Loading
Loading