Merge pull request #2682 from makeittech/feat/gh-2634-pending-question
feat(sessions): show a pending-question indicator on sessions
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>,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -534,6 +534,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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -534,6 +534,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': 'サブセッションを折りたたむ',
|
||||
|
||||
@@ -534,6 +534,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': '하위 세션 접기',
|
||||
|
||||
@@ -534,6 +534,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',
|
||||
|
||||
@@ -534,6 +534,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",
|
||||
|
||||
@@ -534,6 +534,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": "Згорнути підсесії",
|
||||
|
||||
@@ -534,6 +534,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': '折叠子会话',
|
||||
|
||||
@@ -547,6 +547,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': '摺疊子會話',
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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,
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user