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>,
+2
View File
@@ -473,6 +473,8 @@ export const dict = {
'sessions.sidebar.session.status.unread': 'Ungelesene Updates',
'sessions.sidebar.session.status.pinned': 'Angeheftete Sitzung',
'sessions.sidebar.session.status.permissionRequired': 'Berechtigung erforderlich',
'sessions.sidebar.session.status.questionPendingSingle': '1 ausstehende Frage',
'sessions.sidebar.session.status.questionPendingMany': '{count} ausstehende Fragen',
'sessions.sidebar.session.status.activeFor': 'Seit {duration} aktiv',
'sessions.sidebar.session.status.lastTurnDuration': 'Letzter Durchlauf dauerte {duration}',
'sessions.sidebar.session.subsessions.collapse': 'Untersitzungen einklappen',
+2
View File
@@ -530,6 +530,8 @@ export const dict = {
'sessions.sidebar.session.status.pinned': 'Pinned session',
'sessions.sidebar.session.status.movingToWorktree': 'Moving session to a new worktree',
'sessions.sidebar.session.status.permissionRequired': 'Permission required',
'sessions.sidebar.session.status.questionPendingSingle': '1 pending question',
'sessions.sidebar.session.status.questionPendingMany': '{count} pending questions',
'sessions.sidebar.session.status.activeFor': 'Active for {duration}',
'sessions.sidebar.session.status.lastTurnDuration': 'Last turn took {duration}',
'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions',
+2
View File
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.pinned": "Sesión anclada",
"sessions.sidebar.session.status.movingToWorktree": "Moviendo la sesión a un worktree nuevo",
"sessions.sidebar.session.status.permissionRequired": "Permiso requerido",
"sessions.sidebar.session.status.questionPendingSingle": "1 pregunta pendiente",
"sessions.sidebar.session.status.questionPendingMany": "{count} preguntas pendientes",
"sessions.sidebar.session.status.activeFor": "Activa desde hace {duration}",
"sessions.sidebar.session.status.lastTurnDuration": "El último turno duró {duration}",
"sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones",
+2
View File
@@ -366,6 +366,8 @@ export const dict = {
'sessions.sidebar.session.status.pinned': 'Session épinglée',
'sessions.sidebar.session.status.movingToWorktree': 'Déplacement de la session vers un nouveau worktree',
'sessions.sidebar.session.status.permissionRequired': 'Autorisation requise',
'sessions.sidebar.session.status.questionPendingSingle': '1 question en attente',
'sessions.sidebar.session.status.questionPendingMany': '{count} questions en attente',
'sessions.sidebar.session.status.activeFor': 'Active depuis {duration}',
'sessions.sidebar.session.status.lastTurnDuration': 'Le dernier tour a duré {duration}',
'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions',
+2
View File
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション',
'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中',
'sessions.sidebar.session.status.permissionRequired': '権限が必要です',
'sessions.sidebar.session.status.questionPendingSingle': '保留中の質問が1件あります',
'sessions.sidebar.session.status.questionPendingMany': '保留中の質問が{count}件あります',
'sessions.sidebar.session.status.activeFor': 'アクティブ時間 {duration}',
'sessions.sidebar.session.status.lastTurnDuration': '前回のターンの所要時間 {duration}',
'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ',
+2
View File
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.pinned': '고정된 세션',
'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중',
'sessions.sidebar.session.status.permissionRequired': '권한 필요',
'sessions.sidebar.session.status.questionPendingSingle': '대기 중인 질문 1개',
'sessions.sidebar.session.status.questionPendingMany': '대기 중인 질문 {count}개',
'sessions.sidebar.session.status.activeFor': '{duration} 동안 활성 상태',
'sessions.sidebar.session.status.lastTurnDuration': '마지막 턴 소요 시간 {duration}',
'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기',
+2
View File
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.pinned': 'Przypięta sesja',
'sessions.sidebar.session.status.movingToWorktree': 'Przenoszenie sesji do nowego worktree',
'sessions.sidebar.session.status.permissionRequired': 'Wymagane uprawnienie',
'sessions.sidebar.session.status.questionPendingSingle': '1 oczekujące pytanie',
'sessions.sidebar.session.status.questionPendingMany': 'Liczba oczekujących pytań: {count}',
'sessions.sidebar.session.status.activeFor': 'Aktywna od {duration}',
'sessions.sidebar.session.status.lastTurnDuration': 'Ostatnia tura trwała {duration}',
'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje',
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.pinned": "Sessão fixada",
"sessions.sidebar.session.status.movingToWorktree": "Movendo a sessão para um novo worktree",
"sessions.sidebar.session.status.permissionRequired": "Permissão obrigatória",
"sessions.sidebar.session.status.questionPendingSingle": "1 pergunta pendente",
"sessions.sidebar.session.status.questionPendingMany": "{count} perguntas pendentes",
"sessions.sidebar.session.status.activeFor": "Ativa há {duration}",
"sessions.sidebar.session.status.lastTurnDuration": "O último turno levou {duration}",
"sessions.sidebar.session.subsessions.collapse": "Recolher subsessões",
+2
View File
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
"sessions.sidebar.session.status.questionPendingSingle": "1 запитання очікує відповіді",
"sessions.sidebar.session.status.questionPendingMany": "Кількість запитань, що очікують відповіді: {count}",
"sessions.sidebar.session.status.activeFor": "Активна вже {duration}",
"sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}",
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
@@ -531,6 +531,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.pinned': '已置顶会话',
'sessions.sidebar.session.status.movingToWorktree': '正在将会话移至新工作树',
'sessions.sidebar.session.status.permissionRequired': '需要权限',
'sessions.sidebar.session.status.questionPendingSingle': '1 个待回答问题',
'sessions.sidebar.session.status.questionPendingMany': '{count} 个待回答问题',
'sessions.sidebar.session.status.activeFor': '已活动 {duration}',
'sessions.sidebar.session.status.lastTurnDuration': '上一轮耗时 {duration}',
'sessions.sidebar.session.subsessions.collapse': '折叠子会话',
@@ -544,6 +544,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.pinned': '已釘選會話',
'sessions.sidebar.session.status.movingToWorktree': '正在將會話移至新工作樹',
'sessions.sidebar.session.status.permissionRequired': '需要權限',
'sessions.sidebar.session.status.questionPendingSingle': '1 個待回答問題',
'sessions.sidebar.session.status.questionPendingMany': '{count} 個待回答問題',
'sessions.sidebar.session.status.activeFor': '已活動 {duration}',
'sessions.sidebar.session.status.lastTurnDuration': '上一輪耗時 {duration}',
'sessions.sidebar.session.subsessions.collapse': '摺疊子會話',
+2 -2
View File
@@ -204,7 +204,7 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
Directory stores also own session-keyed sidecar notification channels for permissions and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
@@ -345,7 +345,7 @@ Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
| Event type | Fields to clone |
|---|---|
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part` |
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part`; archived/deleted sessions also clone `question` |
| `session.diff` | `session_diff` |
| `session.status` | `session_status` |
| `todo.updated` | `todo` |
+128
View File
@@ -4,6 +4,8 @@ import {
ChildStoreManager,
markDirectorySessionPartChanged,
subscribeDirectoryPermission,
subscribeDirectoryQuestion,
subscribeDirectoryQuestions,
subscribeDirectorySessionMessages,
} from './child-store';
import {
@@ -120,6 +122,132 @@ describe('ChildStoreManager permission subscriptions', () => {
});
});
describe('ChildStoreManager question subscriptions', () => {
test('notifies only the owning session and ignores unrelated high-frequency updates', () => {
const manager = new ChildStoreManager();
const child = manager.ensureChild('/workspace', { bootstrap: false });
const notifications = new Map<string, number>();
const unsubscribers = Array.from({ length: 50 }, (_, index) => {
const sessionID = `session-${index}`;
return subscribeDirectoryQuestion(child, sessionID, () => {
notifications.set(sessionID, (notifications.get(sessionID) ?? 0) + 1);
});
});
setSyncPerformanceDiagnosticsEnabled(true);
for (let index = 0; index < 10_000; index += 1) {
child.setState({ part: { [`message-${index}`]: [] } });
}
expect(notifications.size).toBe(0);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(0);
child.setState({ question: { 'session-17': [{ id: 'question-1' }] as never[] } });
expect(notifications.get('session-17')).toBe(1);
expect(notifications.size).toBe(1);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(1);
// A new map that preserves session-17's bucket must not notify it again.
child.setState({ question: { ...child.getState().question, 'session-18': [{ id: 'question-2' }] as never[] } });
expect(notifications.get('session-17')).toBe(1);
expect(notifications.get('session-18')).toBe(1);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(2);
child.setState({ question: {} });
expect(notifications.get('session-17')).toBe(2);
expect(notifications.get('session-18')).toBe(2);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(4);
for (const unsubscribe of unsubscribers) unsubscribe();
setSyncPerformanceDiagnosticsEnabled(false);
manager.disposeAll();
});
test('notifies subtree and exact-session rows once for each relevant replacement', () => {
const manager = new ChildStoreManager();
const child = manager.ensureChild('/workspace', { bootstrap: false });
let parentNotifications = 0;
let childNotifications = 0;
const unsubscribeParent = subscribeDirectoryQuestions(child, ['parent', 'child'], () => {
parentNotifications += 1;
});
const unsubscribeChild = subscribeDirectoryQuestion(child, 'child', () => {
childNotifications += 1;
});
const parentQuestions = [{ id: 'question-parent' }] as never[];
const childQuestions = [{ id: 'question-child' }] as never[];
child.setState({ question: { parent: parentQuestions, child: childQuestions } });
expect(parentNotifications).toBe(1);
expect(childNotifications).toBe(1);
child.setState({ part: { message: [] } });
expect(parentNotifications).toBe(1);
expect(childNotifications).toBe(1);
child.setState({
question: {
parent: parentQuestions,
child: [{ id: 'question-child-replacement' }] as never[],
},
});
expect(parentNotifications).toBe(2);
expect(childNotifications).toBe(2);
child.setState({ question: {} });
expect(parentNotifications).toBe(3);
expect(childNotifications).toBe(3);
unsubscribeParent();
unsubscribeChild();
manager.disposeAll();
});
test('aggregates exact question buckets across directory stores', () => {
const manager = new ChildStoreManager();
const parentStore = manager.ensureChild('/repo', { bootstrap: false });
const childStore = manager.ensureChild('/worktrees/feature', { bootstrap: false });
let notifications = 0;
const notify = () => {
notifications += 1;
};
const unsubscribers = [
subscribeDirectoryQuestions(parentStore, ['parent'], notify),
subscribeDirectoryQuestions(childStore, ['child'], notify),
];
const questionCount = () => (
(parentStore.getState().question.parent?.length ?? 0)
+ (childStore.getState().question.child?.length ?? 0)
);
childStore.setState({ question: { child: [{ id: 'child-question' }] as never[] } });
expect(questionCount()).toBe(1);
expect(notifications).toBe(1);
childStore.setState({
question: {
...childStore.getState().question,
unrelated: [{ id: 'unrelated-question' }] as never[],
},
});
expect(questionCount()).toBe(1);
expect(notifications).toBe(1);
parentStore.setState({ question: { parent: [{ id: 'parent-question' }] as never[] } });
expect(questionCount()).toBe(2);
expect(notifications).toBe(2);
for (const unsubscribe of unsubscribers) unsubscribe();
manager.disposeAll();
});
});
describe('ChildStoreManager session message subscriptions', () => {
test('routes annotated part changes only to the owning session', () => {
const manager = new ChildStoreManager();
+52 -15
View File
@@ -14,8 +14,10 @@ export type DirectoryStore = State & {
replace: (next: State) => void
}
type PermissionSubscriber = () => void
const permissionSubscribersByStore = new WeakMap<StoreApi<DirectoryStore>, Map<string, Set<PermissionSubscriber>>>()
type BlockingRequestSubscriber = () => void
type BlockingRequestSubscribers = WeakMap<StoreApi<DirectoryStore>, Map<string, Set<BlockingRequestSubscriber>>>
const permissionSubscribersByStore: BlockingRequestSubscribers = new WeakMap()
const questionSubscribersByStore: BlockingRequestSubscribers = new WeakMap()
type SessionMessageChange = {
messagesChanged: boolean
@@ -72,12 +74,42 @@ export function markDirectorySessionPartChanged(
export function subscribeDirectoryPermission(
store: StoreApi<DirectoryStore>,
sessionID: string,
listener: PermissionSubscriber,
listener: BlockingRequestSubscriber,
): () => void {
let bySession = permissionSubscribersByStore.get(store)
return subscribeBlockingRequest(permissionSubscribersByStore, store, sessionID, listener)
}
export function subscribeDirectoryQuestion(
store: StoreApi<DirectoryStore>,
sessionID: string,
listener: BlockingRequestSubscriber,
): () => void {
return subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
}
export function subscribeDirectoryQuestions(
store: StoreApi<DirectoryStore>,
sessionIDs: readonly string[],
listener: BlockingRequestSubscriber,
): () => void {
const unsubscribers = [...new Set(sessionIDs.filter(Boolean))].map((sessionID) => (
subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
))
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
}
}
function subscribeBlockingRequest(
subscribersByStore: BlockingRequestSubscribers,
store: StoreApi<DirectoryStore>,
sessionID: string,
listener: BlockingRequestSubscriber,
): () => void {
let bySession = subscribersByStore.get(store)
if (!bySession) {
bySession = new Map()
permissionSubscribersByStore.set(store, bySession)
subscribersByStore.set(store, bySession)
}
let listeners = bySession.get(sessionID)
if (!listeners) {
@@ -88,24 +120,28 @@ export function subscribeDirectoryPermission(
return () => {
listeners?.delete(listener)
if (listeners?.size === 0) bySession?.delete(sessionID)
if (bySession?.size === 0) permissionSubscribersByStore.delete(store)
if (bySession?.size === 0) subscribersByStore.delete(store)
}
}
const notifyChangedPermissions = (
const notifyChangedBlockingRequests = <T,>(
subscribersByStore: BlockingRequestSubscribers,
counter: "permissionChangeCallbacks" | "questionChangeCallbacks",
store: StoreApi<DirectoryStore>,
current: State["permission"],
previous: State["permission"],
current: Record<string, T>,
previous: Record<string, T>,
): void => {
if (current === previous) return
const subscribers = permissionSubscribersByStore.get(store)
const subscribers = subscribersByStore.get(store)
if (!subscribers || subscribers.size === 0) return
const changedListeners = new Set<BlockingRequestSubscriber>()
for (const [sessionID, listeners] of subscribers) {
if (current[sessionID] === previous[sessionID]) continue
for (const listener of listeners) {
countSyncPerformance("permissionChangeCallbacks")
listener()
}
for (const listener of listeners) changedListeners.add(listener)
}
for (const listener of changedListeners) {
countSyncPerformance(counter)
listener()
}
}
@@ -239,7 +275,8 @@ function createDirectoryStore(directory: string): StoreApi<DirectoryStore> {
if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta)
if (state.icon !== prev.icon) persistIcon(directory, state.icon)
if (state.session !== prev.session) persistSessions(directory, state.session)
notifyChangedPermissions(store, state.permission, prev.permission)
notifyChangedBlockingRequests(permissionSubscribersByStore, "permissionChangeCallbacks", store, state.permission, prev.permission)
notifyChangedBlockingRequests(questionSubscribersByStore, "questionChangeCallbacks", store, state.question, prev.question)
notifyChangedSessionMessages(store, state, prev)
})
@@ -24,6 +24,7 @@ export type SyncPerformanceCounters = {
streamingHeartbeatAttempts: number
streamingHeartbeatCommits: number
permissionChangeCallbacks: number
questionChangeCallbacks: number
sessionMessageChangeCallbacks: number
sessionRenderableNotificationSkips: number
userMessageHistoryNotificationSkips: number
@@ -57,6 +58,7 @@ const createCounters = (): SyncPerformanceCounters => ({
streamingHeartbeatAttempts: 0,
streamingHeartbeatCommits: 0,
permissionChangeCallbacks: 0,
questionChangeCallbacks: 0,
sessionMessageChangeCallbacks: 0,
sessionRenderableNotificationSkips: 0,
userMessageHistoryNotificationSkips: 0,
+47
View File
@@ -14,6 +14,7 @@ import {
ChildStoreManager,
markDirectorySessionPartChanged,
subscribeDirectoryPermission,
subscribeDirectoryQuestions,
subscribeDirectorySessionMessages,
type DirectoryBootstrapContext,
type DirectoryBootstrapReason,
@@ -1620,6 +1621,12 @@ function handleEvent(
case "session.deleted":
cloneField("session", (value) => [...value])
cloneField("permission", (value) => ({ ...value }))
if (
payload.type === "session.deleted"
|| (payload.type === "session.updated" && Boolean((payload.properties as { info?: Session }).info?.time.archived))
) {
cloneField("question", (value) => ({ ...value }))
}
cloneField("todo", (value) => ({ ...value }))
cloneField("part", (value) => ({ ...value }))
cloneField("sessionEventRevision", (value) => ({ ...(value ?? {}) }))
@@ -2436,6 +2443,46 @@ export function useSessionQuestions(sessionID: string, directory?: string) {
)
}
/**
* Total number of pending questions across the given session scopes. Each
* scope names a directory store plus the session IDs to count inside it, so
* collapsed subtree rows can roll up pending questions of hidden descendants
* from their owning directory stores without bootstrapping them.
*
* Subscribes through the per-session question sidecar channel, so unrelated
* streaming or session activity does not re-render rows.
*/
export function useSessionQuestionCount(scopes: readonly { directory: string; sessionIDs: readonly string[] }[]) {
const { childStores } = useSyncSystem()
const scopedStores = React.useMemo(() => scopes.map((scope) => ({
sessionIDs: scope.sessionIDs,
store: childStores.ensureChild(scope.directory, { bootstrap: false }),
})), [childStores, scopes])
React.useEffect(() => {
for (const scope of scopes) childStores.pin(scope.directory)
return () => {
for (const scope of scopes) childStores.unpin(scope.directory)
}
}, [childStores, scopes])
const getSnapshot = React.useCallback(() => {
let count = 0
for (const { sessionIDs, store } of scopedStores) {
const questions = store.getState().question
for (const sessionID of sessionIDs) count += questions[sessionID]?.length ?? 0
}
return count
}, [scopedStores])
const subscribe = React.useCallback((notify: () => void) => {
const unsubscribers = scopedStores.map(({ sessionIDs, store }) => (
subscribeDirectoryQuestions(store, sessionIDs, notify)
))
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
}
}, [scopedStores])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/** Get sessions list for a directory */
export function useSessions(directory?: string) {
return useDirectorySync(