@iblai/web-utils 1.11.11 → 1.12.0

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.
@@ -3,27 +3,78 @@ export interface TimeTrackerConfig {
3
3
  onTimeUpdate: (url: string, timeSpent: number) => void;
4
4
  getCurrentUrl: () => string;
5
5
  onRouteChange?: (callback: () => void) => () => void;
6
+ /**
7
+ * Seconds of no user interaction (mouse, keyboard, touch, scroll) after
8
+ * which the user is considered idle and time tracking pauses. Set to 0 to
9
+ * disable idle detection. Defaults to 120 (2 minutes).
10
+ */
11
+ idleTimeoutSeconds?: number;
12
+ /**
13
+ * Pause tracking while the page/tab is hidden (Page Visibility API).
14
+ * Defaults to true.
15
+ */
16
+ pauseOnHidden?: boolean;
17
+ /**
18
+ * Pause tracking while the window loses focus (e.g. user switched to another
19
+ * application or window). Defaults to true.
20
+ */
21
+ pauseOnBlur?: boolean;
6
22
  }
7
23
  export interface TimeTrackerState {
8
24
  currentUrl: string;
9
- startTime: number;
10
25
  intervalId: number | null;
11
26
  routeUnsubscribe: (() => void) | null;
27
+ /** Active time (ms) banked since the last reset. */
28
+ accumulatedMs: number;
29
+ /** Timestamp the current active window started, or null while inactive. */
30
+ activeSince: number | null;
31
+ isVisible: boolean;
32
+ isFocused: boolean;
33
+ isIdle: boolean;
34
+ idleTimerId: number | null;
12
35
  }
13
36
  export declare class TimeTracker {
14
37
  private state;
15
38
  private config;
39
+ private readonly idleTimeoutMs;
40
+ private readonly pauseOnHidden;
41
+ private readonly pauseOnBlur;
42
+ private readonly idleDetectionEnabled;
43
+ private lastActivityAt;
44
+ private readonly handleActivity;
45
+ private readonly handleVisibilityChange;
46
+ private readonly handleWindowFocus;
47
+ private readonly handleWindowBlur;
16
48
  constructor(config: TimeTrackerConfig);
17
49
  private initialize;
18
- private startTracking;
19
- private clearInterval;
20
- private resetTimer;
50
+ /** Whether time should currently be counted. */
51
+ private isActive;
52
+ /**
53
+ * Reconcile the active window with the current activity signals: open a new
54
+ * window when the user becomes active, or bank the elapsed time when they
55
+ * become inactive.
56
+ */
57
+ private syncActiveWindow;
21
58
  private getTimeSpent;
59
+ private resetTimer;
60
+ private startTracking;
61
+ private clearTrackingInterval;
22
62
  private sendTimeUpdate;
23
63
  private handleRouteChange;
64
+ private attachActivityListeners;
65
+ private detachActivityListeners;
66
+ private startIdleTimer;
67
+ private clearIdleTimer;
68
+ private markIdle;
69
+ private onActivity;
70
+ private onVisibilityChange;
71
+ private onWindowFocus;
72
+ private onWindowBlur;
24
73
  pause(): void;
25
74
  resume(): void;
26
75
  destroy(): void;
27
76
  getCurrentUrl(): string;
28
77
  getTimeSpentSinceLastReset(): number;
78
+ /** Whether the user is currently considered active (present & interacting). */
79
+ getIsActive(): boolean;
29
80
  }
package/dist/index.d.ts CHANGED
@@ -1294,29 +1294,80 @@ interface TimeTrackerConfig {
1294
1294
  onTimeUpdate: (url: string, timeSpent: number) => void;
1295
1295
  getCurrentUrl: () => string;
1296
1296
  onRouteChange?: (callback: () => void) => () => void;
1297
+ /**
1298
+ * Seconds of no user interaction (mouse, keyboard, touch, scroll) after
1299
+ * which the user is considered idle and time tracking pauses. Set to 0 to
1300
+ * disable idle detection. Defaults to 120 (2 minutes).
1301
+ */
1302
+ idleTimeoutSeconds?: number;
1303
+ /**
1304
+ * Pause tracking while the page/tab is hidden (Page Visibility API).
1305
+ * Defaults to true.
1306
+ */
1307
+ pauseOnHidden?: boolean;
1308
+ /**
1309
+ * Pause tracking while the window loses focus (e.g. user switched to another
1310
+ * application or window). Defaults to true.
1311
+ */
1312
+ pauseOnBlur?: boolean;
1297
1313
  }
