fix(desktop): stop dropping pinned sessions past the page limit

The list endpoints deliberately back-fill pinned conversations past their
LIMIT, then the client sliced the response back down to that same limit
and threw them away — so only pins that happened to land inside the most
recent page ever rendered, which reads as a cap on how many sessions you
can pin.

Keep the back-filled rows when trimming, and discount them from the
"window came back full" test that drives Load more. Counting a back-fill
as a loaded row invented a page that could never be fetched, leaving a
Load more button that refetched the same rows forever.
This commit is contained in:
Brooklyn Nicholson 2026-08-06 21:11:10 -05:00
parent cef7d1a1e1
commit fd9fc50dd2
2 changed files with 32 additions and 6 deletions

View File

@ -262,8 +262,12 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
])
// A full window back means the profile still has more on disk.
const truncated = result.sessions.length >= loaded + SIDEBAR_SESSIONS_PAGE_SIZE
// A full window back means the profile still has more on disk — but pinned
// rows arrive as a back-fill PAST the limit, so counting them fakes a full
// page and the "Load more" never goes away (it re-fetches the same rows
// forever). Only unpinned rows count toward the window.
const unpinned = result.sessions.filter(s => !s.pinned).length
const truncated = unpinned >= loaded + SIDEBAR_SESSIONS_PAGE_SIZE
setSessionProfilesTruncated(prev => ({ ...prev, [key]: truncated }))
}, [])

View File

@ -370,6 +370,26 @@ export function pluginSocket(pluginId: string, path: string, onMessage: (data: u
}
}
/**
* Trim a page to its window WITHOUT discarding pinned rows.
*
* The list endpoints deliberately back-fill pinned conversations past their
* LIMIT a pin means "always reachable", so an aged-out pinned chat is
* appended after the recency window. A plain `slice(0, limit)` throws exactly
* those rows away again, which is why pins silently stopped rendering past
* some count: the sidebar could only ever show the pins that happened to fall
* inside the most-recent page.
*/
function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] {
if (sessions.length <= limit) {
return sessions
}
const recent = sessions.slice(0, limit)
return [...recent, ...sessions.slice(limit).filter(session => session.pinned)]
}
export async function listSessions(
limit = 40,
minMessages = 0,
@ -385,7 +405,7 @@ export async function listSessions(
return {
...result,
sessions: result.sessions.slice(0, limit),
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}
@ -426,7 +446,7 @@ export async function listAllProfileSessions(
return {
...result,
sessions: result.sessions.slice(0, limit),
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}
@ -445,14 +465,16 @@ export interface SidebarSessionSlice {
/** Which profiles filled their per-profile window in a returned page. The
* legacy per-slice endpoint doesn't report this, so derive it from the rows:
* a profile at (or over) the cap still has more on disk. */
* a profile at (or over) the cap still has more on disk. Pinned rows are
* discounted they're back-filled past the LIMIT, so counting them fakes a
* full page and leaves a "Load more" that can never resolve. */
function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record<string, boolean> {
const counts = new Map<string, number>()
for (const session of sessions) {
const key = session.profile || 'default'
counts.set(key, (counts.get(key) ?? 0) + 1)
counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1))
}
return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))