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 { Icon } from "@/components/icon/Icon";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
||||||
|
import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator';
|
||||||
|
import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
|
||||||
|
|
||||||
interface SessionFolderItemProps<TSessionNode> {
|
interface SessionFolderItemProps<TSessionNode> {
|
||||||
folder: SessionFolder;
|
folder: SessionFolder;
|
||||||
@@ -18,6 +20,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
|||||||
/** Sub-folders that belong directly to this folder */
|
/** Sub-folders that belong directly to this folder */
|
||||||
subFolderItems?: React.ReactNode;
|
subFolderItems?: React.ReactNode;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
|
collapsedActivityState?: CollapsedActivityState;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onRename: (name: string) => void;
|
onRename: (name: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -67,6 +70,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
sessions,
|
sessions,
|
||||||
subFolderItems,
|
subFolderItems,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
|
collapsedActivityState = null,
|
||||||
onToggle,
|
onToggle,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -262,6 +266,13 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
|
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
|
||||||
• {sessions.length}
|
• {sessions.length}
|
||||||
</span>
|
</span>
|
||||||
|
{collapsedActivityState ? (
|
||||||
|
<CollapsedActivityIndicator
|
||||||
|
state={collapsedActivityState}
|
||||||
|
activeLabel={t('sessions.sidebar.session.status.active')}
|
||||||
|
unreadLabel={t('sessions.sidebar.session.status.unread')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<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,
|
(state) => isVisible ? [...state.statusById.keys()].sort() : EMPTY_STRING_ARRAY,
|
||||||
));
|
));
|
||||||
const activeSessionIdSet = React.useMemo(() => new Set(activeSessionIds), [activeSessionIds]);
|
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 togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -1656,6 +1665,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
editingId={editingId}
|
editingId={editingId}
|
||||||
editTitle={editTitle}
|
editTitle={editTitle}
|
||||||
openSidebarMenuKey={openSidebarMenuKey}
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
activeActivitySessionIds={activeSessionIdSet}
|
||||||
|
unreadActivitySessionIds={unreadSessionIdSet}
|
||||||
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||||
scrollContainerRef={scrollContainerRef}
|
scrollContainerRef={scrollContainerRef}
|
||||||
/>
|
/>
|
||||||
@@ -1692,6 +1704,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
editingId,
|
editingId,
|
||||||
editTitle,
|
editTitle,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
|
activeSessionIdSet,
|
||||||
|
unreadSessionIdSet,
|
||||||
|
notifyOnSubtasks,
|
||||||
toggleCollapsedGroup,
|
toggleCollapsedGroup,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
|||||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useChildStoreManager } from '@/sync/sync-context';
|
import { useChildStoreManager } from '@/sync/sync-context';
|
||||||
|
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
|
||||||
|
import {
|
||||||
|
getSessionNodesActivityState,
|
||||||
|
mergeCollapsedActivityStates,
|
||||||
|
type CollapsedActivityState,
|
||||||
|
} from './collapsedActivityState';
|
||||||
|
|
||||||
type DeleteFolderConfirm = {
|
type DeleteFolderConfirm = {
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
@@ -89,6 +95,9 @@ type Props = {
|
|||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
editTitle: string;
|
editTitle: string;
|
||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
|
activeActivitySessionIds: Set<string>;
|
||||||
|
unreadActivitySessionIds: Set<string>;
|
||||||
|
notifyOnSubtasks: boolean;
|
||||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||||
dragHandleProps?: SortableDragHandleProps | null;
|
dragHandleProps?: SortableDragHandleProps | null;
|
||||||
compactBodyPadding?: boolean;
|
compactBodyPadding?: boolean;
|
||||||
@@ -134,6 +143,26 @@ const groupHasSessionOrderChange = (
|
|||||||
return group.sessions.some(visit);
|
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 = (
|
const groupHasExpansionMembershipChange = (
|
||||||
group: SessionGroup,
|
group: SessionGroup,
|
||||||
prevExpandedParents: Set<string>,
|
prevExpandedParents: Set<string>,
|
||||||
@@ -196,6 +225,21 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
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
|
// Other props are typically stable references from the parent. Default
|
||||||
// to reference equality (the cheap path) and only re-render when the
|
// to reference equality (the cheap path) and only re-render when the
|
||||||
// parent actually swapped something.
|
// parent actually swapped something.
|
||||||
@@ -271,6 +315,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
sessionOrderIndex,
|
sessionOrderIndex,
|
||||||
editingId,
|
editingId,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
|
activeActivitySessionIds,
|
||||||
|
unreadActivitySessionIds,
|
||||||
|
notifyOnSubtasks,
|
||||||
onToggleCollapsedGroup,
|
onToggleCollapsedGroup,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
compactBodyPadding = false,
|
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 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 ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
||||||
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
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
|
// Precompute the per-row "subtree contains editing session" lookup once per
|
||||||
// render. The previous design walked the
|
// 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)
|
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||||
? { label: group.branch, color: null as string | null }
|
? { label: group.branch, color: null as string | null }
|
||||||
: 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];
|
type FolderEntry = (typeof allFoldersForGroup)[number];
|
||||||
|
|
||||||
@@ -673,6 +764,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
||||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||||
|
|
||||||
|
const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id);
|
||||||
return (
|
return (
|
||||||
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
||||||
{(droppableRef, isDropTarget) => (
|
{(droppableRef, isDropTarget) => (
|
||||||
@@ -680,7 +772,8 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
folder={folder}
|
folder={folder}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
sessions={nodes}
|
sessions={nodes}
|
||||||
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
|
isCollapsed={isFolderCollapsed}
|
||||||
|
collapsedActivityState={isFolderCollapsed ? (folderActivityStateById.get(folder.id) ?? null) : null}
|
||||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||||
onRename={(name) => {
|
onRename={(name) => {
|
||||||
renameFolder(scopeKey, folder.id, name);
|
renameFolder(scopeKey, folder.id, name);
|
||||||
@@ -981,6 +1074,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||||
|
{groupActivityIndicator}
|
||||||
</span>
|
</span>
|
||||||
) : (!group.isMain || group.worktree) ? (
|
) : (!group.isMain || group.worktree) ? (
|
||||||
// Worktree sub-header in the flat visual language: slim
|
// 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">
|
<span className="min-w-0 truncate typography-ui-label font-semibold text-muted-foreground">
|
||||||
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
|
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
|
||||||
</span>
|
</span>
|
||||||
|
{groupActivityIndicator}
|
||||||
{groupPrSummary ? (
|
{groupPrSummary ? (
|
||||||
<span
|
<span
|
||||||
className="ml-auto flex-shrink-0 text-[0.72rem] font-medium leading-none"
|
className="ml-auto flex-shrink-0 text-[0.72rem] font-medium leading-none"
|
||||||
@@ -1011,7 +1106,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</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>
|
</p>
|
||||||
{showBranchSubtitle && statusLine ? (
|
{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