mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
48b21acb90
5 changed files with 230 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue