From 604da92fd9ccbdca6b6b992646c2e7d3625c026f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 19:57:07 +0000 Subject: [PATCH] fix(sidebar): keep permission badge and hover actions from overlapping (#2284) Co-authored-by: Serhii Dziupin --- .../session/sidebar/DOCUMENTATION.md | 2 +- .../session/sidebar/SessionNodeItem.tsx | 14 +++++-- .../sidebar/sessionNodeItemUtils.test.ts | 41 ++++++++++++++++++- .../session/sidebar/sessionNodeItemUtils.ts | 19 +++++++++ 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 180dd7f0..a5f796ff 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -32,7 +32,7 @@ - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. -- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount. +- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount. Pending-permission/question badges yield like the date/branch metadata whenever the hover actions are shown (hover, focus-within, or open row menu) on hover-reveal rows; rows with always-visible actions reserve permanent padding, so their badges stay visible (`selectRowBadgeVisibilityClass`). - `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders. - `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows. - `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances. diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 3b61d2a0..f80589a3 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -26,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession import { useSync } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from './sessionFolderDnd'; -import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils'; import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNode } from './types'; import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils'; @@ -684,6 +684,14 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const pendingQuestionLabel = pendingQuestionCount === 1 ? t('sessions.sidebar.session.status.questionPendingSingle') : t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount }); + // Actions are permanently visible (with matching permanent padding) only in + // the non-VSCode alwaysShowActions layout; every other layout hover-reveals + // them over the row's right edge, where the badges live (#2284). + const badgeVisibilityClass = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: alwaysShowActions && !isVSCode, + menuOpen: isSessionMenuOpen, + hideOnHoverClass, + }); const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; // Both states are the same static dot; only the color separates "running" @@ -1295,13 +1303,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { ) : null} {pendingPermissionCount > 0 ? ( - + {pendingPermissionCount} ) : null} {pendingQuestionCount > 0 ? ( - + {pendingQuestionCount} diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts index fe43a537..d71e6ca3 100644 --- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts +++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; -import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils'; import type { SessionNode } from './types'; const session = (id: string, title: string): Session => ({ @@ -158,3 +158,42 @@ describe('selectFolderRootNodes', () => { expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]); }); }); + +describe('selectRowBadgeVisibilityClass', () => { + const hideOnHoverClass = 'group-hover:opacity-0 group-focus-within:opacity-0'; + + test('hides the badge while hover-revealed actions are shown, like the date label (#2284)', () => { + const className = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: false, + menuOpen: false, + hideOnHoverClass, + }); + + expect(className).toContain(hideOnHoverClass); + expect(className).toContain('transition-opacity'); + }); + + test('hides the badge while the row menu keeps the actions visible without hover', () => { + const className = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: false, + menuOpen: true, + hideOnHoverClass, + }); + + expect(className).toContain('opacity-0'); + expect(className).not.toContain('group-hover'); + }); + + test('keeps the badge always visible when actions have reserved permanent padding', () => { + expect(selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: true, + menuOpen: false, + hideOnHoverClass, + })).toBe(''); + expect(selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: true, + menuOpen: true, + hideOnHoverClass, + })).toBe(''); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts index 3de83638..e2f3f15b 100644 --- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts @@ -196,6 +196,25 @@ export const nodeHasPinnedMembershipChange = ( return visit(prevNode, nextNode); }; +/** + * Visibility classes for the row's right-edge badges (pending permissions / + * questions). The hover actions paint over the row's right edge, and they are + * also forced visible while the row menu is open — without hover, so the + * hover reveal padding does not apply and the actions would cover the badges. + * The badges therefore yield exactly like the date/branch metadata label: + * hidden while the actions are hover-revealed or the menu is open. Rows with + * always-visible actions reserve permanent padding instead, so their badges + * never conflict and must stay visible. + */ +export const selectRowBadgeVisibilityClass = (input: { + actionsAlwaysVisible: boolean; + menuOpen: boolean; + hideOnHoverClass: string; +}): string => { + if (input.actionsAlwaysVisible) return ''; + return `transition-opacity duration-150 ${input.menuOpen ? 'opacity-0' : input.hideOnHoverClass}`; +}; + /** * Resolve the session id whose sidebar menu is open, or null if no * menu is open. Only one row can have its menu open at a time.