fix(sidebar): keep permission badge and hover actions from overlapping (#2738)

fix(sidebar): keep permission badge and hover actions from overlapping
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:46:43 +03:00
committed by GitHub
4 changed files with 71 additions and 3 deletions
@@ -74,3 +74,4 @@ make every row observe unrelated streaming updates.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data. - Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action. - Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events. - Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on always-visible-actions rows, which reserve permanent padding and keep the badges shown (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
@@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount
import { useSessionMessageRecordsForExport } from '@/sync/use-sync'; import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd'; import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils';
import type { SessionNode } from '../types'; import type { SessionNode } from '../types';
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils'; import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -685,6 +685,14 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
const pendingQuestionLabel = pendingQuestionCount === 1 const pendingQuestionLabel = pendingQuestionCount === 1
? t('sessions.sidebar.session.status.questionPendingSingle') ? t('sessions.sidebar.session.status.questionPendingSingle')
: t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount }); : 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 showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus; const showStatusMarker = isStreaming || showUnreadStatus;
// Both states are the same static dot; only the color separates "running" // Both states are the same static dot; only the color separates "running"
@@ -1432,13 +1440,13 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
</div> </div>
) : null} ) : null}
{pendingPermissionCount > 0 ? ( {pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}> <span className={cn('inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0', badgeVisibilityClass)} title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<Icon name="shield" className="h-3 w-3" /> <Icon name="shield" className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span> <span className="leading-none">{pendingPermissionCount}</span>
</span> </span>
) : null} ) : null}
{pendingQuestionCount > 0 ? ( {pendingQuestionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}> <span className={cn('inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0', badgeVisibilityClass)} title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
<Icon name="question" className="h-3 w-3" /> <Icon name="question" className="h-3 w-3" />
<span className="leading-none">{pendingQuestionCount}</span> <span className="leading-none">{pendingQuestionCount}</span>
</span> </span>
@@ -9,6 +9,7 @@ import {
nodeHasPinnedMembershipChange, nodeHasPinnedMembershipChange,
selectFolderRootNodes, selectFolderRootNodes,
selectQuestionBadgeSessionScopes, selectQuestionBadgeSessionScopes,
selectRowBadgeVisibilityClass,
} from './sessionNodeItemUtils'; } from './sessionNodeItemUtils';
import type { SessionNode } from '../types'; import type { SessionNode } from '../types';
@@ -166,6 +167,45 @@ describe('selectFolderRootNodes', () => {
}); });
}); });
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('');
});
});
describe('getSessionWorktreeMenuDisabled', () => { describe('getSessionWorktreeMenuDisabled', () => {
test('shares the parent trigger disabled contract with the new worktree action', () => { test('shares the parent trigger disabled contract with the new worktree action', () => {
expect(getSessionWorktreeMenuDisabled({ expect(getSessionWorktreeMenuDisabled({
@@ -333,6 +333,25 @@ export const nodeHasPinnedMembershipChange = (
return visit(prevNode, nextNode); 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 * 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. * menu is open. Only one row can have its menu open at a time.