fix: show collapsed sidebar activity indicators (#2116)

* fix: show collapsed sidebar activity indicators

* test: cover collapsed sidebar activity rollups

* style(ui): unify collapsed activity indicator

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
Pascal André
2026-07-29 20:36:32 +03:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 4c7b87bb21
commit 76b2ab2588
6 changed files with 257 additions and 2 deletions
@@ -5,6 +5,8 @@ import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator';
import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
@@ -18,6 +20,7 @@ interface SessionFolderItemProps<TSessionNode> {
/** Sub-folders that belong directly to this folder */
subFolderItems?: React.ReactNode;
isCollapsed: boolean;
collapsedActivityState?: CollapsedActivityState;
onToggle: () => void;
onRename: (name: string) => void;
onDelete: () => void;
@@ -67,6 +70,7 @@ const SessionFolderItemBase = <TSessionNode,>({
sessions,
subFolderItems,
isCollapsed,
collapsedActivityState = null,
onToggle,
onRename,
onDelete,
@@ -262,6 +266,13 @@ const SessionFolderItemBase = <TSessionNode,>({
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
{sessions.length}
</span>
{collapsedActivityState ? (
<CollapsedActivityIndicator
state={collapsedActivityState}
activeLabel={t('sessions.sidebar.session.status.active')}
unreadLabel={t('sessions.sidebar.session.status.unread')}
/>
) : null}
{isCollapsed ? (
<Icon name="arrow-right-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
@@ -303,6 +303,15 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
(state) => isVisible ? [...state.statusById.keys()].sort() : EMPTY_STRING_ARRAY,
));
const activeSessionIdSet = React.useMemo(() => new Set(activeSessionIds), [activeSessionIds]);
const unreadSessionIds = useNotificationStore(useShallow(
(state) => isVisible
? Object.entries(state.index.session.unseenCount)
.filter(([, count]) => count > 0)
.map(([sessionId]) => sessionId)
.sort()
: EMPTY_STRING_ARRAY,
));
const unreadSessionIdSet = React.useMemo(() => new Set(unreadSessionIds), [unreadSessionIds]);
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
@@ -1656,6 +1665,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
editingId={editingId}
editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey}
activeActivitySessionIds={activeSessionIdSet}
unreadActivitySessionIds={unreadSessionIdSet}
notifyOnSubtasks={notifyOnSubtasks}
onToggleCollapsedGroup={toggleCollapsedGroup}
scrollContainerRef={scrollContainerRef}
/>
@@ -1692,6 +1704,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
editingId,
editTitle,
openSidebarMenuKey,
activeSessionIdSet,
unreadSessionIdSet,
notifyOnSubtasks,
toggleCollapsedGroup,
],
);
@@ -32,6 +32,12 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useI18n } from '@/lib/i18n';
import { useChildStoreManager } from '@/sync/sync-context';
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
import {
getSessionNodesActivityState,
mergeCollapsedActivityStates,
type CollapsedActivityState,
} from './collapsedActivityState';
type DeleteFolderConfirm = {
scopeKey: string;
@@ -89,6 +95,9 @@ type Props = {
editingId: string | null;
editTitle: string;
openSidebarMenuKey: string | null;
activeActivitySessionIds: Set<string>;
unreadActivitySessionIds: Set<string>;
notifyOnSubtasks: boolean;
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
@@ -134,6 +143,26 @@ const groupHasSessionOrderChange = (
return group.sessions.some(visit);
};
const groupHasActivityMembershipChange = (
group: SessionGroup,
prevSessionIds: Set<string>,
nextSessionIds: Set<string>,
): boolean => {
const visit = (node: SessionNode): boolean => {
if (prevSessionIds.has(node.session.id) !== nextSessionIds.has(node.session.id)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasAnyActivityMembership = (group: SessionGroup, sessionIds: Set<string>): boolean => {
const visit = (node: SessionNode): boolean => {
if (sessionIds.has(node.session.id)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasExpansionMembershipChange = (
group: SessionGroup,
prevExpandedParents: Set<string>,
@@ -196,6 +225,21 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
if (prevMenuSessionId || nextMenuSessionId) return false;
}
if (prev.activeActivitySessionIds !== next.activeActivitySessionIds
&& groupHasActivityMembershipChange(next.group, prev.activeActivitySessionIds, next.activeActivitySessionIds)) {
return false;
}
if (prev.unreadActivitySessionIds !== next.unreadActivitySessionIds
&& groupHasActivityMembershipChange(next.group, prev.unreadActivitySessionIds, next.unreadActivitySessionIds)) {
return false;
}
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks
&& groupHasAnyActivityMembership(next.group, next.unreadActivitySessionIds)) {
return false;
}
// Other props are typically stable references from the parent. Default
// to reference equality (the cheap path) and only re-render when the
// parent actually swapped something.
@@ -271,6 +315,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
sessionOrderIndex,
editingId,
openSidebarMenuKey,
activeActivitySessionIds,
unreadActivitySessionIds,
notifyOnSubtasks,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
@@ -409,6 +456,40 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
const childFoldersByParentId = React.useMemo(() => {
const map = new Map<string, typeof allFoldersForGroup>();
allFoldersForGroup.forEach((entry) => {
if (!entry.folder.parentId) return;
const children = map.get(entry.folder.parentId) ?? [];
children.push(entry);
map.set(entry.folder.parentId, children);
});
return map;
}, [allFoldersForGroup]);
const folderActivityStateById = React.useMemo(() => {
const foldersById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry] as const));
const result = new Map<string, CollapsedActivityState>();
const visit = (folderId: string, seen: Set<string>): CollapsedActivityState => {
const cached = result.get(folderId);
if (cached !== undefined) return cached;
if (seen.has(folderId)) return null;
seen.add(folderId);
const entry = foldersById.get(folderId);
let state = entry
? getSessionNodesActivityState(entry.nodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
: null;
for (const child of childFoldersByParentId.get(folderId) ?? []) {
state = mergeCollapsedActivityStates(state, visit(child.folder.id, seen));
if (state === 'active') break;
}
result.set(folderId, state);
return state;
};
allFoldersForGroup.forEach(({ folder }) => visit(folder.id, new Set()));
return result;
}, [activeActivitySessionIds, allFoldersForGroup, childFoldersByParentId, notifyOnSubtasks, unreadActivitySessionIds]);
// Precompute the per-row "subtree contains editing session" lookup once per
// render. The previous design walked the
@@ -666,6 +747,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
? { label: group.branch, color: null as string | null }
: null;
const groupActivityState = isCollapsed
? getSessionNodesActivityState(sourceGroupNodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
: null;
const groupActivityIndicator = groupActivityState ? (
<CollapsedActivityIndicator
state={groupActivityState}
activeLabel={t('sessions.sidebar.session.status.active')}
unreadLabel={t('sessions.sidebar.session.status.unread')}
/>
) : null;
type FolderEntry = (typeof allFoldersForGroup)[number];
@@ -673,6 +764,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const { folder, scopeKey, scopeDirectory, nodes } = entry;
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id);
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
{(droppableRef, isDropTarget) => (
@@ -680,7 +772,8 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
folder={folder}
displayName={displayName}
sessions={nodes}
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
isCollapsed={isFolderCollapsed}
collapsedActivityState={isFolderCollapsed ? (folderActivityStateById.get(folder.id) ?? null) : null}
onToggle={() => toggleFolderCollapse(folder.id)}
onRename={(name) => {
renameFolder(scopeKey, folder.id, name);
@@ -981,6 +1074,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
</span>
</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
{groupActivityIndicator}
</span>
) : (!group.isMain || group.worktree) ? (
// Worktree sub-header in the flat visual language: slim
@@ -1001,6 +1095,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
<span className="min-w-0 truncate typography-ui-label font-semibold text-muted-foreground">
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
</span>
{groupActivityIndicator}
{groupPrSummary ? (
<span
className="ml-auto flex-shrink-0 text-[0.72rem] font-medium leading-none"
@@ -1011,7 +1106,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
) : null}
</span>
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="min-w-0 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
{groupActivityIndicator}
</span>
)}
</p>
{showBranchSubtitle && statusLine ? (
@@ -0,0 +1,39 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import type { CollapsedActivityState } from './collapsedActivityState';
export function CollapsedActivityIndicator({
state,
activeLabel,
unreadLabel,
className,
}: {
state: Exclude<CollapsedActivityState, null>;
activeLabel: string;
unreadLabel: string;
className?: string;
}): React.ReactNode {
const label = state === 'active' ? activeLabel : unreadLabel;
if (state === 'active') {
return (
<Icon
name="loader-4"
className={cn('h-3 w-3 shrink-0 animate-spin text-primary', className)}
aria-label={label}
/>
);
}
return (
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
'bg-[var(--status-info)]',
className,
)}
aria-label={label}
title={label}
/>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { getSessionNodesActivityState } from './collapsedActivityState';
import type { SessionNode } from './types';
const node = (id: string, parentID?: string, children: SessionNode[] = []): SessionNode => ({
session: { id, parentID } as Session,
children,
worktree: null,
});
describe('getSessionNodesActivityState', () => {
test('prioritizes active descendants over unread descendants', () => {
const nodes = [node('unread'), node('root', undefined, [node('active-child', 'root')])];
expect(getSessionNodesActivityState(
nodes,
new Set(['active-child']),
new Set(['unread']),
false,
)).toBe('active');
});
test('includes unread subtasks only when subtask notifications are enabled', () => {
const nodes = [node('root', undefined, [node('unread-child', 'root')])];
const unread = new Set(['unread-child']);
expect(getSessionNodesActivityState(nodes, new Set(), unread, false)).toBeNull();
expect(getSessionNodesActivityState(nodes, new Set(), unread, true)).toBe('unread');
});
test('returns null when no descendant has activity', () => {
expect(getSessionNodesActivityState([node('idle')], new Set(), new Set(), true)).toBeNull();
});
});
@@ -0,0 +1,57 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionNode } from './types';
export type CollapsedActivityState = 'active' | 'unread' | null;
export const mergeCollapsedActivityStates = (
current: CollapsedActivityState,
next: CollapsedActivityState,
): CollapsedActivityState => {
if (current === 'active' || next === 'active') return 'active';
if (current === 'unread' || next === 'unread') return 'unread';
return null;
};
const getSessionNodeActivityState = (
node: SessionNode,
activeSessionIds: Set<string>,
unreadSessionIds: Set<string>,
includeUnreadSubtasks: boolean,
): CollapsedActivityState => {
if (activeSessionIds.has(node.session.id)) {
return 'active';
}
let state: CollapsedActivityState = null;
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) {
state = 'unread';
}
for (const child of node.children) {
state = mergeCollapsedActivityStates(
state,
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
);
if (state === 'active') return state;
}
return state;
};
export const getSessionNodesActivityState = (
nodes: SessionNode[],
activeSessionIds: Set<string>,
unreadSessionIds: Set<string>,
includeUnreadSubtasks: boolean,
): CollapsedActivityState => {
let state: CollapsedActivityState = null;
for (const node of nodes) {
state = mergeCollapsedActivityStates(
state,
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
);
if (state === 'active') return state;
}
return state;
};