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:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user