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
+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)
})