feat(sessions): show a pending-question indicator on session rows

Adds a per-session pending-question badge to sidebar rows, driven by the
live directory-store question state through a dedicated per-session
subscription channel so unrelated streaming never re-renders rows.

Collapsed parent rows roll up pending questions of hidden descendants
from their owning directory stores without bootstrapping them. Question
state is cloned on session delete/archive so badges clear when sessions
disappear. Adds the questionChangeCallbacks sync performance counter,
i18n keys for all locales, and unit tests for the subscription channel
and scope selection.

Fixes #2634
This commit is contained in:
Serhii Dziupin
2026-08-05 13:41:48 +03:00
parent 34c221b07f
commit c1ba631964
20 changed files with 343 additions and 21 deletions
@@ -32,7 +32,7 @@
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount.
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount.
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
@@ -22,11 +22,11 @@ import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPi
import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
@@ -470,6 +470,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
// expand the other. Matches the format of menuInstanceKey.
const expansionKey = menuInstanceKey;
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
const questionBadgeSessionScopes = React.useMemo(
() => selectQuestionBadgeSessionScopes(node, isExpanded, sessionDirectory),
[isExpanded, node, sessionDirectory],
);
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
const unseenCount = useSessionUnseenCount(session.id);
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
@@ -676,6 +681,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
const pendingPermissionCount = sessionPermissions.length;
const pendingQuestionLabel = pendingQuestionCount === 1
? t('sessions.sidebar.session.status.questionPendingSingle')
: t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount });
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
// Both states are the same static dot; only the color separates "running"
@@ -1292,6 +1300,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<span className="leading-none">{pendingPermissionCount}</span>
</span>
) : null}
{pendingQuestionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
<Icon name="question" className="h-3 w-3" />
<span className="leading-none">{pendingQuestionCount}</span>
</span>
) : null}
</div>
</button>
</TooltipTrigger>
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils';
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
const session = (id: string, title: string): Session => ({
@@ -32,6 +32,41 @@ describe('computeNodeStructureKey', () => {
});
});
describe('selectQuestionBadgeSessionScopes', () => {
const withDirectory = (node: SessionNode, directory: string | null): SessionNode => ({
...node,
session: { ...node.session, directory } as Session,
});
test('rolls up the hidden subtree by owning directory when a parent is collapsed', () => {
const grandchild = withDirectory({ session: session('grandchild', 'Grandchild'), children: [], worktree: null }, '/worktrees/feature');
const child = withDirectory({ session: session('child', 'Child'), children: [grandchild], worktree: null }, '/worktrees/feature');
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
expect(selectQuestionBadgeSessionScopes(root, false, '/repo')).toEqual([
{ directory: '/repo', sessionIDs: ['root'] },
{ directory: '/worktrees/feature', sessionIDs: ['child', 'grandchild'] },
]);
});
test('keeps expanded rows accurate to their own session only', () => {
const child = withDirectory({ session: session('child', 'Child'), children: [], worktree: null }, '/worktrees/feature');
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
expect(selectQuestionBadgeSessionScopes(root, true, '/repo')).toEqual([
{ directory: '/repo', sessionIDs: ['root'] },
]);
});
test('falls back to the group directory when the session has none', () => {
const root: SessionNode = { session: session('root', 'Root'), children: [], worktree: null };
expect(selectQuestionBadgeSessionScopes(root, false, '/fallback')).toEqual([
{ directory: '/fallback', sessionIDs: ['root'] },
]);
});
});
describe('nodeHasPinnedMembershipChange', () => {
test('detects composite pin changes using the group directory fallback', () => {
const node: SessionNode = {
@@ -1,4 +1,6 @@
import { getRuntimeKey } from '@/lib/runtime-switch';
import { normalizePath } from '@/lib/pathNormalization';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import type { SessionNode } from './types';
@@ -70,6 +72,41 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
return false;
};
export type QuestionBadgeSessionScope = {
directory: string;
sessionIDs: string[];
};
/**
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
* badge should count. An expanded row counts only its own session; a collapsed
* parent row additionally rolls up the hidden descendants of its subtree,
* grouped by the directory store each descendant actually lives in, so badges
* stay correct for worktree/subtask sessions without bootstrapping their
* directory stores.
*/
export const selectQuestionBadgeSessionScopes = (
node: SessionNode,
isExpanded: boolean,
fallbackDirectory: string | null,
): QuestionBadgeSessionScope[] => {
const sessionIDsByDirectory = new Map<string, string[]>();
const visit = (current: SessionNode): void => {
const directory = resolveGlobalSessionDirectory(current.session)
?? normalizePath(current.worktree?.path)
?? fallbackDirectory;
if (directory) {
const sessionIDs = sessionIDsByDirectory.get(directory) ?? [];
sessionIDs.push(current.session.id);
sessionIDsByDirectory.set(directory, sessionIDs);
}
if (current === node && isExpanded) return;
for (const child of current.children) visit(child);
};
visit(node);
return [...sessionIDsByDirectory].map(([directory, sessionIDs]) => ({ directory, sessionIDs }));
};
export const selectFolderRootNodes = (
sessionIds: string[],
nodeBySessionId: ReadonlyMap<string, SessionNode>,