diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 04a002c103c..3f3fdd0ff39 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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() + // 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() 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. diff --git a/apps/desktop/src/app/chat/sidebar/projects/index.ts b/apps/desktop/src/app/chat/sidebar/projects/index.ts index f30d075a0f5..53366611c34 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/index.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/index.ts @@ -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, diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index 9d1a9c0b6fa..7fb7166f9b6 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -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']) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index f97a14dc871..55f36bf99f9 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -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, diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index b880eb87a92..8cd060bb02a 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -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)