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:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
4c7b87bb21
commit
76b2ab2588
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user