Merge remote-tracking branch 'origin/main' into port-2877

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:48:50 +03:00
140 changed files with 4633 additions and 428 deletions
@@ -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.