cue-console 0.1.21 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cue-console",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Cue Hub console launcher (Next.js UI)",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": ["mcp", "cue", "console", "nextjs"],
@@ -209,6 +209,12 @@
209
209
  background-size: 140px 140px;
210
210
  }
211
211
 
212
+ /* Text wrapping utilities for mobile responsiveness */
213
+ .overflow-wrap-anywhere {
214
+ overflow-wrap: anywhere;
215
+ word-break: break-word;
216
+ }
217
+
212
218
  /* Markdown document-flow: harden list/paragraph spacing to avoid regressions */
213
219
  .md-flow {
214
220
  max-width: 100%;
package/src/app/page.tsx CHANGED
@@ -6,6 +6,7 @@ import { ChatView } from "@/components/chat-view";
6
6
  import { CreateGroupDialog } from "@/components/create-group-dialog";
7
7
  import { MessageCircle } from "lucide-react";
8
8
  import { claimWorkerLease, fetchBotEnabledConversations, processBotTick, processQueueTick } from "@/lib/actions";
9
+ import "@/lib/perf-monitor"; // Auto-starts if enabled in localStorage
9
10
 
10
11
  export default function Home() {
11
12
  const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -126,19 +126,19 @@ export function MessageBubble({
126
126
  ) : (
127
127
  <span className="h-9 w-9 shrink-0" />
128
128
  )}
129
- <div className="flex-1 min-w-0 overflow-hidden">
129
+ <div className="flex-1 min-w-0">
130
130
  {(showName ?? true) && (showAgent || displayName) && (
131
131
  <p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
132
132
  )}
133
133
  <div
134
134
  className={cn(
135
- "rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
135
+ "rounded-3xl p-3 sm:p-4 w-full",
136
136
  "glass-surface-soft glass-noise",
137
137
  isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
138
138
  )}
139
139
  style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
140
140
  >
141
- <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
141
+ <div className="text-sm overflow-wrap-anywhere">
142
142
  <MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
143
143
  </div>
144
144
  <PayloadCard
@@ -103,7 +103,7 @@ export function UserResponseBubble({
103
103
  return (
104
104
  <div className="flex justify-end gap-3 max-w-full min-w-0">
105
105
  <div
106
- className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
106
+ className="rounded-3xl p-3 sm:p-4 w-full sm:max-w-215 sm:w-fit glass-surface-soft glass-noise ring-1 ring-white/25"
107
107
  style={{
108
108
  clipPath: "inset(0 round 1rem)",
109
109
  maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
@@ -119,14 +119,14 @@ export function UserResponseBubble({
119
119
  return (
120
120
  <div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
121
121
  <div
122
- className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
122
+ className="rounded-3xl p-3 sm:p-4 w-full sm:max-w-215 sm:w-fit glass-surface-soft glass-noise ring-1 ring-white/25"
123
123
  style={{
124
124
  clipPath: "inset(0 round 1rem)",
125
125
  maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
126
126
  }}
127
127
  >
128
128
  {displayText && (
129
- <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
129
+ <div className="text-sm overflow-wrap-anywhere">
130
130
  {parsed.mentions && parsed.mentions.length > 0 ? (
131
131
  <p className="whitespace-pre-wrap">
132
132
  {renderTextWithMentions(displayText, parsed.mentions)}
@@ -78,6 +78,8 @@ export function ChatComposer({
78
78
  setImages,
79
79
  setPreviewImage,
80
80
  botEnabled,
81
+ botLoaded,
82
+ botLoadError,
81
83
  onToggleBot,
82
84
  handleSend,
83
85
  enqueueCurrent,
@@ -121,6 +123,8 @@ export function ChatComposer({
121
123
  setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
122
124
  setPreviewImage: Dispatch<SetStateAction<{ mime_type: string; base64_data: string } | null>>;
123
125
  botEnabled: boolean;
126
+ botLoaded: boolean;
127
+ botLoadError: string | null;
124
128
  onToggleBot: () => Promise<boolean>;
125
129
  handleSend: () => void | Promise<void>;
126
130
  enqueueCurrent: () => void;
@@ -677,47 +681,86 @@ export function ChatComposer({
677
681
  Queue
678
682
  </Button>
679
683
 
680
- <div className="relative group">
681
- <Button
682
- type="button"
683
- variant="ghost"
684
- size="icon"
685
- disabled={busy || botToggling}
684
+ <button
685
+ type="button"
686
+ disabled={busy || botToggling || !botLoaded}
687
+ className={cn(
688
+ "relative h-8 px-3 rounded-xl transition-all duration-200",
689
+ "flex items-center gap-1.5",
690
+ "disabled:cursor-not-allowed disabled:opacity-50",
691
+ botEnabled
692
+ ? "bg-emerald-500 hover:bg-emerald-600 text-white shadow-lg shadow-emerald-500/25"
693
+ : botLoadError
694
+ ? "bg-red-500/10 hover:bg-red-500/20 text-red-500 ring-1 ring-red-500/30"
695
+ : "bg-white/10 hover:bg-white/20 text-muted-foreground"
696
+ )}
697
+ onClick={async () => {
698
+ if (busy || botToggling) return;
699
+ if (!botLoaded) return;
700
+ if (!botEnabled) {
701
+ setBotConfirmOpen(true);
702
+ return;
703
+ }
704
+ setBotToggling(true);
705
+ try {
706
+ await onToggleBot();
707
+ } finally {
708
+ setBotToggling(false);
709
+ }
710
+ }}
711
+ aria-label={botEnabled ? "Stop bot" : "Start bot"}
712
+ title={
713
+ !botLoaded
714
+ ? "Bot status loading…"
715
+ : botToggling
716
+ ? "Turning…"
717
+ : botLoadError
718
+ ? "Bot state sync error"
719
+ : botEnabled
720
+ ? "Bot is active - click to stop"
721
+ : "Start bot mode"
722
+ }
723
+ >
724
+ {/* Custom SVG icon */}
725
+ <svg
686
726
  className={cn(
687
- "relative h-9 w-9 rounded-2xl",
688
- "hover:bg-white/40",
689
- botEnabled ? "text-primary" : "text-muted-foreground",
690
- (busy || botToggling) && "opacity-60 cursor-not-allowed"
727
+ "w-4 h-4 transition-transform duration-200",
728
+ botEnabled && "scale-110"
691
729
  )}
692
- onClick={async () => {
693
- if (busy || botToggling) return;
694
- if (!botEnabled) {
695
- setBotConfirmOpen(true);
696
- return;
697
- }
698
- setBotToggling(true);
699
- try {
700
- await onToggleBot();
701
- } finally {
702
- setBotToggling(false);
703
- }
704
- }}
705
- aria-label={botEnabled ? "Stop bot" : "Start bot"}
706
- title={botToggling ? "Turning…" : botEnabled ? "Stop bot" : "Start bot"}
730
+ viewBox="0 0 24 24"
731
+ fill="none"
732
+ stroke="currentColor"
733
+ strokeWidth="2"
734
+ strokeLinecap="round"
735
+ strokeLinejoin="round"
707
736
  >
708
- {botEnabled && (
709
- <span className="pointer-events-none absolute inset-0 rounded-xl">
710
- <span className="absolute inset-0 rounded-2xl bg-primary/15 blur-md animate-pulse" />
711
- </span>
712
- )}
713
- <Bot
714
- className={cn(
715
- "relative z-10 h-5 w-5",
716
- botEnabled && "drop-shadow-[0_0_12px_rgba(99,102,241,0.45)]"
717
- )}
718
- />
719
- </Button>
720
- </div>
737
+ {/* Robot head */}
738
+ <rect x="6" y="8" width="12" height="10" rx="2" />
739
+ {/* Antenna */}
740
+ <path d="M12 8V5" />
741
+ <circle cx="12" cy="4" r="1" fill="currentColor" />
742
+ {/* Eyes */}
743
+ <circle cx="9.5" cy="12" r="1" fill="currentColor" />
744
+ <circle cx="14.5" cy="12" r="1" fill="currentColor" />
745
+ {/* Mouth */}
746
+ <path d="M9 15h6" />
747
+ {/* Arms */}
748
+ <path d="M6 13H4" />
749
+ <path d="M20 13h-2" />
750
+ </svg>
751
+
752
+ <span className="text-xs font-medium">
753
+ {botToggling ? "..." : botEnabled ? "ON" : "Bot"}
754
+ </span>
755
+
756
+ {/* Status indicator dot */}
757
+ {!botLoaded && (
758
+ <span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" />
759
+ )}
760
+ {botLoadError && (
761
+ <span className="w-1.5 h-1.5 rounded-full bg-red-500" />
762
+ )}
763
+ </button>
721
764
  </div>
722
765
 
723
766
  <Button
@@ -80,6 +80,8 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
80
80
  const imagesRef = useRef(images);
81
81
 
82
82
  const [botEnabled, setBotEnabled] = useState(false);
83
+ const [botLoaded, setBotLoaded] = useState(false);
84
+ const [botLoadError, setBotLoadError] = useState<string | null>(null);
83
85
 
84
86
  const { soundEnabled, setSoundEnabled, playDing } = useAudioNotification();
85
87
 
@@ -119,6 +121,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
119
121
  })()
120
122
  );
121
123
  const botTickBusyRef = useRef(false);
124
+ const currentConvRef = useRef({ type, id });
122
125
 
123
126
  const nextCursorRef = useRef<string | null>(null);
124
127
  const loadingMoreRef = useRef(false);
@@ -282,11 +285,16 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
282
285
  }, [id, refreshLatest, setNotice, type]);
283
286
 
284
287
  const toggleBot = useCallback(async (): Promise<boolean> => {
288
+ if (!botLoaded) {
289
+ setNotice("Bot status is still loading");
290
+ return botEnabled;
291
+ }
285
292
  const prev = botEnabled;
286
293
  const next = !prev;
287
294
  try {
288
295
  await updateBotEnabled(type, id, next);
289
296
  setBotEnabled(next);
297
+ setBotLoadError(null);
290
298
  if (next) void triggerBotTickOnce();
291
299
  return next;
292
300
  } catch {
@@ -294,24 +302,72 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
294
302
  setNotice("Failed to toggle bot");
295
303
  return prev;
296
304
  }
297
- }, [botEnabled, id, setNotice, triggerBotTickOnce, type]);
305
+ }, [botEnabled, botLoaded, id, setNotice, triggerBotTickOnce, type]);
298
306
 
299
307
  useEffect(() => {
300
308
  let cancelled = false;
309
+ // Update current conversation ref
310
+ currentConvRef.current = { type, id };
311
+
312
+ // Immediately reset bot state when switching conversations
313
+ setBotEnabled(false);
314
+ setBotLoaded(false);
315
+ setBotLoadError(null);
316
+ botTickBusyRef.current = false;
317
+
301
318
  void (async () => {
302
319
  try {
303
320
  const res = await fetchBotEnabled(type, id);
304
- if (cancelled) return;
321
+ // Check if conversation hasn't changed during async operation
322
+ if (cancelled || currentConvRef.current.type !== type || currentConvRef.current.id !== id) return;
305
323
  setBotEnabled(Boolean(res.enabled));
324
+ setBotLoaded(true);
325
+ setBotLoadError(null);
306
326
  } catch {
307
- if (cancelled) return;
327
+ if (cancelled || currentConvRef.current.type !== type || currentConvRef.current.id !== id) return;
308
328
  setBotEnabled(false);
329
+ setBotLoaded(true);
330
+ setBotLoadError("Failed to sync bot state");
331
+ setNotice("Failed to sync bot state (may still be enabled in background)");
309
332
  }
310
333
  })();
311
334
  return () => {
312
335
  cancelled = true;
313
336
  };
314
- }, [id, type]);
337
+ }, [id, type, setNotice]);
338
+
339
+ useEffect(() => {
340
+ if (!botLoaded) return;
341
+ let cancelled = false;
342
+
343
+ const tick = async () => {
344
+ if (cancelled) return;
345
+ if (document.visibilityState !== "visible") return;
346
+ try {
347
+ const res = await fetchBotEnabled(type, id);
348
+ // Check if conversation hasn't changed during async operation
349
+ if (cancelled || currentConvRef.current.type !== type || currentConvRef.current.id !== id) return;
350
+ const next = Boolean(res.enabled);
351
+ setBotEnabled(next);
352
+ setBotLoadError(null);
353
+ } catch {
354
+ if (cancelled || currentConvRef.current.type !== type || currentConvRef.current.id !== id) return;
355
+ setBotLoadError("Failed to sync bot state");
356
+ }
357
+ };
358
+
359
+ const interval = setInterval(() => void tick(), 3000);
360
+ const onVisibilityChange = () => {
361
+ if (document.visibilityState === "visible") void tick();
362
+ };
363
+ document.addEventListener("visibilitychange", onVisibilityChange);
364
+
365
+ return () => {
366
+ cancelled = true;
367
+ document.removeEventListener("visibilitychange", onVisibilityChange);
368
+ clearInterval(interval);
369
+ };
370
+ }, [botLoaded, id, type]);
315
371
 
316
372
  useEffect(() => {
317
373
  if (!botEnabled) return;
@@ -802,7 +858,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
802
858
  onBack={onBack}
803
859
  busy={busy}
804
860
  canSend={canSend}
805
- hasPendingRequests={hasPendingRequests}
861
+ hasPendingRequests={pendingRequests.length > 0}
806
862
  input={input}
807
863
  conversationMode={conversationMode}
808
864
  setConversationMode={setConversationMode}
@@ -811,8 +867,10 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
811
867
  setImages={setImages}
812
868
  setPreviewImage={setPreviewImage}
813
869
  botEnabled={botEnabled}
870
+ botLoaded={botLoaded}
871
+ botLoadError={botLoadError}
814
872
  onToggleBot={toggleBot}
815
- handleSend={send}
873
+ handleSend={() => void send()}
816
874
  enqueueCurrent={enqueueCurrent}
817
875
  queue={queue}
818
876
  removeQueued={removeQueued}
@@ -834,7 +892,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
834
892
  pointerInMentionRef={pointerInMentionRef}
835
893
  mentionScrollTopRef={mentionScrollTopRef}
836
894
  closeMention={closeMention}
837
- insertMention={insertMention}
895
+ insertMention={insertMentionAtCursor}
838
896
  updateMentionFromCursor={updateMentionFromCursor}
839
897
  draftMentions={mentions}
840
898
  setDraftMentions={setMentions}
@@ -332,14 +332,19 @@ export function ConversationList({
332
332
  };
333
333
  }, [ensureAvatarUrl, items]);
334
334
 
335
+ const loadDataRef = useRef(loadData);
336
+ useEffect(() => {
337
+ loadDataRef.current = loadData;
338
+ }, [loadData]);
339
+
335
340
  useEffect(() => {
336
341
  const t0 = setTimeout(() => {
337
- void loadData();
342
+ void loadDataRef.current();
338
343
  }, 0);
339
344
 
340
345
  const tick = () => {
341
346
  if (document.visibilityState !== "visible") return;
342
- void loadData();
347
+ void loadDataRef.current();
343
348
  };
344
349
 
345
350
  const interval = setInterval(tick, 10_000);
@@ -355,7 +360,7 @@ export function ConversationList({
355
360
  clearInterval(interval);
356
361
  document.removeEventListener("visibilitychange", onVisibilityChange);
357
362
  };
358
- }, [loadData]);
363
+ }, []);
359
364
 
360
365
  useEffect(() => {
361
366
  if (!menu.open) return;
@@ -500,6 +505,16 @@ export function ConversationList({
500
505
  setPendingDelete([]);
501
506
  }, []);
502
507
 
508
+ useEffect(() => {
509
+ return () => {
510
+ // Clean up all pending delete timers on unmount
511
+ for (const timer of deleteTimersRef.current.values()) {
512
+ clearTimeout(timer);
513
+ }
514
+ deleteTimersRef.current.clear();
515
+ };
516
+ }, []);
517
+
503
518
  useEffect(() => {
504
519
  if (pendingDelete.length > 0) {
505
520
  queueMicrotask(() => setUndoToastKey((v) => v + 1));
@@ -45,6 +45,16 @@ export function useConversationTimeline({
45
45
 
46
46
  const loadSeqRef = useRef(0);
47
47
  const pendingNonPauseSeenRef = useRef<Set<string>>(new Set());
48
+ const soundEnabledRef = useRef(soundEnabled);
49
+ const playDingRef = useRef(playDing);
50
+
51
+ useEffect(() => {
52
+ soundEnabledRef.current = soundEnabled;
53
+ }, [soundEnabled]);
54
+
55
+ useEffect(() => {
56
+ playDingRef.current = playDing;
57
+ }, [playDing]);
48
58
 
49
59
  const keyForItem = useCallback((item: AgentTimelineItem) => {
50
60
  return item.item_type === "request"
@@ -115,7 +125,7 @@ export function useConversationTimeline({
115
125
  const { items } = await fetchPage(null, pageSize);
116
126
  const asc = [...items].reverse();
117
127
 
118
- if (document.visibilityState === "visible" && soundEnabled) {
128
+ if (document.visibilityState === "visible" && soundEnabledRef.current) {
119
129
  const seen = pendingNonPauseSeenRef.current;
120
130
  let shouldDing = false;
121
131
  for (const it of asc) {
@@ -129,7 +139,7 @@ export function useConversationTimeline({
129
139
  }
130
140
  }
131
141
  if (shouldDing) {
132
- void playDing();
142
+ void playDingRef.current();
133
143
  }
134
144
  }
135
145
 
@@ -147,7 +157,7 @@ export function useConversationTimeline({
147
157
  } catch (e) {
148
158
  setError(e instanceof Error ? e.message : String(e));
149
159
  }
150
- }, [fetchPage, isPauseRequest, keyForItem, pageSize, playDing, setError, soundEnabled]);
160
+ }, [fetchPage, isPauseRequest, keyForItem, pageSize, setError]);
151
161
 
152
162
  const loadMore = useCallback(
153
163
  async (before: string) => {
@@ -198,10 +198,15 @@ export function useMessageQueue({
198
198
  return () => window.removeEventListener("cue-console:queueUpdated", onQueueUpdated);
199
199
  }, [refreshQueue]);
200
200
 
201
+ const refreshQueueRef = useRef(refreshQueue);
202
+ useEffect(() => {
203
+ refreshQueueRef.current = refreshQueue;
204
+ }, [refreshQueue]);
205
+
201
206
  useEffect(() => {
202
207
  const tick = () => {
203
208
  if (document.visibilityState !== "visible") return;
204
- void refreshQueue();
209
+ void refreshQueueRef.current();
205
210
  };
206
211
 
207
212
  const interval = setInterval(tick, 10_000);
@@ -216,7 +221,7 @@ export function useMessageQueue({
216
221
  document.removeEventListener("visibilitychange", onVisibilityChange);
217
222
  clearInterval(interval);
218
223
  };
219
- }, [refreshQueue]);
224
+ }, []);
220
225
 
221
226
  return {
222
227
  queue,
package/src/lib/db.ts CHANGED
@@ -8,18 +8,43 @@ import type { UserResponse, Group } from "./types";
8
8
  const DB_PATH = join(homedir(), ".cue", "cue.db");
9
9
 
10
10
  let db: Database.Database | null = null;
11
+ let lastCheckpoint = 0;
11
12
 
12
13
  export function getDb(): Database.Database {
13
14
  if (!db) {
14
15
  mkdirSync(join(homedir(), ".cue"), { recursive: true });
15
16
  db = new Database(DB_PATH);
16
17
  db.pragma("journal_mode = WAL");
18
+ db.pragma("wal_autocheckpoint = 1000");
17
19
  initTables();
18
20
  }
19
21
 
22
+ // Periodic WAL checkpoint to prevent unbounded growth
23
+ const now = Date.now();
24
+ if (now - lastCheckpoint > 60000) {
25
+ lastCheckpoint = now;
26
+ try {
27
+ db.pragma("wal_checkpoint(PASSIVE)");
28
+ } catch {
29
+ // ignore checkpoint errors
30
+ }
31
+ }
32
+
20
33
  return db;
21
34
  }
22
35
 
36
+ export function closeDb(): void {
37
+ if (db) {
38
+ try {
39
+ db.pragma("wal_checkpoint(TRUNCATE)");
40
+ db.close();
41
+ } catch {
42
+ // ignore
43
+ }
44
+ db = null;
45
+ }
46
+ }
47
+
23
48
  function filesRootDir(): string {
24
49
  return join(homedir(), ".cue", "files");
25
50
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Performance monitoring utility to track interval leaks and resource usage
3
+ * Usage: Open browser console and run: localStorage.setItem('cue-console:perf-monitor', '1')
4
+ */
5
+
6
+ interface IntervalStats {
7
+ count: number;
8
+ lastCheck: number;
9
+ }
10
+
11
+ class PerformanceMonitor {
12
+ private originalSetInterval: typeof setInterval;
13
+ private originalClearInterval: typeof clearInterval;
14
+ private activeIntervals = new Map<number, { stack: string; created: number }>();
15
+ private stats: IntervalStats = { count: 0, lastCheck: Date.now() };
16
+ private monitorInterval: ReturnType<typeof setInterval> | null = null;
17
+
18
+ constructor() {
19
+ this.originalSetInterval = window.setInterval.bind(window);
20
+ this.originalClearInterval = window.clearInterval.bind(window);
21
+ }
22
+
23
+ start() {
24
+ if (this.monitorInterval) return;
25
+
26
+ // Intercept setInterval
27
+ window.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
28
+ const stack = new Error().stack || '';
29
+ const id = this.originalSetInterval(handler, timeout, ...args);
30
+ this.activeIntervals.set(id as number, {
31
+ stack,
32
+ created: Date.now(),
33
+ });
34
+ return id;
35
+ }) as typeof setInterval;
36
+
37
+ // Intercept clearInterval
38
+ window.clearInterval = ((id?: number) => {
39
+ if (id !== undefined) {
40
+ this.activeIntervals.delete(id);
41
+ }
42
+ this.originalClearInterval(id);
43
+ }) as typeof clearInterval;
44
+
45
+ // Monitor every 10 seconds
46
+ this.monitorInterval = this.originalSetInterval(() => {
47
+ this.report();
48
+ }, 10000);
49
+
50
+ console.log('[PerfMonitor] Started tracking intervals');
51
+ }
52
+
53
+ stop() {
54
+ if (!this.monitorInterval) return;
55
+
56
+ // Restore original functions
57
+ window.setInterval = this.originalSetInterval;
58
+ window.clearInterval = this.originalClearInterval;
59
+
60
+ this.originalClearInterval(this.monitorInterval);
61
+ this.monitorInterval = null;
62
+
63
+ console.log('[PerfMonitor] Stopped tracking intervals');
64
+ }
65
+
66
+ report() {
67
+ const now = Date.now();
68
+ const elapsed = (now - this.stats.lastCheck) / 1000;
69
+ const currentCount = this.activeIntervals.size;
70
+ const delta = currentCount - this.stats.count;
71
+
72
+ console.group(`[PerfMonitor] Interval Report (${elapsed.toFixed(1)}s elapsed)`);
73
+ console.log(`Active intervals: ${currentCount} (${delta >= 0 ? '+' : ''}${delta})`);
74
+
75
+ if (delta > 0) {
76
+ console.warn(`⚠️ Interval leak detected! ${delta} new intervals not cleaned up`);
77
+
78
+ // Show recent intervals
79
+ const recent = Array.from(this.activeIntervals.entries())
80
+ .filter(([, info]) => now - info.created < 15000)
81
+ .slice(0, 5);
82
+
83
+ if (recent.length > 0) {
84
+ console.log('Recent intervals (last 15s):');
85
+ recent.forEach(([id, info]) => {
86
+ const age = ((now - info.created) / 1000).toFixed(1);
87
+ const location = info.stack.split('\n')[3]?.trim() || 'unknown';
88
+ console.log(` ID ${id} (${age}s ago): ${location}`);
89
+ });
90
+ }
91
+ }
92
+
93
+ // Memory info if available
94
+ if ('memory' in performance && (performance as { memory?: { usedJSHeapSize: number; totalJSHeapSize: number } }).memory) {
95
+ const mem = (performance as { memory: { usedJSHeapSize: number; totalJSHeapSize: number } }).memory;
96
+ const usedMB = (mem.usedJSHeapSize / 1024 / 1024).toFixed(1);
97
+ const totalMB = (mem.totalJSHeapSize / 1024 / 1024).toFixed(1);
98
+ console.log(`Memory: ${usedMB}MB / ${totalMB}MB`);
99
+ }
100
+
101
+ console.groupEnd();
102
+
103
+ this.stats = { count: currentCount, lastCheck: now };
104
+ }
105
+
106
+ getActiveCount(): number {
107
+ return this.activeIntervals.size;
108
+ }
109
+ }
110
+
111
+ // Singleton instance
112
+ let monitor: PerformanceMonitor | null = null;
113
+
114
+ export function startPerfMonitor() {
115
+ if (!monitor) {
116
+ monitor = new PerformanceMonitor();
117
+ }
118
+ monitor.start();
119
+ }
120
+
121
+ export function stopPerfMonitor() {
122
+ if (monitor) {
123
+ monitor.stop();
124
+ }
125
+ }
126
+
127
+ export function getPerfMonitorStats() {
128
+ return monitor ? { activeIntervals: monitor.getActiveCount() } : null;
129
+ }
130
+
131
+ // Auto-start if enabled in localStorage
132
+ if (typeof window !== 'undefined') {
133
+ try {
134
+ if (window.localStorage.getItem('cue-console:perf-monitor') === '1') {
135
+ startPerfMonitor();
136
+ }
137
+ } catch {
138
+ // ignore
139
+ }
140
+ }