1298
1314
  interface TimeTrackerState {
1299
1315
  currentUrl: string;
1300
- startTime: number;
1301
1316
  intervalId: number | null;
1302
1317
  routeUnsubscribe: (() => void) | null;
1318
+ /** Active time (ms) banked since the last reset. */
1319
+ accumulatedMs: number;
1320
+ /** Timestamp the current active window started, or null while inactive. */
1321
+ activeSince: number | null;
1322
+ isVisible: boolean;
1323
+ isFocused: boolean;
1324
+ isIdle: boolean;
1325
+ idleTimerId: number | null;
1303
1326
  }
1304
1327
  declare class TimeTracker {
1305
1328
  private state;
1306
1329
  private config;
1330
+ private readonly idleTimeoutMs;
1331
+ private readonly pauseOnHidden;
1332
+ private readonly pauseOnBlur;
1333
+ private readonly idleDetectionEnabled;
1334
+ private lastActivityAt;
1335
+ private readonly handleActivity;
1336
+ private readonly handleVisibilityChange;
1337
+ private readonly handleWindowFocus;
1338
+ private readonly handleWindowBlur;
1307
1339
  constructor(config: TimeTrackerConfig);
1308
1340
  private initialize;
1309
- private startTracking;
1310
- private clearInterval;
1311
- private resetTimer;
1341
+ /** Whether time should currently be counted. */
1342
+ private isActive;
1343
+ /**
1344
+ * Reconcile the active window with the current activity signals: open a new
1345
+ * window when the user becomes active, or bank the elapsed time when they
1346
+ * become inactive.
1347
+ */
1348
+ private syncActiveWindow;
1312
1349
  private getTimeSpent;
1350
+ private resetTimer;
1351
+ private startTracking;
1352
+ private clearTrackingInterval;
1313
1353
  private sendTimeUpdate;
1314
1354
  private handleRouteChange;
1355
+ private attachActivityListeners;
1356
+ private detachActivityListeners;
1357
+ private startIdleTimer;
1358
+ private clearIdleTimer;
1359
+ private markIdle;
1360
+ private onActivity;
1361
+ private onVisibilityChange;
1362
+ private onWindowFocus;
1363
+ private onWindowBlur;
1315
1364
  pause(): void;
1316
1365
  resume(): void;
1317
1366
  destroy(): void;
1318
1367
  getCurrentUrl(): string;
1319
1368
  getTimeSpentSinceLastReset(): number;
1369
+ /** Whether the user is currently considered active (present & interacting). */
1370
+ getIsActive(): boolean;
1320
1371
  }
1321
1372
 
