Merge remote-tracking branch 'origin/main' into port-2877
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -42,9 +42,12 @@ existing data; it is never treated as an authoritative empty list.
|
||||
|
||||
Web and desktop show managed Chats before optional Recent activity. Chats use
|
||||
their shared managed root for folders and never expose worktree actions. Project
|
||||
display can be all projects or one selected project. VS Code excludes worktrees
|
||||
and managed Chats, while retaining its workspace-scoped grouped list and inline
|
||||
archived buckets.
|
||||
display can be all projects or one selected project. The mobile sessions sheet
|
||||
(`apps/MobileSessionsSheet.tsx`) partitions the same way through
|
||||
`partitionSidebarSessions` and lists Chats as a collapsible section above the
|
||||
project tree, with no Recent projection. VS Code excludes worktrees and managed
|
||||
Chats, while retaining its workspace-scoped grouped list and inline archived
|
||||
buckets.
|
||||
|
||||
Directory demand always includes known project roots and worktrees. Visibility
|
||||
only changes priority. Row mounts must not start bootstrap work. Selection and
|
||||
@@ -71,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.
|
||||
- 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.
|
||||
- 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`).
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./SessionNodeItem.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('SessionNodeItem recent-activity timestamp', () => {
|
||||
test('the recent activity rows render the compact timestamp in the inline metadata slot', () => {
|
||||
// The right-slot guard must open for recent rows even when no activity,
|
||||
// goal glyph, or branch marker is present.
|
||||
const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'");
|
||||
expect(guard).toBeGreaterThan(-1);
|
||||
// The recent-only block sits inside that slot…
|
||||
const guardOpen = source.indexOf("{renderContext === 'recent' ? (", guard);
|
||||
expect(guardOpen).toBeGreaterThan(guard);
|
||||
// …and the compact label rendered there is the first one after it.
|
||||
const label = source.indexOf('{sessionCompactUpdatedLabel}', guardOpen);
|
||||
expect(label).toBeGreaterThan(guardOpen);
|
||||
// The only later occurrence is the pre-existing row tooltip (which shows
|
||||
// the full date), not a second inline render.
|
||||
const tooltipLabel = source.indexOf('{sessionCompactUpdatedLabel}', label + 1);
|
||||
expect(tooltipLabel).toBeGreaterThan(label);
|
||||
expect(source.indexOf('title={sessionUpdatedLabel}', tooltipLabel - 80)).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
test('the timestamp shares the hover-fade of the other metadata so revealed actions never overlap it', () => {
|
||||
const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'");
|
||||
// The slot content fades out while the row is hovered (hideOnHoverClass)
|
||||
// and while the row menu is open — the same span that now carries the
|
||||
// recent timestamp.
|
||||
const hideOnHover = source.indexOf('hideOnHoverClass', guard);
|
||||
expect(hideOnHover).toBeGreaterThan(guard);
|
||||
expect(hideOnHover).toBeLessThan(source.indexOf("{renderContext === 'recent' ? (", guard));
|
||||
});
|
||||
|
||||
test('the compact label uses the existing i18n-backed relative time helper', () => {
|
||||
// formatSessionCompactDateLabel (already used by touch runtimes and the
|
||||
// row tooltip) is the source of the label — no new formatting code.
|
||||
expect(source.indexOf('const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);')).toBeGreaterThan(-1);
|
||||
expect(source.indexOf('{sessionCompactUpdatedLabel}')).toBeGreaterThan(-1);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount
|
||||
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
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 { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -685,6 +685,14 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): 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"
|
||||
@@ -1390,7 +1398,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? (
|
||||
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? (
|
||||
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
|
||||
@@ -1414,19 +1422,31 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
style={prIconColor ? { color: prIconColor } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{/* The recent activity list shows its compact
|
||||
timestamp inline (touch runtimes already get
|
||||
it through the alwaysShowActions branch);
|
||||
it shares the slot with the goal/branch
|
||||
metadata and hides on hover exactly like
|
||||
them, so the revealed row actions never
|
||||
overlap it. */}
|
||||
{renderContext === 'recent' ? (
|
||||
<span className="flex-shrink-0 text-[0.72rem] leading-none text-muted-foreground/75 tabular-nums">
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{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" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{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" />
|
||||
<span className="leading-none">{pendingQuestionCount}</span>
|
||||
</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
nodeHasPinnedMembershipChange,
|
||||
selectFolderRootNodes,
|
||||
selectQuestionBadgeSessionScopes,
|
||||
selectRowBadgeVisibilityClass,
|
||||
} from './sessionNodeItemUtils';
|
||||
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', () => {
|
||||
test('shares the parent trigger disabled contract with the new worktree action', () => {
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
|
||||
@@ -333,6 +333,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.
|
||||
|
||||
Reference in New Issue
Block a user