Merge pull request #73164 from NousResearch/bb/pins-out-of-lists

fix(desktop): keep pinned sessions out of the unpinned sidebar lists
This commit is contained in:
brooklyn! 2026-07-28 01:54:08 -05:00 committed by GitHub
commit 48b21acb90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 230 additions and 18 deletions

View file

@ -113,6 +113,7 @@ import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds }
import { ProfileRail } from './profile-switcher'
import { ProjectDialog } from './project-dialog'
import {
excludeProjectSessions,
orderProjectsByIds,
overlayLiveLanes,
overlayLivePreviews,
@ -429,6 +430,16 @@ export function ChatSidebar({
}, [pinnedSessionIds, sessionByAnyId])
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
const pinnedIdSet = useMemo(() => new Set(pinnedSessionIds), [pinnedSessionIds])
// A pinned session belongs to the Pinned section and nowhere else, so every
// other list filters it out (the flat recents already did). Match on the live
// id AND the durable pin id — a backend snapshot can surface either side of a
// compression tip rotation.
const isPinnedSession = useCallback(
(session: SessionInfo) => pinnedRealIdSet.has(session.id) || pinnedIdSet.has(sessionPinId(session)),
[pinnedRealIdSet, pinnedIdSet]
)
// Full-text search across *all* sessions (not just the loaded page) so 699
// sessions stay findable. Debounced; loaded sessions are matched instantly
@ -492,8 +503,8 @@ export function ChatSidebar({
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
[sortedSessions, pinnedRealIdSet]
() => sortedSessions.filter(s => !isPinnedSession(s)),
[sortedSessions, isPinnedSession]
)
useEffect(() => {
@ -615,13 +626,18 @@ export function ChatSidebar({
const sorted = sortProjectsForOverview(
projectTree
.filter(node => !(node.isAuto && dismissed.has(node.id)))
.map(project => ({
...project,
// Home is synthetic, so its name is ours to translate — every other
// label is a repo basename or a name the user typed.
label: project.isNoProject ? s.projects.home : project.label,
repos: orderRepos(project.repos)
})),
.map(project =>
excludeProjectSessions(
{
...project,
// Home is synthetic, so its name is ours to translate — every other
// label is a repo basename or a name the user typed.
label: project.isNoProject ? s.projects.home : project.label,
repos: orderRepos(project.repos)
},
isPinnedSession
)
),
activeProjectId
)
@ -629,7 +645,16 @@ export function ChatSidebar({
// (default) returns `sorted` untouched; projects the user hasn't ordered yet
// keep their sorted position rather than jumping the hand-picked list.
return orderProjectsByIds(sorted, projectOrderIds)
}, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds, s])
}, [
showAllProfiles,
projectTree,
dismissedAutoProjects,
orderRepos,
activeProjectId,
projectOrderIds,
isPinnedSession,
s
])
// The overview only renders in grouped mode; the model stays live regardless
// so scoping is consistent across views.
@ -690,11 +715,16 @@ export function ChatSidebar({
// The live-session overlay (creates/evictions) is applied per-repo in
// RepoFlatSection, AFTER the visual git-worktree lanes are merged in (so
// out-of-tree worktrees can be placed). Here we just order the snapshot.
// out-of-tree worktrees can be placed). Here we just order the snapshot and
// drop pinned rows — the hydrated lanes come straight from the backend, so
// they haven't been through projectModel's filter.
// The label comes from the overview node either way — that's the model's
// presentation copy (Home is translated there), not the raw payload's.
return { ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) }
}, [overviewEnteredProject, enteredProjectTree, orderRepos])
return excludeProjectSessions(
{ ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) },
isPinnedSession
)
}, [overviewEnteredProject, enteredProjectTree, orderRepos, isPinnedSession])
// Overlay live `$sessions` onto the entered project so a just-created session
// (which the backend snapshot hasn't folded in yet) counts as content and
@ -877,6 +907,10 @@ export function ChatSidebar({
}
const bySource = new Map<string, SessionInfo[]>()
// Rows this platform owns that the Pinned section is showing instead. The
// backend's per-platform total counts them, so discount it or "load more"
// promises rows that will never appear.
const pinnedBySource = new Map<string, number>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
@ -885,6 +919,12 @@ export function ChatSidebar({
continue
}
if (isPinnedSession(session)) {
pinnedBySource.set(sourceId, (pinnedBySource.get(sourceId) ?? 0) + 1)
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
@ -894,13 +934,14 @@ export function ChatSidebar({
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
const unpinnedKnown = known == null ? null : Math.max(0, known - (pinnedBySource.get(sourceId) ?? 0))
const total = Math.max(ordered.length, unpinnedKnown ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
hasMore: unpinnedKnown != null ? unpinnedKnown > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
@ -908,7 +949,7 @@ export function ChatSidebar({
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
}, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.

View file

@ -11,6 +11,7 @@ export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
excludeProjectSessions,
overlayLiveLanes,
overlayLivePreviews,
sessionRecency,

View file

@ -5,6 +5,7 @@ import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import {
baseName,
excludeProjectSessions,
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
@ -865,3 +866,102 @@ describe('overlayLivePreviews', () => {
expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh'])
})
})
describe('excludeProjectSessions', () => {
it('drops matching rows from every lane and recounts the subtree', () => {
const keep = makeSession('/www/app', { id: 'keep' })
const pinnedRow = makeSession('/www/app', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 2,
groups: [lane({ id: 'main', isMain: true, label: 'main', path: '/www/app', sessions: [keep, pinnedRow] })]
}
],
sessionCount: 2
})
const filtered = excludeProjectSessions(project, session => session.id === 'pinned')
expect(filtered.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(filtered.repos[0].sessionCount).toBe(1)
expect(filtered.sessionCount).toBe(1)
})
it('keeps a lane the filter emptied — a worktree is structure, not a row', () => {
const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
previewSessions: [pinnedRow],
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: 'wt', label: 'wt', path: '/www/app/wt', sessions: [pinnedRow] })]
}
],
sessionCount: 1
})
const filtered = excludeProjectSessions(project, session => session.id === 'pinned')
expect(filtered.repos[0].groups.map(g => g.id)).toEqual(['wt'])
expect(filtered.repos[0].groups[0].sessions).toEqual([])
expect(filtered.previewSessions).toEqual([])
expect(filtered.sessionCount).toBe(0)
})
it('returns the same node when nothing matches (memo-stable)', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'keep' })],
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: 'main', isMain: true, label: 'main', sessions: [makeSession('/www/app', { id: 'keep' })] })
]
}
],
sessionCount: 1
})
expect(excludeProjectSessions(project, () => false)).toBe(project)
})
it('survives the live overlay: a lane left empty by the filter is not pruned', () => {
// The two run in sequence on an entered project (filter, then overlay), and
// the overlay drops lanes it empties — it must not take the filter's with it.
const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: 'wt', label: 'wt', path: '/www/app/wt', sessions: [pinnedRow] })]
}
],
sessionCount: 1
})
const filtered = excludeProjectSessions(project, session => session.id === 'pinned')
const overlaid = overlayLiveLanes(filtered, [], new Set(['someone-else']))
expect(overlaid.repos[0].groups.map(g => g.id)).toEqual(['wt'])
})
})

