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 4bda736a..f4f36c89 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -476,6 +476,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 2ba921aa..a422daf0 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -533,6 +533,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 8b2a1342..7900e334 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -534,6 +534,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 f9465c52..db497268 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -369,6 +369,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 d2c1fed8..b9052c09 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -534,6 +534,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 302fc278..902d2a60 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -534,6 +534,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 a8dacf18..4865d153 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -534,6 +534,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 1387d286..ac04e32d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -534,6 +534,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 3856769b..89bf82d5 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -534,6 +534,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 bb328a56..c08cde70 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -534,6 +534,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 584f0590..b1dcaeb8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -547,6 +547,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 349e10d7..a1d0b3df 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -206,7 +206,7 @@ When `session.idle` or `session.error` settles a session but the trailing assist When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with active tool parts and no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the parts, see openchamber#2577 / anomalyco/opencode#19023). The active parts are finalized locally as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event or refresh supersedes it while a stale `running` refresh cannot regress it. -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. @@ -351,7 +351,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 91f027a1..9613c9da 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, @@ -1633,6 +1634,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 ?? {}) })) @@ -2542,6 +2549,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(