Merge pull request #83052 from NousResearch/bb/sidebar-all-profiles
Sidebar: show every profile at once
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -15,7 +15,6 @@ export type SidebarActions = Pick<
|
||||
| 'onBranchSession'
|
||||
| 'onDeleteSession'
|
||||
| 'onLoadMoreMessaging'
|
||||
| 'onLoadMoreProfileSessions'
|
||||
| 'onLoadMoreSessions'
|
||||
| 'onManageCronJob'
|
||||
| 'onNavigate'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user