View file

@ -497,6 +497,10 @@ export function overlayRepoLanes(
): SidebarWorkspaceTree {
const repoRootKey = pathKey(repo.path)
let changed = false
// Lanes that arrived with no rows are not eviction casualties — they're real
// structure (a `git worktree list` lane, or one whose sessions are pinned
// away). The prune below is only allowed to drop lanes IT emptied.
const emptyOnInput = new Set(repo.groups.filter(g => !g.sessions.length).map(g => g.id))
// Snapshot lanes minus anything the user just deleted/archived.
const lanes = repo.groups.map(g => {
@ -577,7 +581,7 @@ export function overlayRepoLanes(
// Drop lanes emptied by eviction (the server only emits non-empty lanes; the
// git-worktree enhancer re-adds any still-real worktree as an empty lane).
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0))
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0 || emptyOnInput.has(g.id)))
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
@ -610,6 +614,65 @@ function overlayHomeLane(
}
}
/**
* Drop matching sessions from every lane (and the overview preview) of a
* project subtree, recounting as lanes shrink. Used to keep pinned sessions out
* of the project lists: a pin belongs to the Pinned section, not to both. The
* predicate rather than an id set lets the caller match a pin on its
* durable lineage-root id as well as the live one.
*
* Lanes SURVIVE being emptied. A worktree is structure (it exists on disk, you
* can still start work in it); pinning its last chat must not delete the branch
* from the tree same reason the `git worktree list` enhancer injects lanes
* that never had a session. Only the rows move. Memo-stable: returns the same
* ref when nothing matched.
*/
export function excludeProjectSessions(
project: SidebarProjectTree,
isExcluded: (session: SessionInfo) => boolean
): SidebarProjectTree {
let changed = false
const repos = project.repos.map(repo => {
let repoChanged = false
const groups = repo.groups.map(group => {
const sessions = group.sessions.filter(session => !isExcluded(session))
if (sessions.length === group.sessions.length) {
return group
}
repoChanged = true
return { ...group, sessions }
})
if (!repoChanged) {
return repo
}
changed = true
return { ...repo, groups, sessionCount: groups.reduce((n, group) => n + group.sessions.length, 0) }
})
const previewSessions = project.previewSessions?.filter(session => !isExcluded(session))
changed ||= previewSessions?.length !== project.previewSessions?.length
if (!changed) {
return project
}
return {
...project,
previewSessions,
repos,
sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0)
}
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,

View file

@ -197,7 +197,14 @@ export function SidebarSessionsSection({
// A defined project list is itself content (even an empty project should
// render as a drill-in row so the user can see it exists).
const hasProjectOverview = Boolean(projectOverview?.length)
const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0)
// Lanes count as content even with no rows left in them: the backend only
// emits a lane that has sessions, so a lane surviving with zero rows means
// they were filtered out (pinned) — the branch is real and must still render.
// A genuinely empty project has no lanes at all and keeps its empty state.
const hasProjectContent = Boolean(
projectContent && (projectContent.sessionCount > 0 || projectContent.repos.some(repo => repo.groups.length > 0))
)
const showEmptyState =
forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)