dsh-recent-usage-metrics 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -2155,12 +2155,12 @@ window.__ModuleLoader__.load({
2155
2155
  * subscribes. Reading it inline during render froze the rows at whatever the first
2156
2156
  * render saw — which is why the drawer showed timestamps minutes behind the log.
2157
2157
  */
2158
- /*
2159
- * The shipped Statistics view's data source for this session. `undefined` when that
2160
- * view is absent — a composition without it — which is what routes the drawer to its
2161
- * Chat-snapshot fallback. No switch decides this: the presence of the source does.
2162
- */
2163
- const statsSource = typeof props.statsSource === 'function' ? props.statsSource() : undefined
2158
+ /*
2159
+ * The shipped Statistics view's data source for this session. `undefined` when that
2160
+ * view is absent — a composition without it — which is what routes the drawer to its
2161
+ * Chat-snapshot fallback. No switch decides this: the presence of the source does.
2162
+ */
2163
+ const statsSource = typeof props.statsSource === 'function' ? props.statsSource() : undefined
2164
2164
  const statsSubscribe = useCallback(
2165
2165
  (listener) => {
2166
2166
  if (statsSource === undefined || typeof statsSource.subscribe !== 'function') {
@@ -2183,10 +2183,45 @@ window.__ModuleLoader__.load({
2183
2183
  [statsSource, sessionId],
2184
2184
  )
2185
2185
  const [statsSnapshot, setStatsSnapshot] = useState(statsRead)
2186
+ /*
2187
+ * Re-point the subscription at whatever target is current, then re-read.
2188
+ *
2189
+ * A conversation that is rebuilt underneath the drawer (a model switch, a reconnect, a
2190
+ * released binding) replaces its `BoundConversation`, and with it the view store the old
2191
+ * `target()` closure captured. Refreshing on every render rebinds the listener to the new
2192
+ * target, so the drawer cannot keep rendering a retired snapshot while the Statistics page
2193
+ * has already moved on.
2194
+ */
2195
+ if (statsSource !== undefined && typeof statsSource.refresh === 'function') {
2196
+ statsSource.refresh()
2197
+ }
2186
2198
  useEffect(() => {
2187
2199
  setStatsSnapshot(statsRead())
2188
2200
  return statsSubscribe(() => setStatsSnapshot(statsRead()))
2189
2201
  }, [statsRead, statsSubscribe])
2202
+ /*
2203
+ * Follow a conversation that is rebuilt underneath the drawer.
2204
+ *
2205
+ * A model switch, a reconnect, or a released binding replaces the session's
2206
+ * `BoundConversation`, and with it the view store the old `target()` closure captured.
2207
+ * That stale closure keeps returning its last snapshot forever — which is exactly how the
2208
+ * drawer froze on one turn while the Statistics page moved on.
2209
+ *
2210
+ * On a slow tick this re-points the listener at the target that is current now and
2211
+ * re-reads the snapshot. Both are cheap (a map lookup and a field read) and neither
2212
+ * depends on the stale chain firing.
2213
+ */
2214
+ useEffect(() => {
2215
+ if (statsSource === undefined) return undefined
2216
+ const sync = () => {
2217
+ if (typeof statsSource.refresh === 'function') statsSource.refresh()
2218
+ const next = statsRead()
2219
+ if (next !== undefined) setStatsSnapshot(next)
2220
+ }
2221
+ sync()
2222
+ const timer = setInterval(sync, 1000)
2223
+ return () => clearInterval(timer)
2224
+ }, [statsSource, statsRead])
2190
2225
  /*
2191
2226
  * A snapshot only counts while it belongs to the session being shown.
2192
2227
  *
@@ -2940,82 +2975,115 @@ window.__ModuleLoader__.load({
2940
2975
  */
2941
2976
  const statsSources = new Map()
2942
2977
  /*
2943
- * The Statistics target for one session, created on first use.
2978
+ * The Statistics target for one session.
2979
+ *
2980
+ * The target object is looked up FRESH on every call rather than cached alongside the
2981
+ * source. `uiConversation.binding(id)` keeps a `BoundConversation` per session, and that
2982
+ * object owns the assembler and the view store; when the conversation is rebuilt — a model
2983
+ * switch, a reconnect, a released binding — the old `target()` closure keeps pointing at
2984
+ * the retired store and returns its last snapshot forever. Caching it froze the drawer on
2985
+ * one turn while the Statistics page moved on.
2944
2986
  *
2945
- * `uiConversation.binding(id)` throws once that session is released, so a cached source
2946
- * can become stale. The cache is therefore bounded and evicts oldest-first, releasing
2947
- * the evicted target's subscription: without that, a long session that visits many
2948
- * conversations would keep one live subscription per conversation visited, and each of
2949
- * those keeps the conversation's fold active.
2987
+ * Only the subscriptions are cached, because those must be released on eviction and
2988
+ * re-subscribing on every render would multiply listeners.
2950
2989
  */
2951
2990
  const STATS_SOURCE_LIMIT = 4
2952
2991
  const statsSourceFor = (sessionId) => {
2953
2992
  if (sessionId === null || sessionId === undefined) return undefined
2954
- if (statsSources.has(sessionId)) {
2993
+ const uiConversation = ctx.get('uiConversation')
2994
+ if (uiConversation === undefined || uiConversation === null) return undefined
2995
+
2996
+ /** The target that is current right now, or undefined when it cannot be resolved. */
2997
+ const targetNow = () => {
2998
+ try {
2999
+ return uiConversation.binding(sessionId).target(STATS_TARGET) ?? undefined
3000
+ } catch {
3001
+ // The session was released (or is inactive) — no statistics for it right now.
3002
+ return undefined
3003
+ }
3004
+ }
3005
+
3006
+ const cached = statsSources.get(sessionId)
3007
+ if (cached !== undefined) {
2955
3008
  // Refresh recency so the active conversation is the last one evicted.
2956
- const existing = statsSources.get(sessionId)
2957
3009
  statsSources.delete(sessionId)
2958
- statsSources.set(sessionId, existing)
2959
- return existing
3010
+ statsSources.set(sessionId, cached)
3011
+ return cached.source
2960
3012
  }
2961
- const uiConversation = ctx.get('uiConversation')
2962
- if (uiConversation === undefined || uiConversation === null) return undefined
2963
- let source
2964
- try {
2965
- const target = uiConversation.binding(sessionId).target(STATS_TARGET)
2966
- if (target === undefined || target === null) return undefined
2967
- /*
2968
- * Track the listener/unsubscribe pairs this source handed to the target, so
2969
- * eviction can drop them. The target's `subscribe` returns its own unsubscribe,
2970
- * but nothing else would call it once the source leaves the cache.
2971
- */
2972
- const subscriptions = new Map()
2973
- source = {
2974
- getSnapshot: () => target.getSnapshot(),
2975
- subscribe: (listener) => {
2976
- const existing = subscriptions.get(listener)
2977
- if (existing !== undefined) return existing.dispose
2978
- const unsubscribe = target.subscribe(listener)
2979
- const dispose = () => {
2980
- if (subscriptions.get(listener)?.unsubscribe !== unsubscribe) return
2981
- subscriptions.delete(listener)
2982
- try {
2983
- unsubscribe()
2984
- } catch {}
2985
- }
2986
- subscriptions.set(listener, { unsubscribe, dispose })
2987
- return dispose
2988
- },
2989
- release: () => {
2990
- for (const [listener, entry] of [...subscriptions]) {
2991
- subscriptions.delete(listener)
2992
- try {
2993
- entry.unsubscribe()
2994
- } catch {}
2995
- }
2996
- },
3013
+
3014
+ /*
3015
+ * Each listener is bound to the target that was current when it subscribed; a listener
3016
+ * whose target has since been replaced is re-bound the next time it asks, which is what
3017
+ * keeps a drawn row tracking the live fold instead of a retired one.
3018
+ */
3019
+ const subscriptions = new Map()
3020
+
3021
+ const bind = (listener) => {
3022
+ const target = targetNow()
3023
+ if (target === undefined) return
3024
+ const entry = subscriptions.get(listener)
3025
+ if (entry !== undefined && entry.target === target) return
3026
+ if (entry !== undefined) {
3027
+ try {
3028
+ entry.unsubscribe()
3029
+ } catch {}
3030
+ subscriptions.delete(listener)
2997
3031
  }
2998
- } catch {
2999
- // The session was released (or is inactive) — no statistics for it.
3000
- return undefined
3032
+ try {
3033
+ subscriptions.set(listener, { target, unsubscribe: target.subscribe(listener) })
3034
+ } catch {}
3035
+ }
3036
+
3037
+ const dispose = (listener) => {
3038
+ const entry = subscriptions.get(listener)
3039
+ if (entry === undefined) return
3040
+ subscriptions.delete(listener)
3041
+ try {
3042
+ entry.unsubscribe()
3043
+ } catch {}
3044
+ }
3045
+
3046
+ const source = {
3047
+ /** Always the newest snapshot the session's own target holds. */
3048
+ getSnapshot: () => {
3049
+ const target = targetNow()
3050
+ return target === undefined ? undefined : target.getSnapshot()
3051
+ },
3052
+ subscribe: (listener) => {
3053
+ bind(listener)
3054
+ return () => dispose(listener)
3055
+ },
3056
+ /**
3057
+ * Re-point every listener at the target that is current now.
3058
+ *
3059
+ * Called on each render: one map lookup per listener, and it is what lets the drawer
3060
+ * follow a conversation that was rebuilt underneath it.
3061
+ */
3062
+ refresh: () => {
3063
+ for (const listener of [...subscriptions.keys()]) bind(listener)
3064
+ },
3065
+ release: () => {
3066
+ for (const listener of [...subscriptions.keys()]) dispose(listener)
3067
+ },
3001
3068
  }
3002
- statsSources.set(sessionId, source)
3069
+
3070
+ statsSources.set(sessionId, { source })
3003
3071
  while (statsSources.size > STATS_SOURCE_LIMIT) {
3004
3072
  const oldest = statsSources.keys().next()
3005
3073
  if (oldest.done === true) break
3006
3074
  const evicted = statsSources.get(oldest.value)
3007
3075
  statsSources.delete(oldest.value)
3008
3076
  try {
3009
- evicted?.release?.()
3077
+ evicted?.source?.release?.()
3010
3078
  } catch {}
3011
3079
  }
3012
3080
  return source
3013
3081
  }
3014
3082
  ctx.effect(
3015
3083
  () => () => {
3016
- for (const source of statsSources.values()) {
3084
+ for (const entry of statsSources.values()) {
3017
3085
  try {
3018
- source.release?.()
3086
+ entry.source.release?.()
3019
3087
  } catch {}
3020
3088
  }
3021
3089
  statsSources.clear()
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "dsh-recent-usage-metrics",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Geek level for the Performance & usage setting: a live reading of the newest request (first-token latency, speed, tokens, total, amortised) plus a drawer listing the last ten, all derived from the shipped Statistics view data",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/hansomgar/dsh-recent-usage-metrics.git"
8
+ },
9
+ "homepage": "https://github.com/hansomgar/dsh-recent-usage-metrics#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/hansomgar/dsh-recent-usage-metrics/issues"
12
+ },
5
13
  "type": "module",
6
14
  "license": "MIT",
7
15
  "main": "./lib/index.js",