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)
+ })
})