diff --git a/apps/desktop/src/app/chat/pr-tag.tsx b/apps/desktop/src/app/chat/pr-tag.tsx index bbd323304a6e0..723be8588293a 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/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index f8c03fbb0c09c..c4602f1ace5d6 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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({ {head} {/* The figures own their tail: the separator goes with it. */} - + {head && ' · '} {figures.at(-1)} @@ -200,12 +207,9 @@ function SidebarSessionRowImpl({
{trailing.map(({ key, node }, index) => ( {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" diff --git a/apps/desktop/src/store/layout-sidebar-view.test.ts b/apps/desktop/src/store/layout-sidebar-view.test.ts index bb05bd78b956c..0dd7265e577e9 100644 --- a/apps/desktop/src/store/layout-sidebar-view.test.ts +++ b/apps/desktop/src/store/layout-sidebar-view.test.ts @@ -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') diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index ad8dd4abab0e4..d47ac8b4b73dd 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -243,24 +243,20 @@ const $sidebarFlatGrouping = persistentAtom( 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( 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 = 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 = 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 = 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() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 48585a715cbcf..484127292edeb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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"),