1322
1373
  interface UseTimeTrackerConfig {
package/dist/index.esm.js CHANGED
@@ -9180,22 +9180,22 @@ async function syncAuthToCookies(storageService) {
9180
9180
  function hasNonExpiredAuthToken() {
9181
9181
  const token = window.localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
9182
9182
  if (!token) {
9183
- console.log('################### [hasNonExpiredAuthToken] axd token is not defined', token);
9183
+ console.log("################### [hasNonExpiredAuthToken] axd token is not defined", token);
9184
9184
  return false;
9185
9185
  }
9186
9186
  const tokenExpiry = window.localStorage.getItem(LOCAL_STORAGE_KEYS.TOKEN_EXPIRY);
9187
9187
  if (!tokenExpiry) {
9188
- console.log('################### [hasNonExpiredAuthToken] axd token expiry is not defined', tokenExpiry);
9188
+ console.log("################### [hasNonExpiredAuthToken] axd token expiry is not defined", tokenExpiry);
9189
9189
  return true;
9190
9190
  }
9191
9191
  const expiryDate = new Date(tokenExpiry);
9192
9192
  if (isNaN(expiryDate.getTime())) {
9193
- console.log('################### [hasNonExpiredAuthToken] axd token expiry date', expiryDate);
9193
+ console.log("################### [hasNonExpiredAuthToken] axd token expiry date", expiryDate);
9194
9194
  return false;
9195
9195
  }
9196
9196
  const currentDate = new Date();
9197
9197
  if (expiryDate <= currentDate) {
9198
- console.log('################### [hasNonExpiredAuthToken] axd token expiry date is less than current date ', expiryDate, currentDate);
9198
+ console.log("################### [hasNonExpiredAuthToken] axd token expiry date is less than current date ", expiryDate, currentDate);
9199
9199
  return false;
9200
9200
  }
9201
9201
  return true;
@@ -9458,6 +9458,20 @@ function useAuthProvider({ middleware = new Map(), onAuthSuccess, onAuthFailure,
9458
9458
  console.log("[AuthProvider] Redirect already in progress, skipping");
9459
9459
  return;
9460
9460
  }
9461
+ // Defer every auth redirect while a tenant switch is in flight.
9462
+ // handleTenantSwitch() clears the SHARED (cross-tab) localStorage mid-switch,
9463
+ // so every open window transiently sees missing tokens. Without this guard a
9464
+ // bystander window force-logs-out on the absent DM token, which clears
9465
+ // storage + sets a logout-timestamp cookie that wipes the tokens the
9466
+ // switching tab just wrote and cascades the logout across tabs. The TTL lock
9467
+ // (writeTenantSwitchLock) survives the /login/complete -> /sso-login-complete
9468
+ // redirect and is kept fresh across tabs via useTenantSwitchSync, so it
9469
+ // brackets the switch window. The cross-SPA cookie-sync poll keeps running
9470
+ // and recovers once the new tenant's tokens land.
9471
+ if (isTenantSwitchInProgress()) {
9472
+ console.log("[AuthProvider] Tenant switch in progress, deferring auth redirect");
9473
+ return;
9474
+ }
9461
9475
  isRedirectingRef.current = true;
9462
9476
  redirectStartedAtRef.current = Date.now();
9463
9477
  // NOTE: we intentionally do NOT clear the interval here.
@@ -9643,6 +9657,17 @@ function useAuthProvider({ middleware = new Map(), onAuthSuccess, onAuthFailure,
9643
9657
  var _a;
9644
9658
  try {
9645
9659
  setIsAuthenticating(true);
9660
+ // During an in-flight tenant switch the shared, cross-tab localStorage is
9661
+ // transiently cleared (see handleTenantSwitch), so the auth/DM tokens are
9662
+ // legitimately absent. Bail out BEFORE onAuthFailure — some apps hard
9663
+ // navigate to /error/403 from that callback (e.g. skills does
9664
+ // `window.location.href = '/error/403'`), which would fire even though
9665
+ // safeRedirectToAuthSpa itself is guarded. The cookie-sync poll re-checks
9666
+ // once the new tenant's tokens land.
9667
+ if (isTenantSwitchInProgress()) {
9668
+ console.log("[AuthProvider] Tenant switch in progress, skipping auth check");
9669
+ return;
9670
+ }
9646
9671
  const authToken = await (storageService === null || storageService === void 0 ? void 0 : storageService.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN));
9647
9672
  if (authToken && !hasNonExpiredAuthToken()) {
9648
9673
  const reason = "Auth token expired";
@@ -11869,46 +11894,117 @@ const selectIsReasoning = (state) => state.chatSliceShared.currentStreamingMessa
11869
11894
  // NEW: Get streaming tool calls
11870
11895
  const selectStreamingToolCalls = (state) => state.chatSliceShared.currentStreamingMessage.toolCalls;
11871
11896
 
11897
+ const DEFAULT_IDLE_TIMEOUT_SECONDS = 120;
11898
+ // Minimum gap between idle-timer resets. Interaction events like mousemove fire
11899
+ // rapidly; there's no need to restart the (minutes-long) idle countdown more
11900
+ // than once per second.
11901
+ const ACTIVITY_THROTTLE_MS = 1000;
11902
+ // User interactions that count as activity and keep the user "present".
11903
+ const ACTIVITY_EVENTS = [
11904
+ "mousemove",
11905
+ "mousedown",
11906
+ "keydown",
11907
+ "wheel",
11908
+ "scroll",
11909
+ "touchstart",
11910
+ "pointerdown",
11911
+ ];
11872
11912
  class TimeTracker {
11873
11913
  constructor(config) {
11914
+ var _a, _b, _c;
11874
11915
  this.state = {
11875
11916
  currentUrl: "",
11876
- startTime: 0,
11877
11917
  intervalId: null,
11878
11918
  routeUnsubscribe: null,
11919
+ accumulatedMs: 0,
11920
+ activeSince: null,
11921
+ isVisible: true,
11922
+ isFocused: true,
11923
+ isIdle: false,
11924
+ idleTimerId: null,
11879
11925
  };
11926
+ // Timestamp of the last activity that reset the idle timer (for throttling).
11927
+ this.lastActivityAt = 0;
11928
+ // Bound listeners kept so they can be removed on destroy.
11929
+ this.handleActivity = () => this.onActivity();
11930
+ this.handleVisibilityChange = () => this.onVisibilityChange();
11931
+ this.handleWindowFocus = () => this.onWindowFocus();
11932
+ this.handleWindowBlur = () => this.onWindowBlur();
11880
11933
  this.config = config;
11934
+ this.idleTimeoutMs =
11935
+ ((_a = config.idleTimeoutSeconds) !== null && _a !== void 0 ? _a : DEFAULT_IDLE_TIMEOUT_SECONDS) * 1000;
11936
+ this.pauseOnHidden = (_b = config.pauseOnHidden) !== null && _b !== void 0 ? _b : true;
11937
+ this.pauseOnBlur = (_c = config.pauseOnBlur) !== null && _c !== void 0 ? _c : true;
11938
+ this.idleDetectionEnabled =
11939
+ this.idleTimeoutMs > 0 &&
11940
+ typeof window !== "undefined" &&
11941
+ typeof document !== "undefined";
11881
11942
  this.initialize();
11882
11943
  }
11883
11944
  initialize() {
11884
11945
  this.state.currentUrl = this.config.getCurrentUrl();
11946
+ // Seed activity signals from the current environment.
11947
+ if (this.pauseOnHidden && typeof document !== "undefined") {
11948
+ this.state.isVisible = !document.hidden;
11949
+ }
11950
+ this.state.isFocused = true;
11951
+ this.state.isIdle = false;
11952
+ this.attachActivityListeners();
11885
11953
  this.startTracking();
11954
+ this.startIdleTimer();
11886
11955
  if (this.config.onRouteChange) {
11887
11956
  this.state.routeUnsubscribe = this.config.onRouteChange(() => {
11888
11957
  this.handleRouteChange();
11889
11958
  });
11890
11959
  }
11891
11960
  }
11961
+ // --- Active time accounting -------------------------------------------------
11962
+ /** Whether time should currently be counted. */
11963
+ isActive() {
11964
+ return this.state.isVisible && this.state.isFocused && !this.state.isIdle;
11965
+ }
11966
+ /**
11967
+ * Reconcile the active window with the current activity signals: open a new
11968
+ * window when the user becomes active, or bank the elapsed time when they
11969
+ * become inactive.
11970
+ */
11971
+ syncActiveWindow() {
11972
+ const active = this.isActive();
11973
+ if (active && this.state.activeSince === null) {
11974
+ this.state.activeSince = Date.now();
11975
+ }
11976
+ else if (!active && this.state.activeSince !== null) {
11977
+ this.state.accumulatedMs += Date.now() - this.state.activeSince;
11978
+ this.state.activeSince = null;
11979
+ }
11980
+ }
11981
+ getTimeSpent() {
11982
+ let total = this.state.accumulatedMs;
11983
+ if (this.state.activeSince !== null) {
11984
+ total += Date.now() - this.state.activeSince;
11985
+ }
11986
+ return total;
11987
+ }
11988
+ resetTimer() {
11989
+ this.state.accumulatedMs = 0;
11990
+ this.state.activeSince = this.isActive() ? Date.now() : null;
11991
+ }
11892
11992
  startTracking() {
11893
- this.state.startTime = Date.now();
11894
- this.clearInterval();
11993
+ this.clearTrackingInterval();
11994
+ this.resetTimer();
11995
+ if (typeof window === "undefined")
11996
+ return;
11895
11997
  this.state.intervalId = window.setInterval(() => {
11896
11998
  this.sendTimeUpdate();
11897
11999
  this.resetTimer();
11898
12000
  }, this.config.intervalSeconds * 1000);
11899
12001
  }
11900
- clearInterval() {
12002
+ clearTrackingInterval() {
11901
12003
  if (this.state.intervalId !== null) {
11902
12004
  clearInterval(this.state.intervalId);
11903
12005
  this.state.intervalId = null;
11904
12006
  }
11905
12007
  }
11906
- resetTimer() {
11907
- this.state.startTime = Date.now();
11908
- }
11909
- getTimeSpent() {
11910
- return Date.now() - this.state.startTime;
11911
- }
11912
12008
  sendTimeUpdate() {
11913
12009
  const timeSpent = this.getTimeSpent();
11914
12010
  if (timeSpent > 0) {
@@ -11923,16 +12019,116 @@ class TimeTracker {
11923
12019
  this.resetTimer();
11924
12020
  }
11925
12021
  }
12022
+ // --- Activity / inactivity detection ---------------------------------------
12023
+ attachActivityListeners() {
12024
+ if (typeof window !== "undefined" && window.addEventListener) {
12025
+ if (this.idleDetectionEnabled) {
12026
+ for (const event of ACTIVITY_EVENTS) {
12027
+ window.addEventListener(event, this.handleActivity, {
12028
+ passive: true,
12029
+ });
12030
+ }
12031
+ }
12032
+ if (this.pauseOnBlur) {
12033
+ window.addEventListener("focus", this.handleWindowFocus);
12034
+ window.addEventListener("blur", this.handleWindowBlur);
12035
+ }
12036
+ }
12037
+ if (this.pauseOnHidden &&
12038
+ typeof document !== "undefined" &&
12039
+ document.addEventListener) {
12040
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
12041
+ }
12042
+ }
12043
+ detachActivityListeners() {
12044
+ if (typeof window !== "undefined" && window.removeEventListener) {
12045
+ for (const event of ACTIVITY_EVENTS) {
12046
+ window.removeEventListener(event, this.handleActivity);
12047
+ }
12048
+ window.removeEventListener("focus", this.handleWindowFocus);
12049
+ window.removeEventListener("blur", this.handleWindowBlur);
12050
+ }
12051
+ if (typeof document !== "undefined" && document.removeEventListener) {
12052
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
12053
+ }
12054
+ }
12055
+ startIdleTimer() {
12056
+ if (!this.idleDetectionEnabled)
12057
+ return;
12058
+ this.clearIdleTimer();
12059
+ this.lastActivityAt = Date.now();
12060
+ this.state.idleTimerId = window.setTimeout(() => {
12061
+ this.markIdle();
12062
+ }, this.idleTimeoutMs);
12063
+ }
12064
+ clearIdleTimer() {
12065
+ if (this.state.idleTimerId !== null) {
12066
+ clearTimeout(this.state.idleTimerId);
12067
+ this.state.idleTimerId = null;
12068
+ }
12069
+ }
12070
+ markIdle() {
12071
+ if (this.state.isIdle)
12072
+ return;
12073
+ this.state.isIdle = true;
12074
+ this.syncActiveWindow();
12075
+ }
12076
+ onActivity() {
12077
+ // Returning from idle must always restart the countdown immediately.
12078
+ if (this.state.isIdle) {
12079
+ this.state.isIdle = false;
12080
+ this.syncActiveWindow();
12081
+ this.startIdleTimer();
12082
+ return;
12083
+ }
12084
+ // Otherwise throttle: no need to reset a minutes-long timer on every
12085
+ // mousemove.
12086
+ if (Date.now() - this.lastActivityAt < ACTIVITY_THROTTLE_MS)
12087
+ return;
12088
+ this.startIdleTimer();
12089
+ }
12090
+ onVisibilityChange() {
12091
+ if (typeof document === "undefined")
12092
+ return;
12093
+ this.state.isVisible = !document.hidden;
12094
+ if (this.state.isVisible) {
12095
+ // Returning to the tab counts as being present again.
12096
+ this.state.isIdle = false;
12097
+ this.startIdleTimer();
12098
+ }
12099
+ this.syncActiveWindow();
12100
+ }
12101
+ onWindowFocus() {
12102
+ this.state.isFocused = true;
12103
+ this.state.isIdle = false;
12104
+ this.startIdleTimer();
12105
+ this.syncActiveWindow();
12106
+ }
12107
+ onWindowBlur() {
12108
+ this.state.isFocused = false;
12109
+ this.syncActiveWindow();
12110
+ }
12111
+ // --- Public API -------------------------------------------------------------
11926
12112
  pause() {
11927
12113
  this.sendTimeUpdate();
11928
- this.clearInterval();
12114
+ this.clearTrackingInterval();
12115
+ this.clearIdleTimer();
12116
+ // Stop accounting until resumed.
12117
+ this.state.accumulatedMs = 0;
12118
+ this.state.activeSince = null;
11929
12119
  }
11930
12120
  resume() {
12121
+ // Treat resume as a fresh, present start so a stale idle flag can't keep
12122
+ // the tracker from counting.
12123
+ this.state.isIdle = false;
11931
12124
  this.startTracking();
12125
+ this.startIdleTimer();
11932
12126
  }
11933
12127
  destroy() {
11934
12128
  this.sendTimeUpdate();
11935
- this.clearInterval();
12129
+ this.clearTrackingInterval();
12130
+ this.clearIdleTimer();
12131
+ this.detachActivityListeners();
11936
12132
  if (this.state.routeUnsubscribe) {
11937
12133
  this.state.routeUnsubscribe();
11938
12134
  this.state.routeUnsubscribe = null;
@@ -11944,6 +12140,10 @@ class TimeTracker {
11944
12140
  getTimeSpentSinceLastReset() {
11945
12141
  return this.getTimeSpent();
11946
12142
  }
12143
+ /** Whether the user is currently considered active (present & interacting). */
12144
+ getIsActive() {
12145
+ return this.isActive();
12146
+ }
11947
12147
  }
11948
12148
 
11949
12149
  function useTimeTracker(config) {