diff --git a/apps/desktop/src/app/contrib/latest-actions.test.ts b/apps/desktop/src/app/contrib/latest-actions.test.ts
index 9ff335054f..f719e8438a 100644
--- a/apps/desktop/src/app/contrib/latest-actions.test.ts
+++ b/apps/desktop/src/app/contrib/latest-actions.test.ts
@@ -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 () => {
diff --git a/apps/desktop/src/app/contrib/latest-actions.ts b/apps/desktop/src/app/contrib/latest-actions.ts
index 7d191208a2..41fa4949d8 100644
--- a/apps/desktop/src/app/contrib/latest-actions.ts
+++ b/apps/desktop/src/app/contrib/latest-actions.ts
@@ -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),
diff --git a/apps/desktop/src/app/contrib/types.ts b/apps/desktop/src/app/contrib/types.ts
index 1e2c60ac77..44c29f4685 100644
--- a/apps/desktop/src/app/contrib/types.ts
+++ b/apps/desktop/src/app/contrib/types.ts
@@ -15,7 +15,6 @@ export type SidebarActions = Pick<
| 'onBranchSession'
| 'onDeleteSession'
| 'onLoadMoreMessaging'
- | 'onLoadMoreProfileSessions'
| 'onLoadMoreSessions'
| 'onManageCronJob'
| 'onNavigate'
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index 37c04bd908..356a38cd77 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -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)
diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
index ba2e560b47..8e26f770d2 100644
--- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
@@ -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
diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx
index 9716d246d9..79399f4fec 100644
--- a/apps/desktop/src/components/ui/sidebar.tsx
+++ b/apps/desktop/src/components/ui/sidebar.tsx
@@ -341,7 +341,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
+ /** 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
}
/** Which profiles filled their per-profile window in a returned page. The
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index 19928dc64f..0338f06bc4 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -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({})
diff --git a/apps/desktop/src/store/layout-sidebar-view.test.ts b/apps/desktop/src/store/layout-sidebar-view.test.ts
new file mode 100644
index 0000000000..0dd7265e57
--- /dev/null
+++ b/apps/desktop/src/store/layout-sidebar-view.test.ts
@@ -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')
+ })
+})
diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts
index 52d04795fd..d47ac8b4b7 100644
--- a/apps/desktop/src/store/layout.ts
+++ b/apps/desktop/src/store/layout.ts
@@ -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(
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(
+ 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(
SIDEBAR_SORT_KEY_STORAGE_KEY,
'updated',
oneOf(SIDEBAR_SORT_KEYS, 'updated')
)
-export const $sidebarRowMeta = persistentAtom(SIDEBAR_ROW_META_STORAGE_KEY, [], listOf(ROW_META))
+export const $sidebarRowMeta = persistentAtom(
+ 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(
)
export const $sidebarGrouping: ReadableAtom = 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 = computed(
)
export const $sidebarFiltersActive: ReadableAtom = 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 = computed(
export const $sidebarViewCustomized: ReadableAtom = 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()
}
diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts
index 5d7a0a222d..a810eb7b0e 100644
--- a/apps/desktop/src/store/projects.ts
+++ b/apps/desktop/src/store/projects.ts
@@ -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 {
const generation = ++projectTreeRefreshGeneration
@@ -403,30 +428,14 @@ async function refreshProjectTreeOn(gateway: HermesGateway): Promise {
try {
const res = await gatewayRequestOn(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 {
// 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 {
+ 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 {
}
}
+// 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 {
+ const generation = ++projectTreeRefreshGeneration
+ $projectTreeLoading.set(true)
+
+ try {
+ const res = await window.hermesDesktop.api({
+ 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 {
}
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 {
diff --git a/apps/desktop/src/store/session-pin-sync.test.ts b/apps/desktop/src/store/session-pin-sync.test.ts
index 494650ad7e..634b2b09be 100644
--- a/apps/desktop/src/store/session-pin-sync.test.ts
+++ b/apps/desktop/src/store/session-pin-sync.test.ts
@@ -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)
}))
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index a8024c0739..794e7be61e 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -482,6 +482,16 @@ export const $messagingTruncated = atom(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>({})
+
+/** 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>({})
export const $sessionsLoading = atom(true)
export const $activeSessionId = atom(null)
export const $selectedStoredSessionId = atom(null)
@@ -594,6 +604,8 @@ export const setMessagingPlatformTotals = (next: Updater>
export const setMessagingTruncated = (next: Updater) => updateAtom($messagingTruncated, next)
export const setSessionProfilesTruncated = (next: Updater>) =>
updateAtom($sessionProfilesTruncated, next)
+export const setSessionProfilesUsage = (next: Updater>) =>
+ updateAtom($sessionProfilesUsage, next)
export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next)
export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredIdRotation = (next: Updater) =>
diff --git a/apps/desktop/src/store/sidebar-sort.test.ts b/apps/desktop/src/store/sidebar-sort.test.ts
new file mode 100644
index 0000000000..1703c5b8f2
--- /dev/null
+++ b/apps/desktop/src/store/sidebar-sort.test.ts
@@ -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) =>
+ ({ 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([])
+ })
+})
diff --git a/apps/desktop/src/store/sidebar-sort.ts b/apps/desktop/src/store/sidebar-sort.ts
new file mode 100644
index 0000000000..9b7312603a
--- /dev/null
+++ b/apps/desktop/src/store/sidebar-sort.ts
@@ -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
+): 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 = 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)
+ }
+)
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index bfc7392f13..75dec6eb67 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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;
+ }
}
}
diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py
index 31197c9720..145806c6eb 100644
--- a/hermes_cli/web_routers/profiles.py
+++ b/hermes_cli/web_routers/profiles.py
@@ -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_``) 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
diff --git a/hermes_state.py b/hermes_state.py
index 27aaee7a4b..18c8e1aec7 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -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,
diff --git a/tests/hermes_cli/test_profiles_sidebar_scope.py b/tests/hermes_cli/test_profiles_sidebar_scope.py
new file mode 100644
index 0000000000..75309fbd00
--- /dev/null
+++ b/tests/hermes_cli/test_profiles_sidebar_scope.py
@@ -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]
diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py
index 2991124a63..cd5c966a55 100644
--- a/tui_gateway/project_tree.py
+++ b/tui_gateway/project_tree.py
@@ -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,
),
)
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 69c7122783..14d6dbc2f2 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -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"),