From 76b2ab2588f89a0de6033d8adca2995b19ae5c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 29 Jul 2026 19:36:32 +0200 Subject: [PATCH] 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 --- .../components/session/SessionFolderItem.tsx | 11 ++ .../src/components/session/SessionSidebar.tsx | 15 +++ .../session/sidebar/SessionGroupSection.tsx | 102 +++++++++++++++++- .../sidebar/collapsedActivityIndicator.tsx | 39 +++++++ .../sidebar/collapsedActivityState.test.ts | 35 ++++++ .../session/sidebar/collapsedActivityState.ts | 57 ++++++++++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx create mode 100644 packages/ui/src/components/session/sidebar/collapsedActivityState.test.ts create mode 100644 packages/ui/src/components/session/sidebar/collapsedActivityState.ts diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx index 88e364e7..58992963 100644 --- a/packages/ui/src/components/session/SessionFolderItem.tsx +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -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 { folder: SessionFolder; @@ -18,6 +20,7 @@ interface SessionFolderItemProps { /** 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 = ({ sessions, subFolderItems, isCollapsed, + collapsedActivityState = null, onToggle, onRename, onDelete, @@ -262,6 +266,13 @@ const SessionFolderItemBase = ({ • {sessions.length} + {collapsedActivityState ? ( + + ) : null} {isCollapsed ? ( ) : ( diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 4b57be67..c7a78159 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -303,6 +303,15 @@ const SessionSidebarComponent: React.FC = ({ (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>(() => { try { @@ -1656,6 +1665,9 @@ const SessionSidebarComponent: React.FC = ({ editingId={editingId} editTitle={editTitle} openSidebarMenuKey={openSidebarMenuKey} + activeActivitySessionIds={activeSessionIdSet} + unreadActivitySessionIds={unreadSessionIdSet} + notifyOnSubtasks={notifyOnSubtasks} onToggleCollapsedGroup={toggleCollapsedGroup} scrollContainerRef={scrollContainerRef} /> @@ -1692,6 +1704,9 @@ const SessionSidebarComponent: React.FC = ({ editingId, editTitle, openSidebarMenuKey, + activeSessionIdSet, + unreadSessionIdSet, + notifyOnSubtasks, toggleCollapsedGroup, ], ); diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 344a8663..5fb82420 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -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; + unreadActivitySessionIds: Set; + 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, + nextSessionIds: Set, +): 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): 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, @@ -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(); + 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(); + const visit = (folderId: string, seen: Set): 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 ? ( + + ) : 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 ( {(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 { {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + {groupActivityIndicator} ) : (!group.isMain || group.worktree) ? ( // Worktree sub-header in the flat visual language: slim @@ -1001,6 +1095,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + {groupActivityIndicator} {groupPrSummary ? ( ) : ( - renderHighlightedText(group.label, normalizedSessionSearchQuery) + + {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + {groupActivityIndicator} + )}

{showBranchSubtitle && statusLine ? ( diff --git a/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx b/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx new file mode 100644 index 00000000..4d3cfaba --- /dev/null +++ b/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx @@ -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; + activeLabel: string; + unreadLabel: string; + className?: string; +}): React.ReactNode { + const label = state === 'active' ? activeLabel : unreadLabel; + if (state === 'active') { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/session/sidebar/collapsedActivityState.test.ts b/packages/ui/src/components/session/sidebar/collapsedActivityState.test.ts new file mode 100644 index 00000000..cb8bb25b --- /dev/null +++ b/packages/ui/src/components/session/sidebar/collapsedActivityState.test.ts @@ -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(); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/collapsedActivityState.ts b/packages/ui/src/components/session/sidebar/collapsedActivityState.ts new file mode 100644 index 00000000..18827e32 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/collapsedActivityState.ts @@ -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, + unreadSessionIds: Set, + 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, + unreadSessionIds: Set, + 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; +};