diff --git a/webclient/src/components/sidebar/AppSidebar.vue b/webclient/src/components/sidebar/AppSidebar.vue index 56f95cb..aaa6152 100644 --- a/webclient/src/components/sidebar/AppSidebar.vue +++ b/webclient/src/components/sidebar/AppSidebar.vue @@ -242,14 +242,20 @@ } async function handleSelect(session) { - // If selected from search, switch to the session's profile - if (sessionsStore.searchActive && session.profile_id) { + // loadSession lives here ONLY (SessionList just emits) — a second call + // here used to race the first one, double-fetching the session. + const wasSearch = sessionsStore.searchActive + if (wasSearch && session.profile_id) { profilesStore.selectedProfileId = session.profile_id } - await chatStore.loadSession(session.session_id, session.match_indices?.[0]) - searchOpen.value = false - sessionsStore.exitSearch() - sessionsStore.fetchSessions(profilesStore.selectedProfileId) + await chatStore.loadSession(session.session_id, wasSearch ? session.match_indices?.[0] : null) + if (wasSearch) { + // Leaving search: the sessions array still holds search results — + // restore the normal list (no refetch needed for a plain select) + searchOpen.value = false + sessionsStore.exitSearch() + sessionsStore.fetchSessions(profilesStore.selectedProfileId) + } emit('close') } diff --git a/webclient/src/components/sidebar/SessionItem.vue b/webclient/src/components/sidebar/SessionItem.vue index 9b4884e..cd7866f 100644 --- a/webclient/src/components/sidebar/SessionItem.vue +++ b/webclient/src/components/sidebar/SessionItem.vue @@ -92,19 +92,21 @@ function highlightText(text, query) { if (!query || !text) return escapeHtml(text) - const q = escapeHtml(query.toLowerCase()) - const t = escapeHtml(text) + // Match on the RAW string so match lengths stay correct even when the + // surrounding text contains escaped entities (& < >) — each emitted + // chunk is escaped separately. + const q = query.toLowerCase() + const lower = text.toLowerCase() const parts = [] let lastIndex = 0 - const lower = t.toLowerCase() while (true) { const idx = lower.indexOf(q, lastIndex) if (idx === -1) break - if (idx > lastIndex) parts.push(t.slice(lastIndex, idx)) - parts.push(`${t.slice(idx, idx + query.length)}`) + if (idx > lastIndex) parts.push(escapeHtml(text.slice(lastIndex, idx))) + parts.push(`${escapeHtml(text.slice(idx, idx + query.length))}`) lastIndex = idx + query.length } - if (lastIndex < t.length) parts.push(t.slice(lastIndex)) + if (lastIndex < text.length) parts.push(escapeHtml(text.slice(lastIndex))) return parts.join('') } diff --git a/webclient/src/components/sidebar/SessionList.vue b/webclient/src/components/sidebar/SessionList.vue index a1969d9..c6c3c7a 100644 --- a/webclient/src/components/sidebar/SessionList.vue +++ b/webclient/src/components/sidebar/SessionList.vue @@ -102,8 +102,10 @@ } ) -async function onSelect(session) { - await chatStore.loadSession(session.session_id) +function onSelect(session) { + // Don't loadSession here — AppSidebar.handleSelect owns the load (it knows + // the search context). Emitting after await used to trigger a second + // loadSession for the same session. emit('select', session) } diff --git a/webclient/src/stores/sessions.js b/webclient/src/stores/sessions.js index 07ae75d..b7ec085 100644 --- a/webclient/src/stores/sessions.js +++ b/webclient/src/stores/sessions.js @@ -26,7 +26,18 @@ search: searchActive.value ? searchQuery.value : null, }) const items = Array.isArray(page) ? page : page.items - sessions.value = items + // Merge into the existing array preserving object references — + // DynamicScroller reuses rows instead of remounting the whole list + // (wholesale replacement made the session list flicker on every refetch). + const existing = new Map(sessions.value.map(s => [s.session_id, s])) + sessions.value = items.map(it => { + const prev = existing.get(it.session_id) + if (prev) { + Object.assign(prev, it) + return prev + } + return it + }) hasMore.value = Array.isArray(page) ? false : page.has_more nextOffset.value = Array.isArray(page) ? items.length : page.next_offset if (searchActive.value) { @@ -125,12 +136,11 @@ async function pinSession(id, pinned) { await api.pinSession(id, pinned) - // Replace array: update item + re-sort pinned to top - const updated = sessions.value.map(s => - s.session_id === id ? { ...s, pinned } : s - ) - updated.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) - sessions.value = updated + // Mutate in place + in-place sort: keep item references so the list + // doesn't re-render every row (only the pinned row actually moves). + const session = sessions.value.find(s => s.session_id === id) + if (session) session.pinned = pinned + sessions.value.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) } function updateRecallStatus(id, hasRecall) { diff --git a/webclient/tests/unit/stores/sessions.test.js b/webclient/tests/unit/stores/sessions.test.js index 16eff96..107bed7 100644 --- a/webclient/tests/unit/stores/sessions.test.js +++ b/webclient/tests/unit/stores/sessions.test.js @@ -120,4 +120,57 @@ store.updateName('s1', 'Work') expect(store.sessions[0].name).toBe('Work') }) + + it('refetch merges into existing items, preserving references', async () => { + api.getSessions.mockResolvedValueOnce({ + items: [{ session_id: 's1', preview: 'old', pinned: false }], + has_more: false, + next_offset: 1, + }) + const store = useSessionsStore() + await store.fetchSessions('developer') + const ref = store.sessions[0] + api.getSessions.mockResolvedValueOnce({ + items: [{ session_id: 's1', preview: 'new', pinned: false }], + has_more: false, + next_offset: 1, + }) + await store.fetchSessions('developer') + expect(store.sessions[0]).toBe(ref) // same object reference — no row remount + expect(store.sessions[0].preview).toBe('new') + }) + + it('refetch follows server order and drops sessions removed on the server', async () => { + api.getSessions + .mockResolvedValueOnce({ + items: [{ session_id: 's1' }, { session_id: 's2' }], + has_more: false, + next_offset: 2, + }) + .mockResolvedValueOnce({ + items: [{ session_id: 's2' }, { session_id: 's3' }], + has_more: false, + next_offset: 2, + }) + const store = useSessionsStore() + await store.fetchSessions('developer') + const ref2 = store.sessions[1] + await store.fetchSessions('developer') + expect(store.sessions.map(s => s.session_id)).toEqual(['s2', 's3']) + expect(store.sessions[0]).toBe(ref2) + }) + + it('pinSession keeps item reference (in-place update + sort)', async () => { + api.pinSession.mockResolvedValue() + const store = useSessionsStore() + store.sessions = [ + { session_id: 's1', pinned: false }, + { session_id: 's2', pinned: false }, + ] + const ref2 = store.sessions[1] + await store.pinSession('s2', true) + expect(store.sessions[0].session_id).toBe('s2') + expect(store.sessions[0]).toBe(ref2) + expect(store.sessions[0].pinned).toBe(true) + }) })