fix(desktop): ship the sidebar grouped by date in every scope

The all-profiles scope defaulted to grouping by profile, so "Reset to defaults"
handed back a grouping the user never picked. Both scopes now ship by date, and
a reset clears the scope you are not looking at too — otherwise flipping the
rail restored the customization the reset was supposed to undo.

Hovering a row's PR chip also holds the kebab back now: the chip is a link, and
the button that covers the end of the trailing slot was taking the click.
This commit is contained in:
Brooklyn Nicholson 2026-08-10 03:46:45 -05:00
parent 8de786c7a7
commit 7e1f4f6f36
5 changed files with 47 additions and 27 deletions

View File

@ -37,6 +37,10 @@ export function PrTag({ className, pr }: { className?: string; pr: HermesBranchP
style.className,
className
)}
// Marks the chip as a live link for the row's hover rule: while the
// pointer is on it, the row keeps its metadata and holds the kebab back
// (see session-row) so the click can actually land.
data-pr-link
onClick={event => {
// The row underneath opens the session on click and pins on
// shift-click; the chip is its own target and keeps the press.

View File

@ -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)
@ -148,7 +155,7 @@ function SidebarSessionRowImpl({
<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="inline-block min-w-5 text-right transition-opacity group-hover:opacity-0">
<span className={cn('inline-block text-right', TAIL_HIDES)}>
{head && ' · '}
{figures.at(-1)}
</span>
@ -200,12 +207,9 @@ function SidebarSessionRowImpl({
<div className="relative z-2 flex items-center justify-end gap-1" data-row-actions>
{trailing.map(({ key, node }, index) => (
<span
className={cn(
// Never narrower than the kebab that has to cover it.
chipEndsSlot &&
index === trailing.length - 1 &&
'inline-flex min-w-5 justify-end transition-opacity group-hover:opacity-0'
)}
className={
chipEndsSlot && index === trailing.length - 1 ? cn('inline-flex justify-end', TAIL_HIDES) : undefined
}
key={key}
>
{node}
@ -225,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!',
trailing.length > 0 && 'absolute right-0'
trailing.length > 0 && 'absolute right-0',
pr && KEBAB_YIELDS
)}
size="icon"
variant="ghost"

View File

@ -48,16 +48,27 @@ describe('the sidebar as it ships', () => {
expect($sidebarViewCustomized.get()).toBe(false)
})
it('resets to the all-profiles default while that scope is on', () => {
it('ships by date in the all-profiles scope too, and resets back to it', () => {
$showAllProfiles.set(true)
setSidebarGrouping('status')
setSidebarGrouping('profile')
resetSidebarView()
expect($sidebarGrouping.get()).toBe('profile')
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')

View File

@ -243,24 +243,20 @@ const $sidebarFlatGrouping = persistentAtom<SidebarGrouping>(
oneOf(['date', 'status'], 'date')
)
// All-profiles keeps its own grouping. The two scopes want different answers —
// one profile's chats read best by day, everyone's read best by owner — and a
// shared atom would either drag `profile` into a scope where it means nothing
// or reset the choice every time the user flips the rail.
// 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,
'profile',
oneOf(['date', 'profile', 'status'], 'profile')
)
/** What the active scope groups by before the user touches anything. */
export const $sidebarDefaultGrouping: ReadableAtom<SidebarGrouping> = computed([$showAllProfiles], showAll =>
showAll ? 'profile' : 'date'
'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']
@ -339,9 +335,9 @@ export const $sidebarFiltersActive: ReadableAtom<boolean> = computed(
* offering. Broader than `$sidebarFiltersActive`, which only knows about what
* hides rows, not about how they're grouped, sorted or labelled. */
export const $sidebarViewCustomized: ReadableAtom<boolean> = computed(
[$sidebarGrouping, $sidebarDefaultGrouping, $sidebarOrdering, $sidebarRowMeta, $sidebarFiltersActive],
(grouping, defaultGrouping, ordering, rowMeta, filtersActive) =>
grouping !== defaultGrouping ||
[$sidebarGrouping, $sidebarOrdering, $sidebarRowMeta, $sidebarFiltersActive],
(grouping, ordering, rowMeta, filtersActive) =>
grouping !== SIDEBAR_DEFAULT_GROUPING ||
ordering !== SIDEBAR_DEFAULT_ORDERING ||
!sameRowMeta(rowMeta, SIDEBAR_DEFAULT_ROW_META) ||
filtersActive
@ -619,7 +615,11 @@ function clearSidebarFilters() {
/** 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($sidebarDefaultGrouping.get())
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()

View File

@ -11929,7 +11929,7 @@ def _project_tree_row(r: dict) -> dict:
"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 on filled every
# 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"),