diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md
index 4033b4b8..180dd7f0 100644
--- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md
+++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md
@@ -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.
diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
index 6521feba..3b61d2a0 100644
--- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
+++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
@@ -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 {
{pendingPermissionCount}
) : null}
+ {pendingQuestionCount > 0 ? (
+
+
+ {pendingQuestionCount}
+
+ ) : null}
diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts
index 873796bb..fe43a537 100644
--- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts
+++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts
@@ -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 = {
diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts
index 8bb67ce2..3de83638 100644
--- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts
+++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts
@@ -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();
+ 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,
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index 57a22de2..b4c3457e 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -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',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index c77d6a07..abc035b4 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -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',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 72985914..8b1cb42f 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
"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",
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 986a4cd4..d34fead6 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -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',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 0f4da4e7..58b1503c 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
'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': 'サブセッションを折りたたむ',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 0f09fba2..5fa4c718 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
'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': '하위 세션 접기',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index 12f727b1..41bceff7 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
'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',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 10dd7709..76af1d1e 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
"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",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index dd52bcb9..90df90e9 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
"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": "Згорнути підсесії",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 35de2c9c..e6f11141 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -531,6 +531,8 @@ export const dict: Record = {
'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': '折叠子会话',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 6641606b..d71368c7 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -544,6 +544,8 @@ export const dict: Record = {
'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': '摺疊子會話',
diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md
index 5699fc93..13e4f5d7 100644
--- a/packages/ui/src/sync/DOCUMENTATION.md
+++ b/packages/ui/src/sync/DOCUMENTATION.md
@@ -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` |
diff --git a/packages/ui/src/sync/child-store.test.ts b/packages/ui/src/sync/child-store.test.ts
index 1ec3b0dc..64fed08f 100644
--- a/packages/ui/src/sync/child-store.test.ts
+++ b/packages/ui/src/sync/child-store.test.ts
@@ -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();
+ 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();
diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts
index 0b0142de..f3b5b194 100644
--- a/packages/ui/src/sync/child-store.ts
+++ b/packages/ui/src/sync/child-store.ts
@@ -14,8 +14,10 @@ export type DirectoryStore = State & {
replace: (next: State) => void
}
-type PermissionSubscriber = () => void
-const permissionSubscribersByStore = new WeakMap, Map>>()
+type BlockingRequestSubscriber = () => void
+type BlockingRequestSubscribers = WeakMap, Map>>
+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,
sessionID: string,
- listener: PermissionSubscriber,
+ listener: BlockingRequestSubscriber,
): () => void {
- let bySession = permissionSubscribersByStore.get(store)
+ return subscribeBlockingRequest(permissionSubscribersByStore, store, sessionID, listener)
+}
+
+export function subscribeDirectoryQuestion(
+ store: StoreApi,
+ sessionID: string,
+ listener: BlockingRequestSubscriber,
+): () => void {
+ return subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
+}
+
+export function subscribeDirectoryQuestions(
+ store: StoreApi,
+ 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,
+ 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 = (
+ subscribersByStore: BlockingRequestSubscribers,
+ counter: "permissionChangeCallbacks" | "questionChangeCallbacks",
store: StoreApi,
- current: State["permission"],
- previous: State["permission"],
+ current: Record,
+ previous: Record,
): 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()
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 {
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)
})
diff --git a/packages/ui/src/sync/performance-diagnostics.ts b/packages/ui/src/sync/performance-diagnostics.ts
index 063e4697..c7f41823 100644
--- a/packages/ui/src/sync/performance-diagnostics.ts
+++ b/packages/ui/src/sync/performance-diagnostics.ts
@@ -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,
diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx
index b4ff6c19..99e6624b 100644
--- a/packages/ui/src/sync/sync-context.tsx
+++ b/packages/ui/src/sync/sync-context.tsx
@@ -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(