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 +1 -1
- package/src/app/globals.css +6 -0
- package/src/app/page.tsx +1 -0
- package/src/components/chat/message-bubble.tsx +3 -3
- package/src/components/chat/user-response-bubble.tsx +3 -3
- package/src/components/chat-composer.tsx +81 -38
- package/src/components/chat-view.tsx +65 -7
- package/src/components/conversation-list.tsx +18 -3
- package/src/hooks/use-conversation-timeline.ts +13 -3
- package/src/hooks/use-message-queue.ts +7 -2
- package/src/lib/db.ts +25 -0
- package/src/lib/perf-monitor.ts +140 -0
package/package.json
CHANGED
package/src/app/globals.css
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
"
|
|
688
|
-
"
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
{
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
|
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={
|
|
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={
|
|
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
|
|
342
|
+
void loadDataRef.current();
|
|
338
343
|
}, 0);
|
|
339
344
|
|
|
340
345
|
const tick = () => {
|
|
341
346
|
if (document.visibilityState !== "visible") return;
|
|
342
|
-
void
|
|
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
|
-
}, [
|
|
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" &&
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
}, [
|
|
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
|
+
}
|