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