diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 880e2edbbe..219f20df6d 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -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' diff --git a/apps/desktop/src/app/chat/pr-tag.tsx b/apps/desktop/src/app/chat/pr-tag.tsx index bbd323304a..723be85882 100644 --- a/apps/desktop/src/app/chat/pr-tag.tsx +++ b/apps/desktop/src/app/chat/pr-tag.tsx @@ -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. diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 5adf70a20e..3d422ab3c8 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -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 } +/** 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 ( + + + {facts.join(' · ')} + + {actions ?
{actions}
: null} + + ) : ( + actions + ) + } + className={cn('group/workspace', className)} + {...props} + > + + {lead} + {label} + {toggle ? ( + + + + ) : ( + + )} + +
+ ) +} + /** Dot ↔ grabber swap for dnd-kit reorder rows. */ export function SidebarRowGrab({ ariaLabel, diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index fea0d8dff0..9fadcfbc42 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -150,7 +150,7 @@ export function SidebarCronJobsSection({ {open && ( - + {shown.map(job => ( { const GROUPINGS: Option[] = [ { 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[] = [ @@ -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 }) { )} + + Profile + + {/* 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 => ( + toggleSidebarProfileFilter(name)} + option={{ icon: 'account', id: name, label: name }} + /> + ))} + + + )} + {t.profiles.newProfile} + void runImportProfileFlow()}> + {t.profiles.importProfile} + + + + {projects.length > 1 && ( Project @@ -308,6 +349,19 @@ export function SidebarFilterMenu({ className }: { className?: string }) { )} + {/* 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) && ( + + )} + setSidebarShowArchived(!showArchived)} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d22f84e0bc..040f0ec4c3 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 { currentView: AppView onNavigate: (item: SidebarNavItem) => void onLoadMoreSessions: () => Promise | void - onLoadMoreProfileSessions?: (profile: string) => Promise | void onLoadMoreMessaging?: (platform: string) => Promise | 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([]) const [searchPending, setSearchPending] = useState(false) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) - const [profileLoadMorePending, setProfileLoadMorePending] = useState>({}) const [messagingLoadMorePending, setMessagingLoadMorePending] = useState>({}) 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(() => { - 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>( - () => 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(() => { - 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 ? ( void onLoadMoreRecents()} @@ -1651,7 +1650,7 @@ export function ChatSidebar({ ) : null}
- {!showAllProfiles ? : null} +
) @@ -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} diff --git a/apps/desktop/src/app/chat/sidebar/order.test.ts b/apps/desktop/src/app/chat/sidebar/order.test.ts index 6a12d8e086..37a4b36df9 100644 --- a/apps/desktop/src/app/chat/sidebar/order.test.ts +++ b/apps/desktop/src/app/chat/sidebar/order.test.ts @@ -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([]) diff --git a/apps/desktop/src/app/chat/sidebar/order.ts b/apps/desktop/src/app/chat/sidebar/order.ts index 9998243722..c65a53dc03 100644 --- a/apps/desktop/src/app/chat/sidebar/order.ts +++ b/apps/desktop/src/app/chat/sidebar/order.ts @@ -103,6 +103,15 @@ export function orderByIds(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(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) { diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx index 6630346299..896643636d 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx @@ -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() + const onNewSession = vi.fn() - expect(screen.queryByRole('button', { name: 'New session in Home' })).toBeNull() + render() + fireEvent.click(screen.getByRole('button', { name: 'New session in Home' })) + + expect(onNewSession).toHaveBeenCalledWith(null) }) }) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index aedd2e6e09..155d477e1e 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -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 = ( - - {/* 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 && } + {onNewSession && ( onNewSession(project.path)} /> )} - {!project.isNoProject && } } - className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')} + className={cn(dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')} + label={ + onEnter?.(project.id)} + > + {project.label} + + } + 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} - > - - {lead} - onEnter?.(project.id)} - > - {project.label} - - {preview.length > 0 ? ( - - - - ) : ( - - )} - - + 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 ( diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index 5c05869b5a..33f8382754 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -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 ? ( -