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
+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(