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:
@@ -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