feat(sessions): show a pending-question indicator on session rows

Adds a per-session pending-question badge to sidebar rows, driven by the
live directory-store question state through a dedicated per-session
subscription channel so unrelated streaming never re-renders rows.

Collapsed parent rows roll up pending questions of hidden descendants
from their owning directory stores without bootstrapping them. Question
state is cloned on session delete/archive so badges clear when sessions
disappear. Adds the questionChangeCallbacks sync performance counter,
i18n keys for all locales, and unit tests for the subscription channel
and scope selection.

Fixes #2634
This commit is contained in:
Serhii Dziupin
2026-08-05 13:41:48 +03:00
parent 34c221b07f
commit c1ba631964
20 changed files with 343 additions and 21 deletions
+2 -2
View File
@@ -204,7 +204,7 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
Directory stores also own session-keyed sidecar notification channels for permissions and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
@@ -345,7 +345,7 @@ Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
| Event type | Fields to clone |
|---|---|
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part` |
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part`; archived/deleted sessions also clone `question` |
| `session.diff` | `session_diff` |
| `session.status` | `session_status` |
| `todo.updated` | `todo` |
+128
View File
@@ -4,6 +4,8 @@ import {
ChildStoreManager,
markDirectorySessionPartChanged,
subscribeDirectoryPermission,
subscribeDirectoryQuestion,
subscribeDirectoryQuestions,
subscribeDirectorySessionMessages,
} from './child-store';
import {
@@ -120,6 +122,132 @@ describe('ChildStoreManager permission subscriptions', () => {
});
});
describe('ChildStoreManager question subscriptions', () => {
test('notifies only the owning session and ignores unrelated high-frequency updates', () => {
const manager = new ChildStoreManager();
const child = manager.ensureChild('/workspace', { bootstrap: false });
const notifications = new Map<string, number>();
const unsubscribers = Array.from({ length: 50 }, (_, index) => {
const sessionID = `session-${index}`;
return subscribeDirectoryQuestion(child, sessionID, () => {
notifications.set(sessionID, (notifications.get(sessionID) ?? 0) + 1);
});
});
setSyncPerformanceDiagnosticsEnabled(true);
for (let index = 0; index < 10_000; index += 1) {
child.setState({ part: { [`message-${index}`]: [] } });
}
expect(notifications.size).toBe(0);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(0);
child.setState({ question: { 'session-17': [{ id: 'question-1' }] as never[] } });
expect(notifications.get('session-17')).toBe(1);
expect(notifications.size).toBe(1);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(1);
// A new map that preserves session-17's bucket must not notify it again.
child.setState({ question: { ...child.getState().question, 'session-18': [{ id: 'question-2' }] as never[] } });
expect(notifications.get('session-17')).toBe(1);
expect(notifications.get('session-18')).toBe(1);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(2);
child.setState({ question: {} });
expect(notifications.get('session-17')).toBe(2);
expect(notifications.get('session-18')).toBe(2);
expect(getSyncPerformanceDiagnostics()?.questionChangeCallbacks).toBe(4);
for (const unsubscribe of unsubscribers) unsubscribe();
setSyncPerformanceDiagnosticsEnabled(false);
manager.disposeAll();
});
test('notifies subtree and exact-session rows once for each relevant replacement', () => {
const manager = new ChildStoreManager();
const child = manager.ensureChild('/workspace', { bootstrap: false });
let parentNotifications = 0;
let childNotifications = 0;
const unsubscribeParent = subscribeDirectoryQuestions(child, ['parent', 'child'], () => {
parentNotifications += 1;
});
const unsubscribeChild = subscribeDirectoryQuestion(child, 'child', () => {
childNotifications += 1;
});
const parentQuestions = [{ id: 'question-parent' }] as never[];
const childQuestions = [{ id: 'question-child' }] as never[];
child.setState({ question: { parent: parentQuestions, child: childQuestions } });
expect(parentNotifications).toBe(1);
expect(childNotifications).toBe(1);
child.setState({ part: { message: [] } });
expect(parentNotifications).toBe(1);
expect(childNotifications).toBe(1);
child.setState({
question: {
parent: parentQuestions,
child: [{ id: 'question-child-replacement' }] as never[],
},
});
expect(parentNotifications).toBe(2);
expect(childNotifications).toBe(2);
child.setState({ question: {} });
expect(parentNotifications).toBe(3);
expect(childNotifications).toBe(3);
unsubscribeParent();
unsubscribeChild();
manager.disposeAll();
});
test('aggregates exact question buckets across directory stores', () => {
const manager = new ChildStoreManager();
const parentStore = manager.ensureChild('/repo', { bootstrap: false });
const childStore = manager.ensureChild('/worktrees/feature', { bootstrap: false });
let notifications = 0;
const notify = () => {
notifications += 1;
};
const unsubscribers = [
subscribeDirectoryQuestions(parentStore, ['parent'], notify),
subscribeDirectoryQuestions(childStore, ['child'], notify),
];
const questionCount = () => (
(parentStore.getState().question.parent?.length ?? 0)
+ (childStore.getState().question.child?.length ?? 0)
);
childStore.setState({ question: { child: [{ id: 'child-question' }] as never[] } });
expect(questionCount()).toBe(1);
expect(notifications).toBe(1);
childStore.setState({
question: {
...childStore.getState().question,
unrelated: [{ id: 'unrelated-question' }] as never[],
},
});
expect(questionCount()).toBe(1);
expect(notifications).toBe(1);
parentStore.setState({ question: { parent: [{ id: 'parent-question' }] as never[] } });
expect(questionCount()).toBe(2);
expect(notifications).toBe(2);
for (const unsubscribe of unsubscribers) unsubscribe();
manager.disposeAll();
});
});
describe('ChildStoreManager session message subscriptions', () => {
test('routes annotated part changes only to the owning session', () => {
const manager = new ChildStoreManager();
+52 -15
View File
@@ -14,8 +14,10 @@ export type DirectoryStore = State & {
replace: (next: State) => void
}
type PermissionSubscriber = () => void
const permissionSubscribersByStore = new WeakMap<StoreApi<DirectoryStore>, Map<string, Set<PermissionSubscriber>>>()
type BlockingRequestSubscriber = () => void
type BlockingRequestSubscribers = WeakMap<StoreApi<DirectoryStore>, Map<string, Set<BlockingRequestSubscriber>>>
const permissionSubscribersByStore: BlockingRequestSubscribers = new WeakMap()
const questionSubscribersByStore: BlockingRequestSubscribers = new WeakMap()
type SessionMessageChange = {
messagesChanged: boolean
@@ -72,12 +74,42 @@ export function markDirectorySessionPartChanged(
export function subscribeDirectoryPermission(
store: StoreApi<DirectoryStore>,
sessionID: string,
listener: PermissionSubscriber,
listener: BlockingRequestSubscriber,
): () => void {
let bySession = permissionSubscribersByStore.get(store)
return subscribeBlockingRequest(permissionSubscribersByStore, store, sessionID, listener)
}
export function subscribeDirectoryQuestion(
store: StoreApi<DirectoryStore>,
sessionID: string,
listener: BlockingRequestSubscriber,
): () => void {
return subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
}
export function subscribeDirectoryQuestions(
store: StoreApi<DirectoryStore>,
sessionIDs: readonly string[],
listener: BlockingRequestSubscriber,
): () => void {
const unsubscribers = [...new Set(sessionIDs.filter(Boolean))].map((sessionID) => (
subscribeBlockingRequest(questionSubscribersByStore, store, sessionID, listener)
))
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
}
}
function subscribeBlockingRequest(
subscribersByStore: BlockingRequestSubscribers,
store: StoreApi<DirectoryStore>,
sessionID: string,
listener: BlockingRequestSubscriber,
): () => void {
let bySession = subscribersByStore.get(store)
if (!bySession) {
bySession = new Map()
permissionSubscribersByStore.set(store, bySession)
subscribersByStore.set(store, bySession)
}
let listeners = bySession.get(sessionID)
if (!listeners) {
@@ -88,24 +120,28 @@ export function subscribeDirectoryPermission(
return () => {
listeners?.delete(listener)
if (listeners?.size === 0) bySession?.delete(sessionID)
if (bySession?.size === 0) permissionSubscribersByStore.delete(store)
if (bySession?.size === 0) subscribersByStore.delete(store)
}
}
const notifyChangedPermissions = (
const notifyChangedBlockingRequests = <T,>(
subscribersByStore: BlockingRequestSubscribers,
counter: "permissionChangeCallbacks" | "questionChangeCallbacks",
store: StoreApi<DirectoryStore>,
current: State["permission"],
previous: State["permission"],
current: Record<string, T>,
previous: Record<string, T>,
): void => {
if (current === previous) return
const subscribers = permissionSubscribersByStore.get(store)
const subscribers = subscribersByStore.get(store)
if (!subscribers || subscribers.size === 0) return
const changedListeners = new Set<BlockingRequestSubscriber>()
for (const [sessionID, listeners] of subscribers) {
if (current[sessionID] === previous[sessionID]) continue
for (const listener of listeners) {
countSyncPerformance("permissionChangeCallbacks")
listener()
}
for (const listener of listeners) changedListeners.add(listener)
}
for (const listener of changedListeners) {
countSyncPerformance(counter)
listener()
}
}
@@ -239,7 +275,8 @@ function createDirectoryStore(directory: string): StoreApi<DirectoryStore> {
if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta)
if (state.icon !== prev.icon) persistIcon(directory, state.icon)
if (state.session !== prev.session) persistSessions(directory, state.session)
notifyChangedPermissions(store, state.permission, prev.permission)
notifyChangedBlockingRequests(permissionSubscribersByStore, "permissionChangeCallbacks", store, state.permission, prev.permission)
notifyChangedBlockingRequests(questionSubscribersByStore, "questionChangeCallbacks", store, state.question, prev.question)
notifyChangedSessionMessages(store, state, prev)
})
@@ -24,6 +24,7 @@ export type SyncPerformanceCounters = {
streamingHeartbeatAttempts: number
streamingHeartbeatCommits: number
permissionChangeCallbacks: number
questionChangeCallbacks: number
sessionMessageChangeCallbacks: number
sessionRenderableNotificationSkips: number
userMessageHistoryNotificationSkips: number
@@ -57,6 +58,7 @@ const createCounters = (): SyncPerformanceCounters => ({
streamingHeartbeatAttempts: 0,
streamingHeartbeatCommits: 0,
permissionChangeCallbacks: 0,
questionChangeCallbacks: 0,
sessionMessageChangeCallbacks: 0,
sessionRenderableNotificationSkips: 0,
userMessageHistoryNotificationSkips: 0,
+47
View File
@@ -14,6 +14,7 @@ import {
ChildStoreManager,
markDirectorySessionPartChanged,
subscribeDirectoryPermission,
subscribeDirectoryQuestions,
subscribeDirectorySessionMessages,
type DirectoryBootstrapContext,
type DirectoryBootstrapReason,
@@ -1620,6 +1621,12 @@ function handleEvent(
case "session.deleted":
cloneField("session", (value) => [...value])
cloneField("permission", (value) => ({ ...value }))
if (
payload.type === "session.deleted"
|| (payload.type === "session.updated" && Boolean((payload.properties as { info?: Session }).info?.time.archived))
) {
cloneField("question", (value) => ({ ...value }))
}
cloneField("todo", (value) => ({ ...value }))
cloneField("part", (value) => ({ ...value }))
cloneField("sessionEventRevision", (value) => ({ ...(value ?? {}) }))
@@ -2436,6 +2443,46 @@ export function useSessionQuestions(sessionID: string, directory?: string) {
)
}
/**
* Total number of pending questions across the given session scopes. Each
* scope names a directory store plus the session IDs to count inside it, so
* collapsed subtree rows can roll up pending questions of hidden descendants
* from their owning directory stores without bootstrapping them.
*
* Subscribes through the per-session question sidecar channel, so unrelated
* streaming or session activity does not re-render rows.
*/
export function useSessionQuestionCount(scopes: readonly { directory: string; sessionIDs: readonly string[] }[]) {
const { childStores } = useSyncSystem()
const scopedStores = React.useMemo(() => scopes.map((scope) => ({
sessionIDs: scope.sessionIDs,
store: childStores.ensureChild(scope.directory, { bootstrap: false }),
})), [childStores, scopes])
React.useEffect(() => {
for (const scope of scopes) childStores.pin(scope.directory)
return () => {
for (const scope of scopes) childStores.unpin(scope.directory)
}
}, [childStores, scopes])
const getSnapshot = React.useCallback(() => {
let count = 0
for (const { sessionIDs, store } of scopedStores) {
const questions = store.getState().question
for (const sessionID of sessionIDs) count += questions[sessionID]?.length ?? 0
}
return count
}, [scopedStores])
const subscribe = React.useCallback((notify: () => void) => {
const unsubscribers = scopedStores.map(({ sessionIDs, store }) => (
subscribeDirectoryQuestions(store, sessionIDs, notify)
))
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
}
}, [scopedStores])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/** Get sessions list for a directory */
export function useSessions(directory?: string) {
return useDirectorySync(