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;
+};