Merge pull request #83052 from NousResearch/bb/sidebar-all-profiles

Sidebar: show every profile at once
This commit is contained in:
brooklyn!
2026-08-10 03:57:47 -05:00
committed by GitHub
36 changed files with 1396 additions and 349 deletions
+6 -1
View File
@@ -1,3 +1,4 @@
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const closeFocusedSessionTab = vi.fn(() => false)
@@ -17,7 +18,11 @@ vi.mock('@/store/session-states', () => ({
}))
vi.mock('@/store/profile', () => ({
requestFreshSession: () => requestFreshSession()
// The layout store reads the sidebar's profile scope; this suite only cares
// about the fresh-session call.
$showAllProfiles: atom(false),
requestFreshSession: () => requestFreshSession(),
setShowAllProfiles: () => {}
}))
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
+4
View File
@@ -37,6 +37,10 @@ export function PrTag({ className, pr }: { className?: string; pr: HermesBranchP
style.className,
className
)}
// Marks the chip as a live link for the row's hover rule: while the
// pointer is on it, the row keeps its metadata and holds the kebab back
// (see session-row) so the click can actually land.
data-pr-link
onClick={event => {
// The row underneath opens the session on click and pins on
// shift-click; the chip is its own target and keeps the press.
@@ -1,8 +1,13 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { RowButton } from '@/components/ui/row-button'
import { Tip } from '@/components/ui/tooltip'
import { compactNumber } from '@/lib/format'
import { cn } from '@/lib/utils'
import { $sidebarRowMeta } from '@/store/layout'
// Shared, content-agnostic sidebar chrome — used by both the flat session
// sections and the project/workspace tree, so it lives outside either to keep
@@ -118,6 +123,90 @@ export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'s
return <span className={cn(rowLabel, className)} {...props} />
}
/** What a group's sessions add up to, for the Show options that count something. */
export interface SidebarGroupTotals {
costUsd: number
tokens: number
}
/**
* Header for a group of sessions that hangs its rows underneath — a project, a
* profile. Row-shaped rather than caption-shaped (that's {@link SidebarDateDivider},
* for groupings that only separate), so a group header lines up with the session
* rows it heads. `toggle` omitted keeps the caret's space with nothing to reveal.
*/
export function SidebarGroupRow({
actions,
className,
label,
lead,
toggle,
totals,
...props
}: React.ComponentProps<'div'> & {
actions?: React.ReactNode
label: React.ReactNode
lead: React.ReactNode
toggle?: { ariaLabel: string; onToggle: () => void; open: boolean }
totals?: SidebarGroupTotals
}) {
const rowMeta = useStore($sidebarRowMeta)
const facts = [
totals && rowMeta.includes('tokens') && totals.tokens > 0 ? compactNumber(totals.tokens) : null,
// Sub-cent spend rounds to "$0.00", which reads as a bug rather than as a
// cheap group — below a cent the header says nothing at all.
totals && rowMeta.includes('cost') && totals.costUsd >= 0.01 ? `$${totals.costUsd.toFixed(2)}` : null
].filter(Boolean) as string[]
return (
<SidebarRowShell
actions={
// The controls overlay the figures rather than sitting beside them: in
// flow they hold their width open at all times, which reads as a gap
// torn between the total and the row's edge. Same trade the session row
// makes with its kebab and age — you read the number or you act on the
// group, never both at once.
facts.length ? (
<div className="relative flex items-center">
<span className="min-w-9 whitespace-nowrap text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) transition-opacity group-hover/workspace:opacity-0">
{facts.join(' · ')}
</span>
{actions ? <div className="absolute right-0 flex items-center">{actions}</div> : null}
</div>
) : (
actions
)
}
className={cn('group/workspace', className)}
{...props}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
{label}
{toggle ? (
<Tip label={toggle.ariaLabel}>
<button
aria-label={toggle.ariaLabel}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
data-row-actions
onClick={toggle.onToggle}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={toggle.open}
/>
</button>
</Tip>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
)
}
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
export function SidebarRowGrab({
ariaLabel,
@@ -150,7 +150,7 @@ export function SidebarCronJobsSection({
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
<SidebarGroupContent className="scrollbar-fade flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
@@ -26,6 +26,7 @@ import {
$sidebarGrouping,
$sidebarOrdering,
$sidebarPrFilter,
$sidebarProfileFilter,
$sidebarProjectFilter,
$sidebarRowMeta,
$sidebarShowArchived,
@@ -41,10 +42,19 @@ import {
type SidebarOrdering,
type SidebarRowMeta,
toggleSidebarPrFilter,
toggleSidebarProfileFilter,
toggleSidebarProjectFilter,
toggleSidebarRowMeta,
toggleSidebarStatusFilter
} from '@/store/layout'
import {
$profiles,
$showAllProfiles,
normalizeProfileKey,
requestProfileCreate,
toggleShowAllProfiles
} from '@/store/profile'
import { runImportProfileFlow } from '@/store/profile-share'
import { $projectTree } from '@/store/projects'
import type { PullRequestBucket } from '@/store/pull-requests'
import { $unreadFinishedSessionIds, markAllSessionsRead } from '@/store/session'
@@ -62,7 +72,8 @@ interface Option<T extends string = string> {
const GROUPINGS: Option<SidebarGrouping>[] = [
{ icon: 'clock', id: 'date', label: 'Updated' },
{ icon: 'root-folder', id: 'project', label: 'Project' },
{ icon: 'pulse', id: 'status', label: 'Status' }
{ icon: 'pulse', id: 'status', label: 'Status' },
{ icon: 'account', id: 'profile', label: 'Profile' }
]
const ORDERINGS: Option<SidebarOrdering>[] = [
@@ -141,6 +152,10 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
const rowMeta = useStore($sidebarRowMeta)
const statusFilter = useStore($sidebarStatusFilter)
const projectFilter = useStore($sidebarProjectFilter)
const profileFilter = useStore($sidebarProfileFilter)
const showAllProfiles = useStore($showAllProfiles)
const profileNames = useStore($profiles).map(profile => normalizeProfileKey(profile.name))
const narrowsByProfile = showAllProfiles && profileNames.length > 1
const prFilter = useStore($sidebarPrFilter)
const showArchived = useStore($sidebarShowArchived)
const filtersActive = useStore($sidebarFiltersActive)
@@ -287,6 +302,32 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
</DropdownMenuSub>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>Profile</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
{/* Scoped to one profile the rail is already the filter, so the
per-profile boxes only appear where they can narrow something.
The actions below stand on their own. */}
{narrowsByProfile && (
<>
{profileNames.map(name => (
<OptionCheckbox
checked={profileFilter.includes(name)}
key={name}
onCheck={() => toggleSidebarProfileFilter(name)}
option={{ icon: 'account', id: name, label: name }}
/>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={requestProfileCreate}>{t.profiles.newProfile}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void runImportProfileFlow()}>
{t.profiles.importProfile}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{projects.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Project</DropdownMenuSubTrigger>
@@ -308,6 +349,19 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
</DropdownMenuSub>
)}
{/* Off by default: one profile's sessions are what the rail selected.
Nothing to widen to until a second profile exists — but stay
visible while it's on, or deleting your way back down to one
profile would strand the sidebar in a mode nothing can leave (the
rail hides its switcher at one profile too). */}
{(profileNames.length > 1 || showAllProfiles) && (
<OptionCheckbox
checked={showAllProfiles}
onCheck={toggleShowAllProfiles}
option={{ id: 'all-profiles', label: t.profiles.allProfiles }}
/>
)}
<OptionCheckbox
checked={showArchived}
onCheck={() => setSidebarShowArchived(!showArchived)}
+84 -82
View File
@@ -26,7 +26,7 @@ import { useContributions } from '@/contrib/react/use-contributions'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { comboTokens } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { resolveProfileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
@@ -44,6 +44,7 @@ import {
$sidebarPinsOpen,
$sidebarPrDataWanted,
$sidebarPrFilter,
$sidebarProfileFilter,
$sidebarProjectFilter,
$sidebarProjectOrderIds,
$sidebarRecentsOpen,
@@ -69,7 +70,14 @@ import {
toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
import {
$newChatProfile,
$profileColors,
$profiles,
$profileScope,
ALL_PROFILES,
normalizeProfileKey
} from '@/store/profile'
import {
$activeProjectId,
$projects,
@@ -110,9 +118,10 @@ import {
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $sessionDotStateById, sessionStatusBucket, sessionStatusRank } from '@/store/session-dot-state'
import { $sessionDotStateById, sessionStatusBucket } from '@/store/session-dot-state'
import { $focusedStoredSessionId, $workingSessionIds, type SplitDir } from '@/store/session-states'
import { $archivedSessions, loadArchivedSessions, sessionCostUsd } from '@/store/sidebar-archive'
import { $archivedSessions, loadArchivedSessions } from '@/store/sidebar-archive'
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
import {
type AppView,
@@ -203,8 +212,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
// Vertical scroll only — never a horizontal bar from glow bleed, long titles, etc.
const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain'
// Vertical scroll only — never a horizontal bar from glow bleed, long titles,
// etc. The bar itself only shows while the pointer is in the list.
const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain scrollbar-fade'
// The outer list reserves its bar's width whether or not one is showing, so
// filtering or collapsing a section doesn't reflow every row sideways. Only the
@@ -256,7 +266,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => Promise<void> | void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
@@ -273,7 +282,6 @@ export function ChatSidebar({
currentView,
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
@@ -319,6 +327,7 @@ export function ChatSidebar({
const ordering = useStore($sidebarOrdering)
const statusFilter = useStore($sidebarStatusFilter)
const projectFilter = useStore($sidebarProjectFilter)
const profileFilter = useStore($sidebarProfileFilter)
const prFilter = useStore($sidebarPrFilter)
const prDataWanted = useStore($sidebarPrDataWanted)
const prBranchOverrides = useStore($prBranchBySession)
@@ -327,6 +336,9 @@ export function ChatSidebar({
const showArchived = useStore($sidebarShowArchived)
const archivedSessions = useStore($archivedSessions)
const dotStates = useStore($sessionDotStateById)
// The active sort key as an id order. The flat list applies it within its
// dividers; groups apply it to their own lanes.
const sortOrderIds = useStore($sidebarSessionRankIds)
const agentsGrouped = grouping === 'project'
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
@@ -344,6 +356,7 @@ export function ChatSidebar({
const sessionsLoading = useStore($sessionsLoading)
const sessionProfilesTruncated = useStore($sessionProfilesTruncated)
const profiles = useStore($profiles)
const profileColors = useStore($profileColors)
const profileScope = useStore($profileScope)
// Only surface the profile switcher when more than one profile exists, so
// single-profile users see the unchanged sidebar.
@@ -373,7 +386,6 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [searchPending, setSearchPending] = useState(false)
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const [recentsLoadMorePending, setRecentsLoadMorePending] = useState(false)
const messagingOpenIds = useStore($sidebarMessagingOpenIds)
@@ -440,6 +452,12 @@ export function ChatSidebar({
return false
}
// Narrowing to a few of the profiles on screen. Scoped to one profile the
// list is already that profile's, so a stale selection can't blank it.
if (showAllProfiles && profileFilter.length && !profileFilter.includes(normalizeProfileKey(session.profile))) {
return false
}
if (prFilter.length) {
const key = sessionPrKey(session)
@@ -452,10 +470,14 @@ export function ChatSidebar({
// lands in the lane the user picked it from.
return !projectFilter.length || projectFilter.includes(liveSessionProjectId(session, projects) ?? '')
},
[statusFilter, projectFilter, prFilter, pullRequests, projects, dotStates]
[statusFilter, projectFilter, profileFilter, showAllProfiles, prFilter, pullRequests, projects, dotStates]
)
const filtersNarrow = statusFilter.length > 0 || projectFilter.length > 0 || prFilter.length > 0
const filtersNarrow =
statusFilter.length > 0 ||
projectFilter.length > 0 ||
prFilter.length > 0 ||
(showAllProfiles && profileFilter.length > 0)
const visibleSessions = useMemo(
() => (filtersNarrow ? scopedSessions.filter(sessionMatchesFilters) : scopedSessions),
@@ -634,7 +656,7 @@ export function ChatSidebar({
// Workspace grouping is a `project -> repo -> lane -> sessions` tree computed
// authoritatively on the backend (projects.tree). Parents reorder via
// workspaceParentOrderIds; worktrees within a parent via workspaceOrderIds.
const worktreeGroupingActive = agentsGrouped && !showAllProfiles && !showArchived
const worktreeGroupingActive = agentsGrouped && !showArchived
const gatewayReady = gatewayState === 'open'
// The backend project tree is a structural snapshot, NOT a per-message feed.
@@ -651,6 +673,18 @@ export function ChatSidebar({
if (worktreeGroupingActive) {
void refreshProjects()
// The all-profiles tree is served off every profile's databases at once
// and deliberately leaves discovery out — a repo with no sessions is the
// same repo in every profile, so scanning here would multiply empty lanes
// by the profile count and write the result into profiles the user isn't
// driving.
if (showAllProfiles) {
void refreshProjectTree()
return
}
// Paint the list from the fast tree fetch (explicit projects + repos from
// existing sessions / the backend cache) FIRST, then kick off the heavy
// home-dir git crawl so newly-discovered repos fold in afterward — instead
@@ -667,7 +701,7 @@ export function ChatSidebar({
const warm = window.setTimeout(() => void refreshProjectTree(), PROJECT_TREE_WARM_MS)
return () => window.clearTimeout(warm)
}, [worktreeGroupingActive, profileScope, gatewayReady])
}, [worktreeGroupingActive, showAllProfiles, profileScope, gatewayReady])
// Sessions the branch join can't answer for get one look at their own
// transcript — a `gh pr create` in there names the PR outright. Backfills
@@ -768,6 +802,12 @@ export function ChatSidebar({
void refreshProjects()
void refreshProjectTree()
// Discovery stays off while browsing every profile, for the reason the
// first fetch leaves it out.
if (showAllProfiles) {
return
}
const now = Date.now()
if (now - lastScanAt >= SCAN_THROTTLE_MS) {
@@ -783,7 +823,7 @@ export function ChatSidebar({
window.removeEventListener('focus', onActive)
document.removeEventListener('visibilitychange', onActive)
}
}, [worktreeGroupingActive, gatewayReady])
}, [worktreeGroupingActive, showAllProfiles, gatewayReady])
// Apply the persisted repo + worktree orders to a project's repo subtrees.
const orderRepos = useCallback(
@@ -801,10 +841,6 @@ export function ChatSidebar({
// state on top: dismissed auto-projects, persisted repo/lane order, and the
// overview sort. Membership is the backend tree's — never re-derived here.
const projectModel = useMemo<SidebarProjectTree[]>(() => {
if (showAllProfiles) {
return []
}
const sorted = sortProjectsForOverview(
filterVisibleProjects(projectTree, dismissedAutoProjects)
// A filtered-out project drops its whole lane, header included — hiding
@@ -830,7 +866,6 @@ export function ChatSidebar({
// keep their sorted position rather than jumping the hand-picked list.
return orderProjectsByIds(sorted, projectOrderIds)
}, [
showAllProfiles,
projectTree,
dismissedAutoProjects,
orderRepos,
@@ -1020,8 +1055,14 @@ export function ChatSidebar({
// session shows under its project instantly (and with its working arc),
// matching the flat Recents list. Keyed by project id for the rows.
const overviewPreviews = useMemo<Record<string, SessionInfo[]>>(
() => overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, removedSessionIds),
[projectOverview, agentSessions, projects, removedSessionIds]
() =>
overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, {
removed: removedSessionIds,
// Rank before the trim, so "3 priciest in this project" isn't "3 most
// recent, priciest first".
rankIds: sortOrderIds
}),
[projectOverview, agentSessions, projects, removedSessionIds, sortOrderIds]
)
const onEnterProject = useCallback(
@@ -1070,11 +1111,6 @@ export function ChatSidebar({
[]
)
const loadMoreForProfileGroup = useCallback(
(profile: string) => runKeyedLoad(profile, onLoadMoreProfileSessions, setProfileLoadMorePending),
[onLoadMoreProfileSessions, runKeyedLoad]
)
const loadMoreForMessaging = useCallback(
(platform: string) => runKeyedLoad(platform, onLoadMoreMessaging, setMessagingLoadMorePending),
[onLoadMoreMessaging, runKeyedLoad]
@@ -1146,10 +1182,14 @@ export function ChatSidebar({
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession])
// ALL-profiles view: one collapsible group per profile, color on the header
// Grouping by profile: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
// Only reachable while the sidebar is showing every profile — scoped to one,
// it would draw a single group around the whole list.
const profileGrouped = showAllProfiles && grouping === 'profile'
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
if (!showAllProfiles) {
if (!profileGrouped) {
return undefined
}
@@ -1159,7 +1199,7 @@ export function ChatSidebar({
const key = normalizeProfileKey(session.profile)
const group = groups.get(key) ?? {
color: profileColor(key),
color: resolveProfileColor(key, profileColors),
id: key,
label: key,
mode: 'profile',
@@ -1172,25 +1212,11 @@ export function ChatSidebar({
groups.set(key, group)
}
return (
[...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
hasMore: Boolean(sessionProfilesTruncated[group.id])
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
// default (root) first, then the rest alphabetically.
return [...groups.values()].sort((a, b) =>
a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)
)
}, [
showAllProfiles,
agentSessions,
loadMoreForProfileGroup,
onLoadMoreProfileSessions,
profileLoadMorePending,
sessionProfilesTruncated
])
}, [profileGrouped, agentSessions, profileColors])
// The flat Sessions list always shows ALL recent sessions; Projects is a
// parallel grouped view, not a filter on this one — nothing is hidden here.
@@ -1268,31 +1294,7 @@ export function ChatSidebar({
// state-based keys stay bucketed, where they read correctly per day.
const rankedGlobally = ordering === 'cost' || ordering === 'tokens'
// Every sort key but `updated` is expressed as an id order applied within
// whatever dividers are on — so a bucketed key ranks rows inside each day,
// and a globally-ranked one (which has no dividers left) ranks the lot.
// `updated` is the natural order the list already arrives in, so it needs no
// ids at all.
const sortOrderIds = useMemo(() => {
const rank: null | ((session: SessionInfo) => number) =
ordering === 'status'
? session => sessionStatusRank(dotStates[session.id])
: ordering === 'created'
? session => -session.started_at
: ordering === 'tokens'
? session => -(session.input_tokens + session.output_tokens)
: ordering === 'cost'
? session => -sessionCostUsd(session)
: null
if (!rank) {
return undefined
}
return [...agentSessions].sort((a, b) => rank(a) - rank(b)).map(session => session.id)
}, [ordering, agentSessions, dotStates])
const displayAgentGroups = showAllProfiles ? profileGroups : undefined
const displayAgentGroups = profileGroups
// The recents list owns its own (virtualized) scroll container only when it's a
// long flat list. In that case it must keep its scroller even in short mode, so
@@ -1550,11 +1552,8 @@ export function ChatSidebar({
activeSessionId={activeSidebarSessionId}
collapsible={!inProject}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col pb-1.75',
'flex min-h-0 flex-1 flex-col gap-px pb-1.75',
SCROLL_Y,
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px',
// Flatten into the single scroll when compact — unless this is the
// virtualized long list, which must keep its own scroller.
!recentsVirtualizes && COMPACT_FLAT
@@ -1576,10 +1575,10 @@ export function ChatSidebar({
)
}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
// Hidden only when workspace-grouped — those groups page
// themselves. Profile groups don't: this one footer fetches the
// next page, which grows every profile at once.
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading || recentsLoadMorePending}
onClick={() => void onLoadMoreRecents()}
@@ -1651,7 +1650,7 @@ export function ChatSidebar({
</Tip>
) : null}
<div className="grid size-6 place-items-center">
{!showAllProfiles ? <SidebarFilterMenu className={HEADER_NAV_BTN} /> : null}
<SidebarFilterMenu className={HEADER_NAV_BTN} />
</div>
</div>
)
@@ -1670,7 +1669,10 @@ export function ChatSidebar({
onBranchSession={onBranchSession}
onDeleteSession={onDeleteSession}
onEnterProject={onEnterProject}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
// Unlike reorder below, this stays on across profiles: a folder
// is a folder, and the new session lands in the active profile
// — the same one the composer would have started it in.
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorderProjects={showAllProfiles ? undefined : reorderProjects}
onReorderSessions={showAllProfiles ? undefined : reorderSessions}
onResumeSession={onResumeSession}
@@ -6,6 +6,7 @@ import type { SessionInfo } from '@/types/hermes'
import {
orderByIds,
orderRowsWithinGroups,
rankSessions,
reconcileOrderIds,
reorderableRowIds,
resolveManualSessionOrderIds,
@@ -64,6 +65,23 @@ describe('orderByIds', () => {
})
})
describe('rankSessions', () => {
const sessions = [{ id: 'newest' }, { id: 'middle' }, { id: 'oldest' }]
it('leaves the lane alone when the sidebar is on its default sort', () => {
expect(rankSessions(sessions)).toBe(sessions)
expect(rankSessions(sessions, [])).toBe(sessions)
})
it('applies the active sort key to a lane the flat list never renders', () => {
expect(rankSessions(sessions, ['oldest', 'newest', 'middle']).map(s => s.id)).toEqual([
'oldest',
'newest',
'middle'
])
})
})
describe('reconcileOrderIds', () => {
it('returns empty for no current ids', () => {
expect(reconcileOrderIds([], ['a'])).toEqual([])
@@ -103,6 +103,15 @@ export function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds:
return [...newer, ...ordered, ...older]
}
/**
* Apply the active sort key (as an id order) to a set of session rows, leaving
* them in the order they came in when nothing is ranked. Grouped views call
* this on their own lane so a sort key reaches rows the flat list never renders.
*/
export function rankSessions<T extends { id: string }>(sessions: T[], rankIds?: string[]): T[] {
return rankIds?.length ? orderByIds(sessions, session => session.id, rankIds) : sessions
}
/** Reconcile a persisted order against the live id set. */
export function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
if (!currentIds.length) {
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -71,11 +71,19 @@ describe('ProjectOverviewRow', () => {
expect(screen.queryByRole('button', { name: 'Show Test D sessions' })).toBeNull()
})
it('drops the "new session" add button on Home, which has no folder to start in', () => {
const home = { id: '__no_project__', isNoProject: true, label: 'Home' } as unknown as SidebarProjectTree
it('offers the "new session" add button on Home, which starts one with no folder', () => {
const home = {
id: '__no_project__',
isNoProject: true,
label: 'Home',
path: null
} as unknown as SidebarProjectTree
render(<ProjectOverviewRow onNewSession={vi.fn()} project={home} />)
const onNewSession = vi.fn()
expect(screen.queryByRole('button', { name: 'New session in Home' })).toBeNull()
render(<ProjectOverviewRow onNewSession={onNewSession} project={home} />)
fireEvent.click(screen.getByRole('button', { name: 'New session in Home' }))
expect(onNewSession).toHaveBeenCalledWith(null)
})
})
@@ -2,16 +2,14 @@ import type * as React from 'react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
SIDEBAR_LEAD_ICON_SIZE,
SidebarGroupRow,
SidebarRowBody,
SidebarRowCluster,
SidebarRowGrab,
SidebarRowLabel,
SidebarRowLead,
@@ -113,18 +111,30 @@ export function ProjectOverviewRow({
)
const shell = (
<SidebarRowShell
<SidebarGroupRow
actions={
<>
{/* Home has no folder to start a chat in — the sidebar's own "New
session" is that button — and no record to rename or delete. */}
{onNewSession && !project.isNoProject && (
{/* Home is a bucket, not a record, so there's nothing to rename or
delete — but it still starts sessions: a null path is the "no
folder" chat. New session sits outermost: it's the one you reach
for. */}
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
{onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
className={cn(dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
label={
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
}
lead={lead}
// The label is grab surface too, not just the lead's grabber — same
// listeners, minus the controls that keep their own gestures. A project
// row has no rival drag (its title navigates on CLICK), so the sortable
@@ -138,36 +148,13 @@ export function ProjectOverviewRow({
dragHandleProps?.onPointerDown?.(event)
}}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<Tip label={s.projects.toggle(project.label, !open)}>
<button
aria-label={s.projects.toggle(project.label, !open)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
data-row-actions
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
</Tip>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
toggle={
preview.length > 0
? { ariaLabel: s.projects.toggle(project.label, !open), onToggle: toggleOpen, open }
: undefined
}
totals={{ costUsd: project.totalCostUsd ?? 0, tokens: project.totalTokens ?? 0 }}
/>
)
return (
@@ -1,19 +1,24 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ProfileGlyph } from '@/components/ui/profile-glyph'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { useStoreSelector } from '@/lib/use-session-slice'
import { setWorkspaceNodeOpen } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { newSessionInProfile, selectProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
import { $sessionProfilesUsage } from '@/store/session'
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
import { SidebarRowStack } from '../chrome'
import { SidebarLoadMoreRow } from '../load-more-row'
import { SidebarGroupRow, SidebarRowLead, SidebarRowLink, SidebarRowStack } from '../chrome'
import { rankSessions } from '../order'
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import { PROJECT_PREVIEW_COUNT, SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import type { SidebarSessionGroup } from './workspace-groups'
import {
WorkspaceAddButton,
@@ -36,6 +41,10 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
// Totals for the whole profile, not the loaded page — a selector so a refresh
// that leaves this profile's spend unchanged doesn't repaint its header.
const usage = useStoreSelector($sessionProfilesUsage, all => all[group.id])
const rankIds = useStore($sidebarSessionRankIds)
// Empty worktree/branch lanes start collapsed — they only show a "No sessions
// yet" placeholder, so defaulting them open just adds noise. Profile lanes and
// lanes that already hold sessions default open.
@@ -43,21 +52,18 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
const [open, toggleOpen] = useWorkspaceNodeOpen(group.id, defaultOpen)
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
const loadedCount = group.sessions.length
const visibleSessions = group.sessions.slice(0, visibleCount)
// Profile groups can have more rows on the server than are loaded — the
// aggregator reports `hasMore` so the lane can offer another page without
// pricing an exact total per refresh. Workspace groups only ever page within
// what's already loaded.
const hiddenLoaded = Math.max(0, loadedCount - visibleSessions.length)
const hiddenCount = isProfileGroup && group.hasMore ? Math.max(hiddenLoaded, 1) : hiddenLoaded
// A lane ranks by whatever the sort key says before it trims itself, so the
// rows it hides are the ones the sort ranked last.
const sessions = rankSessions(group.sessions, rankIds)
// A profile previews the same handful a project does, and clicking its label
// is how you see the rest. Workspace groups page within what's loaded.
const visibleSessions = sessions.slice(0, isProfileGroup ? PROJECT_PREVIEW_COUNT : visibleCount)
const hiddenCount = isProfileGroup ? 0 : sessions.length - visibleSessions.length
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
// Leading glyph: profile color dot, a home mark for the repo's primary
// checkout (labeled by its live branch), or a branch/kanban mark otherwise.
const leadingIcon = group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : (
// Leading glyph: a home mark for the repo's primary checkout (labeled by its
// live branch), a branch/kanban mark otherwise.
const leadingIcon = (
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={group.isKanban ? 'checklist' : group.isHome ? 'home' : 'git-branch'}
@@ -65,18 +71,6 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
/>
)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + SIDEBAR_GROUP_PAGE
setVisibleCount(target)
if (target > loadedCount && group.hasMore) {
group.onLoadMore?.()
}
}
const handleNewSession = async () => {
// Reveal the lane the new session targets — an empty worktree/branch lane
// starts collapsed, so without this the session lands in a folder the user
@@ -109,33 +103,67 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
onNewSession(group.path)
}
// Profile groups start a fresh session in that profile but keep the
// all-profiles browse view; workspace groups seed the new session's cwd.
// Main checkout lanes are branch-targeted.
const addButton = (onNewSession || isProfileGroup) && (
<WorkspaceAddButton label={s.newSessionIn(group.label)} onClick={() => void handleNewSession()} />
)
return (
<SidebarRowStack>
<WorkspaceContextMenu onRemove={onRemove} path={group.path}>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
label={s.newSessionIn(group.label)}
// Profile groups start a fresh session in that profile but keep
// the all-profiles browse view; workspace groups seed the new
// session's cwd. Main checkout lanes are branch-targeted.
onClick={() => void handleNewSession()}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
{isProfileGroup ? (
// A profile heads its sessions the way a project does, so it takes the
// project row's shape rather than the tree caption the lanes below use.
<SidebarGroupRow
actions={addButton}
// Clicking a profile scopes the sidebar to it, the way clicking a
// project enters that project. Capitalized to sit level with the
// project labels it alternates with (`Home`, and whatever the user
// named theirs) — profile keys are stored lowercase.
label={
<SidebarRowLink
aria-label={t.profiles.switchToProfile(group.label)}
labelClassName="capitalize hover:text-foreground hover:underline"
onClick={() => selectProfile(group.id)}
>
{group.label}
</SidebarRowLink>
}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ? displayPath(group.path) : undefined}
lead={
<SidebarRowLead>
{/* Fills the lead cell like a project's icon does: the glyph's own
16px would sit 2px proud of the 14px column. */}
<ProfileGlyph
className="size-full"
color={group.color ?? null}
isDefault={group.id === 'default'}
name={group.label}
/>
</SidebarRowLead>
}
toggle={{ ariaLabel: s.projects.toggle(group.label, !open), onToggle: toggleOpen, open }}
totals={{ costUsd: usage?.cost_usd ?? 0, tokens: usage?.tokens ?? 0 }}
/>
</WorkspaceContextMenu>
) : (
<WorkspaceContextMenu onRemove={onRemove} path={group.path}>
<WorkspaceHeader
action={
(onNewSession || onRemove) && (
<div className="flex items-center">
{addButton}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ? displayPath(group.path) : undefined}
/>
</WorkspaceContextMenu>
)}
{open && (
<>
{visibleSessions.length === 0 ? (
@@ -143,20 +171,13 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
) : (
renderRows(visibleSessions)
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
) : (
<WorkspaceShowMoreButton
count={nextCount}
label={group.label}
onClick={() => setVisibleCount(count => count + SIDEBAR_GROUP_PAGE)}
/>
))}
{hiddenCount > 0 && (
<WorkspaceShowMoreButton
count={nextCount}
label={group.label}
onClick={() => setVisibleCount(count => count + SIDEBAR_GROUP_PAGE)}
/>
)}
</>
)}
</SidebarRowStack>
@@ -855,11 +855,26 @@ describe('overlayLivePreviews', () => {
]
})
const previews = overlayLivePreviews([project], [], [], 3, new Set(['gone']))
const previews = overlayLivePreviews([project], [], [], 3, { removed: new Set(['gone']) })
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
})
it('ranks by the active sort key before trimming, so the preview is its top rows', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [
makeSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }),
makeSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }),
makeSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 })
]
})
const previews = overlayLivePreviews([project], [], [], 2, { rankIds: ['priciest', 'newest', 'cheap'] })
expect(previews['/www/app'].map(s => s.id)).toEqual(['priciest', 'newest'])
})
it('previews a detached session under Home, which no cwd could place', () => {
const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3)
@@ -2,6 +2,8 @@ import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/hermes'
import { normalize } from '@/lib/text'
import { rankSessions } from '../order'
// Session grouping is now computed authoritatively on the backend
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
// `projects.project_sessions`). The desktop is a thin renderer: this module
@@ -26,14 +28,8 @@ export interface SidebarSessionGroup {
// worktrees (`<repo>/.worktrees/t_*`) into one row, so a heavy board doesn't
// spray hundreds of throwaway branch lanes across the sidebar.
isKanban?: boolean
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
/** Profile lanes only: the backend page was capped, so more rows exist on
* disk than were loaded. Replaces the old exact `totalCount`, which cost a
* COUNT(*) per profile on every sidebar refresh just to render `n/total`. */
hasMore?: boolean
}
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */
@@ -62,6 +58,10 @@ export interface SidebarProjectTree {
isNoProject?: boolean
repos: SidebarWorkspaceTree[]
sessionCount: number
// Tokens and spend over the same sessions `sessionCount` counts, summed by
// the backend — the tree only carries a preview of the rows themselves.
totalTokens?: number
totalCostUsd?: number
// Max activity timestamp across the project's sessions (overview sort key).
lastActive?: number
// Up to N most-recent sessions for the overview preview (set by `projects.tree`).
@@ -700,13 +700,19 @@ export function overlayLiveLanes(
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
}
interface PreviewOverlayOptions {
removed?: ReadonlySet<string>
/** The active sort key as an id order; recency when empty. */
rankIds?: string[]
}
/** Merge live sessions into per-project overview previews, keyed by project id. */
export function overlayLivePreviews(
projects: SidebarProjectTree[],
live: SessionInfo[],
explicitProjects: ProjectInfo[],
limit: number,
removed: ReadonlySet<string> = new Set()
{ removed = NO_REMOVED, rankIds }: PreviewOverlayOptions = {}
): Record<string, SessionInfo[]> {
const byProject = new Map<string, SessionInfo[]>()
@@ -746,7 +752,9 @@ export function overlayLivePreviews(
}
}
out[node.id] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
const pool = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a))
out[node.id] = rankSessions(pool, rankIds).slice(0, limit)
}
return out
@@ -61,6 +61,13 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
// The last thing in the trailing slot hands its place to the ⋯ button on hover,
// and is never narrower than the button that has to cover it. A PR chip is the
// exception while the pointer is on it: it's a link, and the kebab sits
// absolute over this space, so it has to stop taking clicks too, not just fade.
const TAIL_HIDES = 'min-w-5 transition-opacity group-hover:opacity-0 group-has-[[data-pr-link]:hover]:opacity-100'
const KEBAB_YIELDS = 'group-has-[[data-pr-link]:hover]:pointer-events-none group-has-[[data-pr-link]:hover]:opacity-0'
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
const { unit, value } = coarseElapsed(Date.now() - seconds * 1000)
@@ -112,10 +119,9 @@ function SidebarSessionRowImpl({
const totalTokens = session.input_tokens + session.output_tokens
const cost = sessionCostUsd(session)
// Tokens, cost and age share the one trailing slot rather than each claiming
// their own column: several switched on read as one figure, not as a
// widening gutter.
const pinnedFacts = [
// Tokens, cost and age share one figure rather than each claiming a column:
// several switched on read as one number, not as a widening gutter.
const figures = [
rowMeta.includes('tokens') && totalTokens > 0 ? compactNumber(totalTokens) : null,
// Sub-cent spend rounds to "$0.00", which reads as a bug rather than as a
// cheap session — below a cent the row says nothing at all.
@@ -123,18 +129,43 @@ function SidebarSessionRowImpl({
pinnedAge ? age : null
].filter(Boolean) as string[]
// The kebab covers the END of the slot, so only the last fact steps aside for
// it. With tokens and age both on, hovering costs you the age and keeps the
// number you switched on to read.
const pinnedTail = pinnedFacts.at(-1) ?? ''
const pinnedHead = pinnedFacts.slice(0, -1).join(' · ')
const pinnedLabel = pinnedFacts.join(' · ')
// Chips that ride in the BODY, beside the title. The kebab lifts out of the
// actions slot, so it only ever covers what's in there — a body chip has no
// reason to step aside, and the hover-age (which does overlay the body) is
// dropped rather than made to fight them.
const bodyChip = Boolean(pr) || pinnedProfile || (showProfile && hasProfileTag)
const pinnedMeta = Boolean(pinnedLabel) || bodyChip
// Everything the Show menu puts after the title shares ONE right-aligned
// slot, in reading order: identity chips, then the figures. The kebab covers
// the END of that slot on hover, so only the last thing in it steps aside —
// with tokens and age both on you lose the age and keep the number you
// switched on, and a PR keeps its place (and its click) unless it IS the last
// thing. Chips used to render in the body instead, which left them stranded
// to the left of the kebab's own column: never flush right, never swapping.
const trailing: { key: string; node: React.ReactNode }[] = []
if ((showProfile || pinnedProfile) && hasProfileTag) {
trailing.push({ key: 'profile', node: <ProfileTag profile={session.profile} /> })
}
if (pr) {
trailing.push({ key: 'pr', node: <PrTag pr={pr} /> })
}
if (figures.length) {
const head = figures.slice(0, -1).join(' · ')
trailing.push({
key: 'figures',
node: (
<span className="pointer-events-none whitespace-nowrap text-[0.625rem] leading-none text-(--ui-text-tertiary)">
{head}
{/* The figures own their tail: the separator goes with it. */}
<span className={cn('inline-block text-right', TAIL_HIDES)}>
{head && ' · '}
{figures.at(-1)}
</span>
</span>
)
})
}
// A chip that ends the slot hides whole; the figures handle their own tail.
const chipEndsSlot = trailing.length > 0 && !figures.length
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
@@ -167,38 +198,23 @@ function SidebarSessionRowImpl({
>
<SidebarRowShell
actions={
// Pinned metadata sits in normal flow and the kebab lifts out of it,
// so this slot's intrinsic width IS the metadata's — the row's
// The trailing metadata sits in normal flow and the kebab lifts out
// of it, so this slot's intrinsic width IS the metadata's — the row's
// `auto` actions column measures it and the title truncates against
// whatever is switched on, with no width to hand-maintain.
<div className="relative z-2 flex items-center justify-end" data-row-actions>
{/* Pinned metadata stays put through a turn — it was switched on to
be read. Only the tail hands its slot to the kebab; anything
ahead of it stays legible while you hover. The hover-only age is
an overlay instead, so it needs the row to open up 48px of right
padding — which beside a body chip reads as a hole. A row that
already shows a chip skips it. */}
{(pinnedLabel || (!liveTurn && !bodyChip)) && (
// whatever is switched on, with no width to hand-maintain. Nothing
// switched on leaves the slot to the kebab alone; hover changes what
// you can see in it, never how wide it is.
<div className="relative z-2 flex items-center justify-end gap-1" data-row-actions>
{trailing.map(({ key, node }, index) => (
<span
className={cn(
'pointer-events-none whitespace-nowrap text-right text-[0.625rem] leading-none text-(--ui-text-tertiary)',
!pinnedLabel && 'absolute right-6 opacity-0 transition-opacity group-hover:opacity-100'
)}
className={
chipEndsSlot && index === trailing.length - 1 ? cn('inline-flex justify-end', TAIL_HIDES) : undefined
}
key={key}
>
{pinnedLabel ? (
<>
{pinnedHead}
{/* Never narrower than the kebab that has to cover it. */}
<span className="inline-block min-w-5 transition-opacity group-hover:opacity-0">
{pinnedHead && ' · '}
{pinnedTail}
</span>
</>
) : (
age
)}
{node}
</span>
)}
))}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
@@ -213,7 +229,8 @@ function SidebarSessionRowImpl({
aria-label={r.sessionActions}
className={cn(
'size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!',
pinnedLabel && 'absolute right-0'
trailing.length > 0 && 'absolute right-0',
pr && KEBAB_YIELDS
)}
size="icon"
variant="ghost"
@@ -266,10 +283,10 @@ function SidebarSessionRowImpl({
>
{showsRunningArc(dotState) && <span aria-hidden="true" className="arc-border arc-row" />}
<SidebarRowBody
// Pinned metadata already sits in the actions slot, so the title only
// needs a gap from it — and the same gap on hover, or the row would
// jump every time the kebab took over.
className={cn('z-0', pinnedMeta ? 'pr-2' : 'group-hover:pr-12', branchStem && 'pl-3.5')}
// Every trailing figure lives in the actions slot, which the row
// measures — so the title needs a gap from it and nothing else. Hover
// changes what you can see in that slot, never how wide it is.
className={cn('z-0 pr-2', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory).
{...middleClickHandlers(() => {
triggerHaptic('selection')
@@ -339,10 +356,6 @@ function SidebarSessionRowImpl({
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</SidebarRowLabel>
{/* Stays put on hover, unlike the other chips: it's a link, and the
kebab lives in its own column rather than over this one. */}
{pr && <PrTag pr={pr} />}
{(showProfile || pinnedProfile) && hasProfileTag && <ProfileTag profile={session.profile} />}
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>
@@ -143,7 +143,10 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
// just consume that context via useSortable.
return (
<div
className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)}
className={cn(
'scrollbar-fade relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain',
className
)}
ref={scrollerRef}
>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
@@ -38,7 +38,6 @@ function makeSidebarActions(): SidebarActions {
onBranchSession: vi.fn(),
onDeleteSession: vi.fn(),
onLoadMoreMessaging: vi.fn(),
onLoadMoreProfileSessions: vi.fn(),
onLoadMoreSessions: vi.fn(),
onManageCronJob: vi.fn(),
onNavigate: vi.fn(),
@@ -100,12 +99,10 @@ describe('latestActions adapters', () => {
const sidebar = makeSidebarActions()
sidebar.onLoadMoreMessaging = undefined
sidebar.onLoadMoreProfileSessions = undefined
const adaptedSidebar = latestSidebarActions(sidebar)
expect(adaptedSidebar.onLoadMoreMessaging).toBeUndefined()
expect(adaptedSidebar.onLoadMoreProfileSessions).toBeUndefined()
})
it('still late-binds a PRESENT optional handler to the latest closure', async () => {
@@ -61,7 +61,6 @@ export function latestSidebarActions(actions: SidebarActions): SidebarActions {
onBranchSession: (...args) => actions.onBranchSession(...args),
onDeleteSession: (...args) => actions.onDeleteSession(...args),
onLoadMoreMessaging: latestOptional(() => actions.onLoadMoreMessaging),
onLoadMoreProfileSessions: latestOptional(() => actions.onLoadMoreProfileSessions),
onLoadMoreSessions: (...args) => actions.onLoadMoreSessions(...args),
onManageCronJob: (...args) => actions.onManageCronJob(...args),
onNavigate: (...args) => actions.onNavigate(...args),
-1
View File
@@ -15,7 +15,6 @@ export type SidebarActions = Pick<
| 'onBranchSession'
| 'onDeleteSession'
| 'onLoadMoreMessaging'
| 'onLoadMoreProfileSessions'
| 'onLoadMoreSessions'
| 'onManageCronJob'
| 'onNavigate'
-2
View File
@@ -251,7 +251,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const {
loadMoreMessagingForPlatform,
loadMoreSessions,
loadMoreSessionsForProfile,
refreshCronJobs,
refreshMessagingSessions,
refreshSessions
@@ -879,7 +878,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onDismissError: dismissError,
onEdit: editMessage,
onLoadMoreMessaging: loadMoreMessagingForPlatform,
onLoadMoreProfileSessions: loadMoreSessionsForProfile,
onLoadMoreSessions: loadMoreSessions,
onManageCronJob: jobId => {
setCronFocusJobId(jobId)
@@ -32,6 +32,7 @@ import {
setMessagingSessions,
setMessagingTruncated,
setSessionProfilesTruncated,
setSessionProfilesUsage,
setSessions,
setSessionsLoading
} from '@/store/session'
@@ -223,6 +224,19 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
? prev
: next
})
// Same identity gate: these totals only move when a session bills, and
// a fresh object every refresh would repaint every profile header.
setSessionProfilesUsage(prev => {
const next = recents.profiles_usage ?? {}
const prevKeys = Object.keys(prev)
return prevKeys.length === Object.keys(next).length &&
prevKeys.every(
key => prev[key]?.tokens === next[key]?.tokens && prev[key]?.cost_usd === next[key]?.cost_usd
)
? prev
: next
})
// Cron section: latest N cron sessions (kept so a pinned cron run still
// resolves via sessionByAnyId), signature-gated like above.
@@ -282,37 +296,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
[refreshSessions]
)
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})
const keep = sessionsToKeep(key)
setSessions(prev => [
...prev.filter(s => !inKey(s)),
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
])
// 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 }))
}, [])
return {
loadMoreMessagingForPlatform,
loadMoreSessions,
loadMoreSessionsForProfile,
refreshCronJobs,
refreshMessagingSessions,
refreshSessions
+1 -1
View File
@@ -341,7 +341,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-hidden',
'scrollbar-fade flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
data-sidebar="content"
+3
View File
@@ -462,6 +462,9 @@ export interface SidebarSessionSlice {
/** Per-profile "the window came back full, more rows exist on disk" flags —
* what pagination needs, without a COUNT(*) per profile DB per refresh. */
profiles_truncated?: Record<string, boolean>
/** Per-profile tokens and spend over every session, not just this window.
* Absent from the legacy per-slice endpoint, which has no aggregate. */
profiles_usage?: Record<string, { cost_usd: number; tokens: number }>
}
/** Which profiles filled their per-profile window in a returned page. The
+2
View File
@@ -17,6 +17,7 @@ import {
setMessagingTruncated,
setSelectedStoredSessionId,
setSessionProfilesTruncated,
setSessionProfilesUsage,
setSessions,
setSessionsLoading
} from '@/store/session'
@@ -50,6 +51,7 @@ export function wipeSessionListsForGatewaySwitch(): void {
resetSessionPinMirror()
setSessions([])
setSessionProfilesTruncated({})
setSessionProfilesUsage({})
setCronSessions([])
setMessagingSessions([])
setMessagingPlatformTotals({})
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$sidebarGrouping,
$sidebarOrdering,
$sidebarRowMeta,
$sidebarViewCustomized,
resetSidebarView,
setSidebarGrouping,
setSidebarOrdering,
toggleSidebarRowMeta,
toggleSidebarStatusFilter
} from './layout'
import { $showAllProfiles } from './profile'
beforeEach(() => {
$showAllProfiles.set(false)
resetSidebarView()
})
describe('the sidebar as it ships', () => {
it('groups by date, sorts by recency, and pins the timestamp', () => {
expect($sidebarGrouping.get()).toBe('date')
expect($sidebarOrdering.get()).toBe('updated')
expect($sidebarRowMeta.get()).toEqual(['updated'])
})
it('offers no reset until something actually moves off the defaults', () => {
expect($sidebarViewCustomized.get()).toBe(false)
toggleSidebarRowMeta('tokens')
expect($sidebarViewCustomized.get()).toBe(true)
})
it('is what reset puts back — every knob, not just the filters', () => {
setSidebarGrouping('project')
setSidebarOrdering('cost')
toggleSidebarRowMeta('updated')
toggleSidebarRowMeta('cost')
toggleSidebarStatusFilter('working')
resetSidebarView()
expect($sidebarGrouping.get()).toBe('date')
expect($sidebarOrdering.get()).toBe('updated')
expect($sidebarRowMeta.get()).toEqual(['updated'])
expect($sidebarViewCustomized.get()).toBe(false)
})
it('ships by date in the all-profiles scope too, and resets back to it', () => {
$showAllProfiles.set(true)
setSidebarGrouping('profile')
resetSidebarView()
expect($sidebarGrouping.get()).toBe('date')
expect($sidebarViewCustomized.get()).toBe(false)
})
it('resets the scope the user is not looking at, so flipping the rail cannot restore it', () => {
setSidebarGrouping('status')
$showAllProfiles.set(true)
setSidebarGrouping('profile')
resetSidebarView()
$showAllProfiles.set(false)
expect($sidebarGrouping.get()).toBe('date')
})
it('turns all-profiles on when the user groups by profile, since that is the ask', () => {
setSidebarGrouping('profile')
expect($showAllProfiles.get()).toBe(true)
expect($sidebarGrouping.get()).toBe('profile')
})
})
+85 -16
View File
@@ -8,6 +8,7 @@ import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage'
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
import { $showAllProfiles, setShowAllProfiles } from './profile'
import type { PullRequestBucket } from './pull-requests'
import type { SessionStatusBucket } from './session-dot-state'
@@ -33,11 +34,13 @@ const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY = 'hermes.desktop.sessionOrder.manual'
const SIDEBAR_GROUPING_STORAGE_KEY = 'hermes.desktop.sidebarGrouping'
const SIDEBAR_ALL_PROFILES_GROUPING_STORAGE_KEY = 'hermes.desktop.sidebarGrouping.allProfiles'
const SIDEBAR_SORT_KEY_STORAGE_KEY = 'hermes.desktop.sidebarSortKey'
const SIDEBAR_ROW_META_STORAGE_KEY = 'hermes.desktop.sidebarRowMeta'
const SIDEBAR_STATUS_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarStatusFilter'
const SIDEBAR_SHOW_ARCHIVED_STORAGE_KEY = 'hermes.desktop.sidebarShowArchived'
const SIDEBAR_PROJECT_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarProjectFilter'
const SIDEBAR_PROFILE_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarProfileFilter'
const SIDEBAR_PR_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarPrFilter'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder'
@@ -201,8 +204,9 @@ export const $sidebarMessagingOpenIds = persistentAtom(
export const $sidebarAgentsGrouped = persistentAtom(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false, Codecs.bool)
/** How the recents list is divided. `date` is the sidebar's long-standing
* default (Today / Yesterday / Last week dividers). */
export type SidebarGrouping = 'date' | 'project' | 'status'
* default (Today / Yesterday / Last week dividers). `profile` only means
* anything while the sidebar is showing every profile at once. */
export type SidebarGrouping = 'date' | 'profile' | 'project' | 'status'
/** What ranks rows within whatever grouping is active. */
export type SidebarOrdering = 'cost' | 'created' | 'manual' | 'status' | 'tokens' | 'updated'
/** The sort keys the menu offers; `manual` is entered by dragging, not picked. */
@@ -239,13 +243,40 @@ const $sidebarFlatGrouping = persistentAtom<SidebarGrouping>(
oneOf(['date', 'status'], 'date')
)
// All-profiles keeps its own grouping: `profile` only means anything there, and
// a shared atom would either drag it into a scope where it means nothing or
// reset the choice every time the user flips the rail. Both scopes still ship
// by day — grouping by owner is something you go and pick.
const $sidebarAllProfilesGrouping = persistentAtom<SidebarGrouping>(
SIDEBAR_ALL_PROFILES_GROUPING_STORAGE_KEY,
'date',
oneOf(['date', 'profile', 'status'], 'date')
)
// The sidebar as it ships. Declared once so the atoms below, "Reset to
// defaults" and the "has this view been customized?" check can't drift apart —
// they used to inline the same literals in three places.
const SIDEBAR_DEFAULT_GROUPING: SidebarGrouping = 'date'
const SIDEBAR_DEFAULT_ORDERING: SidebarOrdering = 'updated'
const SIDEBAR_DEFAULT_ROW_META: SidebarRowMeta[] = ['updated']
const $sidebarSortKey = persistentAtom<SidebarSortKey>(
SIDEBAR_SORT_KEY_STORAGE_KEY,
'updated',
oneOf(SIDEBAR_SORT_KEYS, 'updated')
)
export const $sidebarRowMeta = persistentAtom<SidebarRowMeta[]>(SIDEBAR_ROW_META_STORAGE_KEY, [], listOf(ROW_META))
export const $sidebarRowMeta = persistentAtom<SidebarRowMeta[]>(
SIDEBAR_ROW_META_STORAGE_KEY,
SIDEBAR_DEFAULT_ROW_META,
listOf(ROW_META)
)
/** Order-insensitive: the menu appends in click order, so ['tokens','updated']
* and ['updated','tokens'] are the same view. */
function sameRowMeta(a: SidebarRowMeta[], b: SidebarRowMeta[]): boolean {
return a.length === b.length && a.every(id => b.includes(id))
}
// Archived sessions are a separate backend query (`archived: 'only'`), so this
// flag both filters the list and drives the fetch.
@@ -265,6 +296,14 @@ export const $sidebarProjectFilter = persistentAtom(
Codecs.stringArray
)
// Profile names, as `normalizeProfileKey` reports them. Only bites while the
// sidebar is showing every profile — scoped to one, the scope is the filter.
export const $sidebarProfileFilter = persistentAtom(
SIDEBAR_PROFILE_FILTER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Whether a session's branch has a PR, and in what state. Fetched per repo via
// `gh` (see store/pull-requests), so this is empty on backends without a local
// checkout — the menu hides the submenu rather than offering a dead filter.
@@ -275,8 +314,8 @@ export const $sidebarPrFilter = persistentAtom<PullRequestBucket[]>(
)
export const $sidebarGrouping: ReadableAtom<SidebarGrouping> = computed(
[$sidebarAgentsGrouped, $sidebarFlatGrouping],
(grouped, grouping) => (grouped ? 'project' : grouping)
[$sidebarAgentsGrouped, $sidebarFlatGrouping, $sidebarAllProfilesGrouping, $showAllProfiles],
(grouped, flat, allProfiles, showAll) => (grouped ? 'project' : showAll ? allProfiles : flat)
)
// A hand-dragged order outranks any sort key — dragging IS how you pick manual,
@@ -287,8 +326,9 @@ export const $sidebarOrdering: ReadableAtom<SidebarOrdering> = computed(
)
export const $sidebarFiltersActive: ReadableAtom<boolean> = computed(
[$sidebarStatusFilter, $sidebarProjectFilter, $sidebarPrFilter, $sidebarShowArchived],
(statuses, projects, prs, archived) => statuses.length > 0 || projects.length > 0 || prs.length > 0 || archived
[$sidebarStatusFilter, $sidebarProjectFilter, $sidebarProfileFilter, $sidebarPrFilter, $sidebarShowArchived],
(statuses, projects, profiles, prs, archived) =>
statuses.length > 0 || projects.length > 0 || profiles.length > 0 || prs.length > 0 || archived
)
/** Anything at all moved off the shipped view — what makes a reset worth
@@ -297,7 +337,10 @@ export const $sidebarFiltersActive: ReadableAtom<boolean> = computed(
export const $sidebarViewCustomized: ReadableAtom<boolean> = computed(
[$sidebarGrouping, $sidebarOrdering, $sidebarRowMeta, $sidebarFiltersActive],
(grouping, ordering, rowMeta, filtersActive) =>
grouping !== 'date' || ordering !== 'updated' || rowMeta.length > 0 || filtersActive
grouping !== SIDEBAR_DEFAULT_GROUPING ||
ordering !== SIDEBAR_DEFAULT_ORDERING ||
!sameRowMeta(rowMeta, SIDEBAR_DEFAULT_ROW_META) ||
filtersActive
)
// When true, the sessions sidebar moves to the right and the file browser +
@@ -494,9 +537,27 @@ export function setSidebarAgentsGrouped(grouped: boolean) {
export function setSidebarGrouping(grouping: SidebarGrouping) {
setSidebarAgentsGrouped(grouping === 'project')
if (grouping !== 'project') {
$sidebarFlatGrouping.set(grouping)
if (grouping === 'project') {
return
}
// Grouping by owner is a request to see every owner, so it turns the
// all-profiles view on rather than drawing one group around one profile.
// (The flat scope's atom can't hold 'profile' at all.)
if (grouping === 'profile') {
setShowAllProfiles(true)
$sidebarAllProfilesGrouping.set(grouping)
return
}
if ($showAllProfiles.get()) {
$sidebarAllProfilesGrouping.set(grouping)
return
}
$sidebarFlatGrouping.set(grouping)
}
export function setSidebarOrdering(ordering: SidebarOrdering) {
@@ -535,6 +596,10 @@ export function toggleSidebarProjectFilter(projectId: string) {
toggleIn($sidebarProjectFilter, projectId)
}
export function toggleSidebarProfileFilter(profile: string) {
toggleIn($sidebarProfileFilter, profile)
}
export function toggleSidebarPrFilter(bucket: PullRequestBucket) {
toggleIn($sidebarPrFilter, bucket)
}
@@ -542,17 +607,21 @@ export function toggleSidebarPrFilter(bucket: PullRequestBucket) {
function clearSidebarFilters() {
$sidebarStatusFilter.set([])
$sidebarProjectFilter.set([])
$sidebarProfileFilter.set([])
$sidebarPrFilter.set([])
$sidebarShowArchived.set(false)
}
/** Every knob the filter menu owns, back to the sidebar as it ships: date
* groups, newest first, no extra row metadata, no filters. Ordering goes
* through its setter so a hand-dragged sequence is dropped along with it. */
/** Every knob the filter menu owns, back to the sidebar as it ships. Ordering
* goes through its setter so a hand-dragged sequence is dropped along with it. */
export function resetSidebarView() {
setSidebarGrouping('date')
setSidebarOrdering('updated')
$sidebarRowMeta.set([])
setSidebarGrouping(SIDEBAR_DEFAULT_GROUPING)
// Both scopes, not just the one on screen: each keeps its own grouping, so a
// reset that left the other customized would hand it back on the next flip.
$sidebarFlatGrouping.set(SIDEBAR_DEFAULT_GROUPING)
$sidebarAllProfilesGrouping.set(SIDEBAR_DEFAULT_GROUPING)
setSidebarOrdering(SIDEBAR_DEFAULT_ORDERING)
$sidebarRowMeta.set(SIDEBAR_DEFAULT_ROW_META)
clearSidebarFilters()
}
+69 -20
View File
@@ -16,7 +16,7 @@ import { persistentAtom } from '@/lib/persisted'
import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
import { setSidebarAgentsGrouped } from '@/store/layout'
import { notify } from '@/store/notifications'
import { $activeGatewayProfile, requestFreshSession } from '@/store/profile'
import { $activeGatewayProfile, $profileScope, ALL_PROFILES, requestFreshSession } from '@/store/profile'
import {
$selectedStoredSessionId,
$sessions,
@@ -392,8 +392,33 @@ interface ProjectTreePayload {
scoped_session_ids: string[]
}
const PROJECT_TREE_PREVIEW_LIMIT = 3
// The all-profiles fan-out reads one database per profile, so it is allowed the
// same headroom as the cross-profile session list rather than the interactive
// default.
const PROJECT_TREE_REQUEST_TIMEOUT_MS = 60_000
let projectTreeRefreshGeneration = 0
function applyProjectTreePayload(res: ProjectTreePayload): void {
const scoped = new Set(res.scoped_session_ids ?? [])
$projectTree.set(res.projects ?? [])
$activeProjectId.set(res.active_id ?? null)
const tombstones = $removedSessionIds.get()
if (tombstones.size) {
// Keep a tombstone while the backend still lists the id (delete pending on
// its side) OR while its mutation is still in flight locally — dropping it
// early flashes the row back until the RPC lands.
const inFlight = $sessionMutationsInFlight.get()
const pending = new Set([...tombstones].filter(id => scoped.has(id) || inFlight.has(id)))
if (pending.size !== tombstones.size) {
$removedSessionIds.set(pending)
}
}
}
async function refreshProjectTreeOn(gateway: HermesGateway): Promise<void> {
const generation = ++projectTreeRefreshGeneration
@@ -403,30 +428,14 @@ async function refreshProjectTreeOn(gateway: HermesGateway): Promise<void> {
try {
const res = await gatewayRequestOn<ProjectTreePayload>(gateway, 'projects.tree', {
preview_limit: 3
preview_limit: PROJECT_TREE_PREVIEW_LIMIT
})
if (generation !== projectTreeRefreshGeneration || activeGateway() !== gateway) {
return
}
const scoped = new Set(res.scoped_session_ids ?? [])
$projectTree.set(res.projects ?? [])
$activeProjectId.set(res.active_id ?? null)
const tombstones = $removedSessionIds.get()
if (tombstones.size) {
// Keep a tombstone while the backend still lists the id (delete pending on
// its side) OR while its mutation is still in flight locally — dropping it
// early flashes the row back until the RPC lands.
const inFlight = $sessionMutationsInFlight.get()
const pending = new Set([...tombstones].filter(id => scoped.has(id) || inFlight.has(id)))
if (pending.size !== tombstones.size) {
$removedSessionIds.set(pending)
}
}
applyProjectTreePayload(res)
markProjectsRpcSuccess()
} catch (err) {
if (activeGateway() === gateway) {
@@ -443,6 +452,12 @@ async function refreshProjectTreeOn(gateway: HermesGateway): Promise<void> {
// sessions + the scoped-session-id set). Best-effort: a failure leaves the
// cached tree intact so the sidebar doesn't flicker.
export async function refreshProjectTree(): Promise<void> {
if ($profileScope.get() === ALL_PROFILES) {
await refreshProjectTreeAcrossProfiles()
return
}
try {
const { gateway } = await activeProjectsContext()
await refreshProjectTreeOn(gateway)
@@ -451,6 +466,37 @@ export async function refreshProjectTree(): Promise<void> {
}
}
// The grouped sidebar in all-profiles mode. `projects.tree` answers for one
// backend's own profile, so it can only ever describe a slice of this view;
// the REST fan-out reads every profile's databases directly instead of asking
// us to hold a backend open per profile just to draw lanes.
async function refreshProjectTreeAcrossProfiles(): Promise<void> {
const generation = ++projectTreeRefreshGeneration
$projectTreeLoading.set(true)
try {
const res = await window.hermesDesktop.api<ProjectTreePayload>({
path: `/api/profiles/projects/tree?preview_limit=${PROJECT_TREE_PREVIEW_LIMIT}`,
timeoutMs: PROJECT_TREE_REQUEST_TIMEOUT_MS
})
// A profile switch mid-flight leaves this payload describing the wrong
// scope; the newer refresh owns the tree.
if (generation !== projectTreeRefreshGeneration || $profileScope.get() !== ALL_PROFILES) {
return
}
applyProjectTreePayload(res)
markProjectsRpcSuccess()
} catch (err) {
markProjectsRpcFailure(err)
} finally {
if (generation === projectTreeRefreshGeneration) {
$projectTreeLoading.set(false)
}
}
}
// Fully hydrated lanes (repo -> lane -> session rows) for one project, fetched
// when the user enters it. Same backend grouping as `projects.tree`, so ids and
// membership match exactly.
@@ -617,7 +663,10 @@ export async function scanAndRecordRepos(force = false): Promise<void> {
}
state.completedSignature = signature
await refreshProjectTreeOn(context.gateway)
// Scope-aware on purpose: the scan records into one profile, but folding
// its result back in through the active scope keeps an all-profiles tree
// from being overwritten by the scanned profile's own.
await refreshProjectTree()
} catch {
state.completedSignature = undefined
} finally {
@@ -7,6 +7,9 @@ const patch = vi.fn<(id: string, pinned: boolean, profile?: null | string) => Pr
)
vi.mock('@/hermes', () => ({
// The layout store reaches the profile store, which sets the request profile
// at import time; this suite only cares about the pin call.
setApiRequestProfile: () => {},
setSessionPinnedRemote: (id: string, pinned: boolean, profile?: null | string) => patch(id, pinned, profile)
}))
+12
View File
@@ -482,6 +482,16 @@ export const $messagingTruncated = atom<boolean>(false)
// "is there another page?" is what pagination actually needs and comes free
// from the row count the query already returned.
export const $sessionProfilesTruncated = atom<Record<string, boolean>>({})
/** Tokens and spend per profile across ALL its sessions, not just the loaded
* page — summed in SQL so a profile group's header total doesn't move when the
* window does. Keyed by profile name. */
export interface ProfileUsage {
cost_usd: number
tokens: number
}
export const $sessionProfilesUsage = atom<Record<string, ProfileUsage>>({})
export const $sessionsLoading = atom(true)
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
@@ -594,6 +604,8 @@ export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>
export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
export const setSessionProfilesTruncated = (next: Updater<Record<string, boolean>>) =>
updateAtom($sessionProfilesTruncated, next)
export const setSessionProfilesUsage = (next: Updater<Record<string, ProfileUsage>>) =>
updateAtom($sessionProfilesUsage, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredIdRotation = (next: Updater<ActiveSessionStoredIdRotation | null>) =>
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/hermes'
import { resetSidebarView, setSidebarOrdering } from './layout'
import { $sessions } from './session'
import { $sidebarSessionRankIds } from './sidebar-sort'
const session = (id: string, fields: Partial<SessionInfo>) =>
({ id, input_tokens: 0, output_tokens: 0, started_at: 0, ...fields }) as SessionInfo
beforeEach(() => {
resetSidebarView()
$sessions.set([])
})
describe('$sidebarSessionRankIds', () => {
it('ranks the priciest session first', () => {
$sessions.set([
session('cheap', { actual_cost_usd: 0.01 }),
session('dear', { actual_cost_usd: 2 }),
session('estimated', { estimated_cost_usd: 0.5 })
])
setSidebarOrdering('cost')
expect($sidebarSessionRankIds.get()).toEqual(['dear', 'estimated', 'cheap'])
})
it('ranks by total tokens, both halves counted', () => {
$sessions.set([
session('small', { input_tokens: 10, output_tokens: 10 }),
session('big', { input_tokens: 1, output_tokens: 500 })
])
setSidebarOrdering('tokens')
expect($sidebarSessionRankIds.get()).toEqual(['big', 'small'])
})
it('ranks by creation, newest first — the sidebar orders by recency elsewhere', () => {
$sessions.set([session('older', { started_at: 1 }), session('newer', { started_at: 9 })])
setSidebarOrdering('created')
expect($sidebarSessionRankIds.get()).toEqual(['newer', 'older'])
})
it('leaves the default view unranked, and hands back the same array each time', () => {
$sessions.set([session('a', { actual_cost_usd: 1 }), session('b', { actual_cost_usd: 2 })])
const first = $sidebarSessionRankIds.get()
$sessions.set([session('c', { actual_cost_usd: 3 })])
expect(first).toEqual([])
// Reference-stable, so the default sidebar never repaints on a rank it isn't using.
expect($sidebarSessionRankIds.get()).toBe(first)
})
it('drops the ranking when a hand-dragged order takes over', () => {
$sessions.set([session('a', { actual_cost_usd: 1 }), session('b', { actual_cost_usd: 2 })])
setSidebarOrdering('cost')
setSidebarOrdering('manual')
expect($sidebarSessionRankIds.get()).toEqual([])
})
})
+62
View File
@@ -0,0 +1,62 @@
import { computed, type ReadableAtom } from 'nanostores'
import type { SessionInfo } from '@/hermes'
import { $sidebarOrdering, type SidebarOrdering } from './layout'
import { $sessions } from './session'
import { $sessionDotStateById, type SessionDotState, sessionStatusRank } from './session-dot-state'
import { sessionCostUsd } from './sidebar-archive'
// Same array on every recompute, so the default (unranked) sidebar never churns
// its subscribers.
const UNRANKED: string[] = []
function rankBy(
ordering: SidebarOrdering,
dotStates: Record<string, SessionDotState>
): null | ((session: SessionInfo) => number) {
switch (ordering) {
case 'cost':
return session => -sessionCostUsd(session)
case 'created':
return session => -session.started_at
case 'status':
return session => sessionStatusRank(dotStates[session.id])
case 'tokens':
return session => -(session.input_tokens + session.output_tokens)
default:
return null
}
}
/**
* The active sort key as a plain id order — the one ranking every sidebar
* surface reads.
*
* The sort key used to be applied where the flat list is assembled, so it did
* nothing at all once rows moved into groups: picking "cost" while grouped by
* project or profile left every lane in the order the backend sent it. Ranking
* lives here instead, above any one view, and each surface applies it to the
* rows it owns — the flat list within its date dividers, a group within its
* lane (and before it trims itself to a preview, so the rows it drops are the
* ones the sort key ranked last).
*
* Empty for `updated` and `manual`: recency is the order sessions already
* arrive in, and a hand-dragged sequence is the flat list's own business.
*/
export const $sidebarSessionRankIds: ReadableAtom<string[]> = computed(
[$sidebarOrdering, $sessions, $sessionDotStateById],
(ordering, sessions, dotStates) => {
const rank = rankBy(ordering, dotStates)
if (!rank) {
return UNRANKED
}
return [...sessions].sort((a, b) => rank(a) - rank(b)).map(session => session.id)
}
)
+29
View File
@@ -1144,6 +1144,26 @@ code {
display: none;
}
/* Hover-only bars. A thumb sitting on a list you aren't touching is chrome,
not information — the gutter is reserved either way, so fading it in and
out costs no reflow. Opt-in per surface: the sidebar wants it, a document
you're reading down wants its bar where you can see it. */
.scrollbar-fade::-webkit-scrollbar-thumb,
.scrollbar-dt .scrollbar-fade::-webkit-scrollbar-thumb {
background-color: transparent;
transition: background-color 150ms ease-out;
}
.scrollbar-fade:hover::-webkit-scrollbar-thumb,
.scrollbar-dt .scrollbar-fade:hover::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--dt-midground) 18%, transparent);
}
.scrollbar-fade:hover::-webkit-scrollbar-thumb:hover,
.scrollbar-dt .scrollbar-fade:hover::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--dt-midground) 40%, transparent);
}
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
render under document.body, outside the `.scrollbar-dt` scope on
#root. Same visual treatment, applied directly to the overlay
@@ -1191,6 +1211,15 @@ code {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
}
/* No transition — Firefox doesn't animate scrollbar-color. */
.scrollbar-fade {
scrollbar-color: transparent transparent;
}
.scrollbar-fade:hover {
scrollbar-color: color-mix(in srgb, var(--dt-midground) 18%, transparent) transparent;
}
}
}
+179 -19
View File
@@ -241,13 +241,18 @@ def get_profiles_sessions_sidebar(
"""Batched sidebar session slices — one profile-DB open per refresh.
The desktop sidebar needs three source-scoped windows per refresh: recents
(local chats, scoped to the active profile), cron sessions (all profiles),
and messaging-platform sessions (all profiles). Served as three separate
``/api/profiles/sessions`` calls they reopened every profile's ``state.db``
three times and re-counted each refresh. This opens each DB once and runs
the three filtered queries together, returning the three windows in one
payload. Read-only and process-light, same row projection and 300s active
heuristic as ``/api/profiles/sessions``.
(local chats), cron sessions, and messaging-platform sessions. Served as
three separate ``/api/profiles/sessions`` calls they reopened every
profile's ``state.db`` three times and re-counted each refresh. This opens
each DB once and runs the three filtered queries together, returning the
three windows in one payload. Read-only and process-light, same row
projection and 300s active heuristic as ``/api/profiles/sessions``.
``recents_profile`` scopes the whole payload, not just recents. Cron and
messaging used to come back cross-profile unconditionally, which is what
made a concrete profile show another profile's Telegram threads and
cronjobs (#65710, #42651, #70629) — the sidebar has one scope, so every
slice answers to it, and ``all`` is how the caller asks for everything.
The caller passes the source taxonomy (``recents_exclude`` /
``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays
@@ -257,8 +262,6 @@ def get_profiles_sessions_sidebar(
"""
from hermes_cli import profiles as profiles_mod
# cron + messaging are cross-profile; recents is scoped to recents_profile.
# Scan every profile once regardless (each DB opened a single time).
try:
infos = profiles_mod.list_profiles()
targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos]
@@ -280,6 +283,7 @@ def get_profiles_sessions_sidebar(
cron_rows: List[Dict[str, Any]] = []
messaging_rows: List[Dict[str, Any]] = []
recents_truncated: Dict[str, bool] = {}
profile_totals: Dict[str, Dict[str, float]] = {}
errors: List[Dict[str, str]] = []
now = time.time()
@@ -314,6 +318,8 @@ def get_profiles_sessions_sidebar(
)
for name, home in targets:
if recents_scope != "all" and name != recents_scope:
continue
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
@@ -327,16 +333,19 @@ def get_profiles_sessions_sidebar(
errors.append({"profile": name, "error": str(exc)})
continue
try:
if recents_scope == "all" or name == recents_scope:
profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap)
# A full window means more rows remain on disk. That is all the
# sidebar's "load more" needs, and unlike an exact COUNT(*) per
# profile per refresh it costs nothing beyond the rows already
# read. Discount pinned back-fills — they arrive past the LIMIT
# and would otherwise fake a full page on a short list.
unpinned_count = sum(1 for s in profile_rows if not s.get("pinned"))
recents_truncated[name] = unpinned_count >= recents_cap
recents_rows.extend(_tag(profile_rows, name))
profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap)
# A full window means more rows remain on disk. That is all the
# sidebar's "load more" needs, and unlike an exact COUNT(*) per
# profile per refresh it costs nothing beyond the rows already
# read. Discount pinned back-fills — they arrive past the LIMIT
# and would otherwise fake a full page on a short list.
unpinned_count = sum(1 for s in profile_rows if not s.get("pinned"))
recents_truncated[name] = unpinned_count >= recents_cap
recents_rows.extend(_tag(profile_rows, name))
# Aggregated in SQL rather than over the window above: the window is
# a page, and a total that shrank when you scrolled would be worse
# than no total at all.
profile_totals[name] = db.usage_totals()
cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name))
messaging_rows.extend(
_tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name)
@@ -363,6 +372,7 @@ def get_profiles_sessions_sidebar(
"recents": {
"sessions": _window(recents_rows, recents_cap),
"profiles_truncated": recents_truncated,
"profiles_usage": profile_totals,
},
"cron": {"sessions": _window(cron_rows, cron_cap)},
"messaging": {
@@ -373,6 +383,156 @@ def get_profiles_sessions_sidebar(
}
def _merge_by_id(into: Dict[str, Dict[str, Any]], entries: List[Dict[str, Any]], child_key: str) -> None:
"""Fold ``entries`` into ``into`` by id, recursing through one child list.
Repos merge their lanes, lanes merge their sessions. Counts add up and the
newest activity wins; everything else is first-writer, since the entries
describe the same path either way.
"""
for entry in entries:
existing = into.get(entry["id"])
if existing is None:
into[entry["id"]] = entry
continue
if child_key == "sessions":
existing["sessions"].extend(entry.get("sessions") or [])
else:
children: Dict[str, Dict[str, Any]] = {c["id"]: c for c in existing.get(child_key) or []}
_merge_by_id(children, entry.get(child_key) or [], "sessions")
existing[child_key] = list(children.values())
if "sessionCount" in existing:
existing["sessionCount"] = (existing.get("sessionCount") or 0) + (entry.get("sessionCount") or 0)
def _merge_profile_tree(
merged: Dict[str, Dict[str, Any]],
projects: List[Dict[str, Any]],
profile: str,
preview_limit: int,
) -> None:
"""Fold one profile's projects into the shared tree, keyed by folder.
The same checkout in two profiles is one group, as is ``__no_project__``,
which every profile has and which would otherwise put a "Home" on screen per
profile. Keying on the path rather than the id also folds a profile's
declared project (``p_<hash>``) together with the auto entry another profile
grows for the same folder. Sessions carry the owning profile instead, which
is what the row badge and the profile filter read; a group header never
claims a single owner.
"""
for project in projects:
for lane in (repo for r in project.get("repos") or [] for repo in r.get("groups") or []):
for session in lane.get("sessions") or []:
session["profile"] = profile
session["is_default_profile"] = profile == "default"
for session in project.get("previewSessions") or []:
session["profile"] = profile
session["is_default_profile"] = profile == "default"
key = project.get("path") or project["id"]
existing = merged.get(key)
if existing is None:
merged[key] = project
continue
# A declared project carries the label, color and icon the user chose,
# so it wins the identity when it meets another profile's auto entry.
if existing.get("isAuto") and not project.get("isAuto"):
existing, project = project, existing
merged[key] = existing
repos: Dict[str, Dict[str, Any]] = {r["id"]: r for r in existing.get("repos") or []}
_merge_by_id(repos, project.get("repos") or [], "groups")
existing["repos"] = list(repos.values())
existing["sessionCount"] = (existing.get("sessionCount") or 0) + (project.get("sessionCount") or 0)
existing["totalTokens"] = (existing.get("totalTokens") or 0) + (project.get("totalTokens") or 0)
existing["totalCostUsd"] = (existing.get("totalCostUsd") or 0) + (project.get("totalCostUsd") or 0)
existing["lastActive"] = max(existing.get("lastActive") or 0, project.get("lastActive") or 0)
previews = (existing.get("previewSessions") or []) + (project.get("previewSessions") or [])
previews.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True)
existing["previewSessions"] = previews[:preview_limit]
@sessions_router.get("/api/profiles/projects/tree")
def get_profiles_projects_tree(preview_limit: int = 3, session_limit: int = 2000):
"""Project tree for every profile at once, for the all-profiles sidebar.
``projects.tree`` over JSON-RPC answers for the backend's own profile, so
the grouped sidebar had nothing to draw once the user asked for all of
them. This runs the same authoritative builder once per profile against
that profile's ``state.db``, scoping the rest of its inputs — projects.db,
the repo-scan policy, the HERMES_HOME junk filters — through the
context-local home override the profile-scoped writers already use.
Projects merge by id across profiles, so a group stands for a checkout
rather than a checkout-and-owner, and the profile shows up per row where
the filter can act on it.
Discovery is off. A repo with zero sessions is the same repo in every
profile, so folding the disk scan in would multiply empty lanes by the
profile count — and it is the one part of the builder that writes
(policy reconciliation), which this read-only fan-out should not do to a
profile the user is not driving.
"""
from hermes_cli import profiles as profiles_mod
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tui_gateway import server as gateway_server
try:
targets: List[Tuple[str, Path]] = [
(info.name, info.path) for info in profiles_mod.list_profiles()
]
except Exception:
_log.exception("GET /api/profiles/projects/tree: list_profiles failed")
targets = []
if not targets:
targets.append(("default", profiles_mod.get_profile_dir("default")))
merged: Dict[str, Dict[str, Any]] = {}
scoped_session_ids: List[str] = []
errors: List[Dict[str, str]] = []
for name, home in targets:
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
try:
db = _open_session_db_at_path(db_path, read_only=True)
except Exception as exc:
_warn_profile_read_error(name, exc)
errors.append({"profile": name, "error": str(exc)})
continue
token = set_hermes_home_override(str(home))
try:
tree, _active_id = gateway_server._build_project_tree(
db,
preview_limit=preview_limit,
hydrate=False,
session_limit=session_limit,
include_discovered=False,
)
_merge_profile_tree(merged, tree["projects"], name, preview_limit)
scoped_session_ids.extend(tree["scoped_session_ids"])
except Exception as exc:
_warn_profile_read_error(name, exc)
errors.append({"profile": name, "error": str(exc)})
finally:
reset_hermes_home_override(token)
db.close()
projects = sorted(merged.values(), key=lambda p: p.get("lastActive") or 0, reverse=True)
return {
"projects": projects,
# Ownership is per profile, so no single project is "the active one"
# here; the desktop only reads active_id to bias its overview sort.
"active_id": None,
"scoped_session_ids": scoped_session_ids,
"errors": errors,
}
# `gh pr create` prints the PR url and nothing else, so a tool result whose
# whole output IS a PR url means this session opened that PR. Anything looser —
# a url inside prose, a `gh pr view` payload, an issue link — is a session
+29
View File
@@ -6886,6 +6886,35 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
)
_session_compact_cols_sql: Optional[str] = None
def usage_totals(self, *, min_message_count: int = 1, include_archived: bool = False) -> Dict[str, float]:
"""Tokens and spend across this store, as one aggregate.
The sidebar shows a profile's totals beside a page of its sessions, so
summing the rows it happens to have loaded would report a fraction of
the truth and shrink as paging changed. SQLite adds the columns up over
every row instead, at the cost of one scan.
Spend is the billed figure when the provider returned one and the
estimate otherwise — the same precedence a single row renders.
"""
where = ["parent_session_id IS NULL", "message_count >= ?"]
params: List[Any] = [min_message_count]
if not include_archived:
where.append("COALESCE(archived, 0) = 0")
with self._read_ctx() as conn:
row = conn.execute(
f"""
SELECT COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0),
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0)
FROM sessions
WHERE {' AND '.join(where)}
""",
params,
).fetchone()
return {"tokens": int(row[0] or 0), "cost_usd": float(row[1] or 0.0)}
def list_sessions_rich(
self,
source: str = None,
@@ -0,0 +1,248 @@
"""The sidebar's profile scope, across both endpoints that serve it.
Two behaviors that only show up with more than one profile on disk:
* ``/api/profiles/sessions/sidebar`` must answer to one scope for all three of
its slices. Cron and messaging ignoring it is what made a concrete profile
show another profile's Telegram threads and cronjobs (#65710, #42651,
#70629).
* ``/api/profiles/projects/tree`` must build each profile's tree from that
profile's own state.db AND its own projects.db, and hand back ids that can
coexist in one list.
"""
import pytest
@pytest.fixture
def profiles_on_disk(tmp_path, monkeypatch, _isolate_hermes_home):
"""An isolated default home plus one named profile, each with a state.db."""
from hermes_cli import profiles
from hermes_constants import get_hermes_home
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker"
for home in (default_home, worker_home):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker": worker_home}
@pytest.fixture
def client(monkeypatch, profiles_on_disk):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
from hermes_constants import get_hermes_home
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def _seed_session(home, session_id, *, source, cwd=None, tokens=None, cost=None):
"""One session with a message, so it clears the sidebar's min_messages=1.
``cwd`` is what attaches it to a project — without one it lands in Home.
``tokens`` is an (input, output) pair; both it and ``cost`` are written
straight to the row, the shape a finished turn leaves behind.
"""
import sqlite3
from hermes_state import SessionDB
db = SessionDB(db_path=home / "state.db")
try:
db.create_session(session_id, source=source, cwd=str(cwd) if cwd else None)
db.append_message(session_id=session_id, role="user", content="hi")
finally:
db.close()
if tokens is None and cost is None:
return
conn = sqlite3.connect(home / "state.db")
try:
conn.execute(
"UPDATE sessions SET input_tokens = ?, output_tokens = ?, estimated_cost_usd = ? WHERE id = ?",
(*(tokens or (0, 0)), cost or 0.0, session_id),
)
conn.commit()
finally:
conn.close()
def _seed_project(home, name, folder):
from hermes_cli import projects_db
with projects_db.connect_closing(db_path=home / "projects.db") as conn:
return projects_db.create_project(conn, name=name, folders=[str(folder)])
def _slice_ids(payload, slice_name):
return {row["id"] for row in payload[slice_name]["sessions"]}
class TestSidebarScope:
def test_concrete_profile_sees_only_its_own_slices(self, client, profiles_on_disk):
_seed_session(profiles_on_disk["default"], "default-chat", source="cli")
_seed_session(profiles_on_disk["default"], "default-cron", source="cron")
_seed_session(profiles_on_disk["default"], "default-telegram", source="telegram")
_seed_session(profiles_on_disk["worker"], "worker-chat", source="cli")
_seed_session(profiles_on_disk["worker"], "worker-cron", source="cron")
_seed_session(profiles_on_disk["worker"], "worker-telegram", source="telegram")
payload = client.get(
"/api/profiles/sessions/sidebar",
params={"recents_profile": "worker", "recents_exclude": "cron,telegram", "messaging_exclude": "cli,cron"},
).json()
assert payload["errors"] == []
assert _slice_ids(payload, "recents") == {"worker-chat"}
# The bug: these two used to come back with the default profile's rows
# folded in, whatever scope the sidebar asked for.
assert _slice_ids(payload, "cron") == {"worker-cron"}
assert _slice_ids(payload, "messaging") == {"worker-telegram"}
def test_all_scope_still_spans_every_profile(self, client, profiles_on_disk):
_seed_session(profiles_on_disk["default"], "default-telegram", source="telegram")
_seed_session(profiles_on_disk["worker"], "worker-telegram", source="telegram")
payload = client.get(
"/api/profiles/sessions/sidebar",
params={"recents_profile": "all", "messaging_exclude": "cli,cron"},
).json()
assert _slice_ids(payload, "messaging") == {"default-telegram", "worker-telegram"}
assert {row["profile"] for row in payload["messaging"]["sessions"]} == {"default", "worker"}
class TestCrossProfileProjectTree:
def test_one_folder_worked_in_by_two_profiles_is_one_project(self, client, profiles_on_disk, tmp_path):
# A folder is a folder no matter who opened it. Two profiles working the
# same checkout is the normal case (that's the point of profiles), so it
# heads ONE group carrying both their sessions — not one group each.
shared = tmp_path / "repos" / "shared"
shared.mkdir(parents=True)
for name, home in profiles_on_disk.items():
_seed_session(home, f"{name}-chat", source="cli", cwd=shared)
_seed_project(home, "Shared", shared)
payload = client.get("/api/profiles/projects/tree").json()
assert payload["errors"] == []
declared = [project for project in payload["projects"] if not project["isNoProject"]]
assert [project["path"] for project in declared] == [str(shared)]
assert declared[0]["sessionCount"] == 2
def test_group_totals_add_up_the_sessions_the_group_counts(self, client, profiles_on_disk, tmp_path):
# A header total is only meaningful if it covers exactly what the header
# says it counts — a project's totals span every profile working it, the
# same set `sessionCount` reports.
shared = tmp_path / "repos" / "shared"
shared.mkdir(parents=True)
for name, home in profiles_on_disk.items():
_seed_session(home, f"{name}-chat", source="cli", cwd=shared, tokens=(100, 20), cost=0.25)
_seed_project(home, "Shared", shared)
payload = client.get("/api/profiles/projects/tree").json()
project = next(p for p in payload["projects"] if not p["isNoProject"])
assert project["sessionCount"] == 2
assert project["totalTokens"] == 240
assert project["totalCostUsd"] == pytest.approx(0.5)
def test_profile_usage_covers_sessions_past_the_window(self, client, profiles_on_disk):
# The whole point of aggregating in SQL: the total must not be a sum of
# whichever page the sidebar happens to have asked for.
for index in range(3):
_seed_session(
profiles_on_disk["worker"], f"worker-{index}", source="cli", tokens=(10, 5), cost=1.5
)
payload = client.get(
"/api/profiles/sessions/sidebar", params={"recents_profile": "all", "recents_limit": 1}
).json()
assert payload["recents"]["profiles_usage"]["worker"] == {
"cost_usd": pytest.approx(4.5),
"tokens": 45,
}
def test_home_is_one_bucket_across_profiles(self, client, profiles_on_disk):
# Every profile builds its own unowned-sessions bucket. Merging by id is
# what keeps the sidebar from stacking N identical "Home" rows.
for name, home in profiles_on_disk.items():
_seed_session(home, f"{name}-chat", source="cli")
payload = client.get("/api/profiles/projects/tree").json()
homes = [project for project in payload["projects"] if project["isNoProject"]]
assert len(homes) == 1
assert homes[0]["sessionCount"] == 2
def test_each_profile_contributes_its_own_projects_db(self, client, profiles_on_disk, tmp_path):
"""Proves the per-profile scoping, not just that two trees got merged.
The builder reads projects.db, the repo-scan policy and the junk
filters through ``get_hermes_home()``. If the fan-out failed to rebind
it per profile, every tree would come back describing whichever home
the process happens to be running as.
"""
for name in profiles_on_disk:
(tmp_path / "repos" / f"only-{name}").mkdir(parents=True)
_seed_session(profiles_on_disk[name], f"{name}-chat", source="cli")
_seed_project(profiles_on_disk[name], f"Only {name}", tmp_path / "repos" / f"only-{name}")
payload = client.get("/api/profiles/projects/tree").json()
labels = {project["label"] for project in payload["projects"] if not project["isNoProject"]}
assert labels == {"Only default", "Only worker"}
def test_a_profile_that_cannot_be_read_does_not_sink_the_rest(
self, client, profiles_on_disk, tmp_path, monkeypatch
):
(tmp_path / "repos" / "healthy").mkdir(parents=True)
# A state.db has to exist for a profile to be visited at all.
for name, home in profiles_on_disk.items():
_seed_session(home, f"{name}-chat", source="cli")
_seed_project(profiles_on_disk["default"], "Healthy", tmp_path / "repos" / "healthy")
from tui_gateway import server as gateway_server
real_build = gateway_server._build_project_tree
def explode_for_worker(db, **kwargs):
from hermes_constants import get_hermes_home
if get_hermes_home().name == "worker":
raise RuntimeError("worker store is unreadable")
return real_build(db, **kwargs)
monkeypatch.setattr(gateway_server, "_build_project_tree", explode_for_worker)
payload = client.get("/api/profiles/projects/tree").json()
assert [error["profile"] for error in payload["errors"]] == ["worker"]
# The healthy profile's tree still lands; only the broken one drops out.
assert "Healthy" in [project["label"] for project in payload["projects"]]
assert [project["sessionCount"] for project in payload["projects"] if project["isNoProject"]] == [1]
+18
View File
@@ -505,6 +505,15 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R
# ---------------------------------------------------------------------------
def _session_cost(session: dict) -> float:
"""A session's spend, billed if the provider reported it, else estimated."""
for key in ("actual_cost_usd", "estimated_cost_usd"):
value = session.get(key)
if value:
return float(value)
return 0.0
def _project_node(
*,
pid: str,
@@ -514,6 +523,7 @@ def _project_node(
session_count: int,
last_active: float,
preview_sessions: list[dict],
sessions: Optional[list[dict]] = None,
color: Any = None,
icon: Any = None,
is_auto: bool = False,
@@ -529,6 +539,11 @@ def _project_node(
"isNoProject": is_no_project,
"sessionCount": session_count,
"lastActive": last_active,
# Totals over the same sessions `sessionCount` counts, so a project's
# header can add up what its rows show. The window the caller loaded is
# the whole truth either way — count and totals can't disagree.
"totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []),
"totalCostUsd": sum(_session_cost(s) for s in sessions or []),
"repos": repos,
"previewSessions": preview_sessions,
}
@@ -612,6 +627,7 @@ def build_tree(
session_count=len(psessions),
last_active=_last_active(psessions),
preview_sessions=_previews(psessions),
sessions=psessions,
)
)
@@ -693,6 +709,7 @@ def build_tree(
session_count=repo_node["sessionCount"],
last_active=_last_active(auto_sessions),
preview_sessions=_previews(auto_sessions),
sessions=auto_sessions,
is_auto=True,
)
)
@@ -761,6 +778,7 @@ def build_tree(
session_count=len(homeless),
last_active=_last_active(homeless),
preview_sessions=_previews(homeless),
sessions=homeless,
is_no_project=True,
),
)
+5
View File
@@ -11936,6 +11936,11 @@ def _project_tree_row(r: dict) -> dict:
"tool_call_count": r.get("tool_call_count") or 0,
"input_tokens": r.get("input_tokens") or 0,
"output_tokens": r.get("output_tokens") or 0,
# Cost is one of the fields SidebarSessionRow renders, so a lane row has
# to carry it too — without it, switching Show → cost filled in every
# figure in Recents and left the same sessions blank under a project.
"actual_cost_usd": r.get("actual_cost_usd"),
"estimated_cost_usd": r.get("estimated_cost_usd"),
"model": r.get("model"),
"is_active": False,
"cwd": r.get("cwd"),