cue-console 0.1.16 → 0.1.18

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.16",
3
+ "version": "0.1.18",
4
4
  "description": "Cue Hub console launcher (Next.js UI)",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": ["mcp", "cue", "console", "nextjs"],
@@ -1,6 +1,7 @@
1
1
  import type { Metadata } from "next";
2
2
  import "@fontsource-variable/source-sans-3";
3
3
  import "./globals.css";
4
+ import { Providers } from "./providers";
4
5
 
5
6
  export const metadata: Metadata = {
6
7
  title: "cue-console",
@@ -18,7 +19,7 @@ export default function RootLayout({
18
19
  className="antialiased"
19
20
  suppressHydrationWarning
20
21
  >
21
- {children}
22
+ <Providers>{children}</Providers>
22
23
  </body>
23
24
  </html>
24
25
  );
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { ConfigProvider } from "@/contexts/config-context";
5
+
6
+ export function Providers({ children }: { children: ReactNode }) {
7
+ return <ConfigProvider>{children}</ConfigProvider>;
8
+ }
@@ -1,13 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { memo, useMemo, useState, type ReactNode } from "react";
3
+ import { memo } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
- import { Badge } from "@/components/ui/badge";
6
- import { cn, formatFullTime, getAgentEmoji, getWaitingDuration } from "@/lib/utils";
7
- import { MarkdownRenderer } from "@/components/markdown-renderer";
8
- import { PayloadCard } from "@/components/payload-card";
9
- import type { AgentTimelineItem, CueRequest, CueResponse } from "@/lib/actions";
10
- import { Copy, Check } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import type { AgentTimelineItem } from "@/lib/actions";
7
+ import { MessageBubble } from "@/components/chat/message-bubble";
8
+ import { UserResponseBubble } from "@/components/chat/user-response-bubble";
11
9
 
12
10
  function parseDbTime(dateStr: string) {
13
11
  return new Date((dateStr || "").replace(" ", "T"));
@@ -153,347 +151,3 @@ export const TimelineList = memo(function TimelineList({
153
151
  </>
154
152
  );
155
153
  });
156
-
157
- const MessageBubble = memo(function MessageBubble({
158
- request,
159
- showAgent,
160
- agentNameMap,
161
- avatarUrlMap,
162
- isHistory,
163
- showName,
164
- showAvatar,
165
- compact,
166
- disabled,
167
- currentInput,
168
- isGroup,
169
- onPasteChoice,
170
- onSubmitConfirm,
171
- onMentionAgent,
172
- onReply,
173
- onCancel,
174
- }: {
175
- request: CueRequest;
176
- showAgent?: boolean;
177
- agentNameMap?: Record<string, string>;
178
- avatarUrlMap?: Record<string, string>;
179
- isHistory?: boolean;
180
- showName?: boolean;
181
- showAvatar?: boolean;
182
- compact?: boolean;
183
- disabled?: boolean;
184
- currentInput?: string;
185
- isGroup?: boolean;
186
- onPasteChoice?: (text: string, mode?: "replace" | "append" | "upsert") => void;
187
- onSubmitConfirm?: (
188
- requestId: string,
189
- text: string,
190
- cancelled: boolean
191
- ) => void | Promise<void>;
192
- onMentionAgent?: (agentId: string) => void;
193
- onReply?: () => void;
194
- onCancel?: () => void;
195
- }) {
196
- const isPending = request.status === "PENDING";
197
- const [copied, setCopied] = useState(false);
198
-
199
- const handleCopy = async () => {
200
- try {
201
- await navigator.clipboard.writeText(request.prompt || "");
202
- setCopied(true);
203
- setTimeout(() => setCopied(false), 2000);
204
- } catch (err) {
205
- console.error("Failed to copy:", err);
206
- }
207
- };
208
-
209
- const isPause = useMemo(() => {
210
- if (!request.payload) return false;
211
- try {
212
- const obj = JSON.parse(request.payload) as Record<string, unknown>;
213
- return obj?.type === "confirm" && obj?.variant === "pause";
214
- } catch {
215
- return false;
216
- }
217
- }, [request.payload]);
218
-
219
- const selectedLines = useMemo(() => {
220
- const text = (currentInput || "").trim();
221
- if (!text) return new Set<string>();
222
- return new Set(
223
- text
224
- .split(/\r?\n/)
225
- .map((s) => s.trim())
226
- .filter(Boolean)
227
- );
228
- }, [currentInput]);
229
-
230
- const rawId = request.agent_id || "";
231
- const displayName = (agentNameMap && rawId ? agentNameMap[rawId] || rawId : rawId) || "";
232
- const cardMaxWidth = (showAvatar ?? true) ? "calc(100% - 3rem)" : "100%";
233
- const avatarUrl = rawId && avatarUrlMap ? avatarUrlMap[`agent:${rawId}`] : "";
234
-
235
- return (
236
- <div
237
- className={cn(
238
- "flex max-w-full min-w-0 items-start gap-3",
239
- compact && "gap-2",
240
- isHistory && "opacity-60"
241
- )}
242
- >
243
- {(showAvatar ?? true) ? (
244
- <span
245
- className={cn(
246
- "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg",
247
- isGroup && request.agent_id && onMentionAgent && "cursor-pointer"
248
- )}
249
- title={
250
- isGroup && request.agent_id && onMentionAgent
251
- ? "Double-click avatar to @mention"
252
- : undefined
253
- }
254
- onDoubleClick={() => {
255
- if (!isGroup) return;
256
- const agentId = request.agent_id;
257
- if (!agentId) return;
258
- onMentionAgent?.(agentId);
259
- }}
260
- >
261
- {avatarUrl ? (
262
- <img src={avatarUrl} alt="" className="h-full w-full rounded-full" />
263
- ) : (
264
- getAgentEmoji(request.agent_id || "")
265
- )}
266
- </span>
267
- ) : (
268
- <span className="h-9 w-9 shrink-0" />
269
- )}
270
- <div className="flex-1 min-w-0 overflow-hidden">
271
- {(showName ?? true) && (showAgent || displayName) && (
272
- <p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
273
- )}
274
- <div
275
- className={cn(
276
- "rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
277
- "glass-surface-soft glass-noise",
278
- isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
279
- )}
280
- style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
281
- >
282
- <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
283
- <MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
284
- </div>
285
- <PayloadCard
286
- raw={request.payload}
287
- disabled={disabled}
288
- onPasteChoice={onPasteChoice}
289
- onSubmitConfirm={(text, cancelled) =>
290
- isPending ? onSubmitConfirm?.(request.request_id, text, cancelled) : undefined
291
- }
292
- selectedLines={selectedLines}
293
- />
294
- </div>
295
- <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
296
- <span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
297
- <Button
298
- variant="ghost"
299
- size="sm"
300
- className="h-auto p-1 text-xs"
301
- onClick={handleCopy}
302
- disabled={disabled}
303
- title={copied ? "已复制" : "复制"}
304
- >
305
- {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
306
- <span className="ml-1">{copied ? "已复制" : "复制"}</span>
307
- </Button>
308
- {isPending && (
309
- <>
310
- <Badge variant="outline" className="text-xs shrink-0">
311
- Waiting {getWaitingDuration(request.created_at || "")}
312
- </Badge>
313
- {!isPause && (
314
- <>
315
- <Badge variant="default" className="text-xs shrink-0">
316
- Pending
317
- </Badge>
318
- {onReply && (
319
- <Button
320
- variant="link"
321
- size="sm"
322
- className="h-auto p-0 text-xs"
323
- onClick={onReply}
324
- disabled={disabled}
325
- >
326
- Reply
327
- </Button>
328
- )}
329
- {onCancel && (
330
- <Button
331
- variant="link"
332
- size="sm"
333
- className="h-auto p-0 text-xs text-destructive"
334
- onClick={onCancel}
335
- disabled={disabled}
336
- >
337
- End
338
- </Button>
339
- )}
340
- </>
341
- )}
342
- </>
343
- )}
344
- {request.status === "COMPLETED" && (
345
- <Badge variant="secondary" className="text-xs shrink-0">
346
- Replied
347
- </Badge>
348
- )}
349
- {request.status === "CANCELLED" && (
350
- <Badge variant="destructive" className="text-xs shrink-0">
351
- Ended
352
- </Badge>
353
- )}
354
- </div>
355
- </div>
356
- </div>
357
- );
358
- });
359
-
360
- const UserResponseBubble = memo(function UserResponseBubble({
361
- response,
362
- showAvatar = true,
363
- compact = false,
364
- onPreview,
365
- }: {
366
- response: CueResponse;
367
- showAvatar?: boolean;
368
- compact?: boolean;
369
- onPreview?: (img: { mime_type: string; base64_data: string }) => void;
370
- }) {
371
- const parsed = JSON.parse(response.response_json || "{}") as {
372
- text?: string;
373
- mentions?: { userId: string; start: number; length: number; display: string }[];
374
- };
375
-
376
- const filesRaw = (response as unknown as { files?: unknown }).files;
377
- const files = Array.isArray(filesRaw) ? filesRaw : [];
378
- const imageFiles = files.filter((f) => {
379
- const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
380
- const mime = String(obj?.mime_type || "");
381
- const b64 = obj?.inline_base64;
382
- return mime.startsWith("image/") && typeof b64 === "string" && b64.length > 0;
383
- });
384
- const otherFiles = files.filter((f) => {
385
- const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
386
- const mime = String(obj?.mime_type || "");
387
- return !mime.startsWith("image/");
388
- });
389
-
390
- const renderTextWithMentions = (text: string, mentions?: { start: number; length: number }[]) => {
391
- if (!mentions || mentions.length === 0) return text;
392
- const safe = [...mentions]
393
- .filter((m) => m.start >= 0 && m.length > 0 && m.start + m.length <= text.length)
394
- .sort((a, b) => a.start - b.start);
395
-
396
- const nodes: ReactNode[] = [];
397
- let cursor = 0;
398
- for (const m of safe) {
399
- if (m.start < cursor) continue;
400
- if (m.start > cursor) {
401
- nodes.push(text.slice(cursor, m.start));
402
- }
403
- const seg = text.slice(m.start, m.start + m.length);
404
- nodes.push(
405
- <span key={`m-${m.start}`} className="text-emerald-900/90 dark:text-emerald-950 font-semibold">
406
- {seg}
407
- </span>
408
- );
409
- cursor = m.start + m.length;
410
- }
411
- if (cursor < text.length) nodes.push(text.slice(cursor));
412
- return nodes;
413
- };
414
-
415
- if (response.cancelled) {
416
- return (
417
- <div className="flex justify-end gap-3 max-w-full min-w-0">
418
- <div
419
- 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"
420
- style={{
421
- clipPath: "inset(0 round 1rem)",
422
- maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
423
- }}
424
- >
425
- <p className="text-sm text-muted-foreground italic">Conversation ended</p>
426
- <p className="text-xs text-muted-foreground mt-1">{formatFullTime(response.created_at)}</p>
427
- </div>
428
- </div>
429
- );
430
- }
431
-
432
- return (
433
- <div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
434
- <div
435
- 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"
436
- style={{
437
- clipPath: "inset(0 round 1rem)",
438
- maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
439
- }}
440
- >
441
- {parsed.text && (
442
- <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
443
- {parsed.mentions && parsed.mentions.length > 0 ? (
444
- <p className="whitespace-pre-wrap">{renderTextWithMentions(parsed.text, parsed.mentions)}</p>
445
- ) : (
446
- <MarkdownRenderer>{parsed.text}</MarkdownRenderer>
447
- )}
448
- </div>
449
- )}
450
- {imageFiles.length > 0 && (
451
- <div className="flex flex-wrap gap-2 mt-2 max-w-full">
452
- {imageFiles.map((f, i) => {
453
- const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
454
- const mime = String(obj?.mime_type || "image/png");
455
- const b64 = String(obj?.inline_base64 || "");
456
- const img = { mime_type: mime, base64_data: b64 };
457
- return (
458
- <img
459
- key={i}
460
- src={`data:${img.mime_type};base64,${img.base64_data}`}
461
- alt=""
462
- className="max-h-32 max-w-full h-auto rounded cursor-pointer"
463
- onClick={() => onPreview?.(img)}
464
- />
465
- );
466
- })}
467
- </div>
468
- )}
469
-
470
- {otherFiles.length > 0 && (
471
- <div className="mt-2 flex flex-col gap-1 max-w-full">
472
- {otherFiles.map((f, i) => {
473
- const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
474
- const fileRef = String(obj?.file || "");
475
- const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
476
- return (
477
- <div
478
- key={i}
479
- className="px-2 py-1 rounded-lg bg-white/40 dark:bg-black/20 ring-1 ring-border/40 text-xs text-foreground/80 truncate"
480
- title={fileRef}
481
- >
482
- {name}
483
- </div>
484
- );
485
- })}
486
- </div>
487
- )}
488
- <p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
489
- </div>
490
- {showAvatar ? (
491
- <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
492
- 👤
493
- </span>
494
- ) : (
495
- <span className="h-9 w-9 shrink-0" />
496
- )}
497
- </div>
498
- );
499
- });
@@ -1,7 +1,8 @@
1
- import type { ReactNode } from "react";
1
+ import { useMemo, type ReactNode } from "react";
2
2
  import { cn, formatFullTime } from "@/lib/utils";
3
3
  import { MarkdownRenderer } from "@/components/markdown-renderer";
4
4
  import type { CueResponse } from "@/lib/actions";
5
+ import { useConfig } from "@/contexts/config-context";
5
6
 
6
7
  interface UserResponseBubbleProps {
7
8
  response: CueResponse;
@@ -16,11 +17,45 @@ export function UserResponseBubble({
16
17
  compact = false,
17
18
  onPreview,
18
19
  }: UserResponseBubbleProps) {
20
+ const { config } = useConfig();
19
21
  const parsed = JSON.parse(response.response_json || "{}") as {
20
22
  text?: string;
21
23
  mentions?: { userId: string; start: number; length: number; display: string }[];
22
24
  };
23
25
 
26
+ const analysisOnlyInstruction = config.chat_mode_append_text;
27
+ const { analysisOnlyApplied, displayText } = useMemo(() => {
28
+ const text = parsed.text;
29
+ if (typeof text !== "string") return { analysisOnlyApplied: false, displayText: text };
30
+
31
+ const raw = text;
32
+ const lines = raw.split(/\r?\n/);
33
+ let lastNonEmpty = -1;
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ if (lines[i]?.trim().length) {
36
+ lastNonEmpty = i;
37
+ break;
38
+ }
39
+ }
40
+ if (lastNonEmpty === -1) return { analysisOnlyApplied: false, displayText: raw };
41
+
42
+ const tail = (lines[lastNonEmpty] ?? "").trim();
43
+ if (tail !== analysisOnlyInstruction) {
44
+ return { analysisOnlyApplied: false, displayText: raw };
45
+ }
46
+
47
+ let cut = lastNonEmpty;
48
+ if (cut > 0 && (lines[cut - 1] ?? "").trim().length === 0) {
49
+ cut -= 1;
50
+ }
51
+ const stripped = lines.slice(0, cut).join("\n").replace(/\s+$/, "");
52
+ if (stripped.trim().length === 0) {
53
+ return { analysisOnlyApplied: false, displayText: raw };
54
+ }
55
+
56
+ return { analysisOnlyApplied: true, displayText: stripped };
57
+ }, [parsed.text, analysisOnlyInstruction]);
58
+
24
59
  const filesRaw = (response as unknown as { files?: unknown }).files;
25
60
  const files = Array.isArray(filesRaw) ? filesRaw : [];
26
61
  const imageFiles = files.filter((f) => {
@@ -89,14 +124,14 @@ export function UserResponseBubble({
89
124
  maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
90
125
  }}
91
126
  >
92
- {parsed.text && (
127
+ {displayText && (
93
128
  <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
94
129
  {parsed.mentions && parsed.mentions.length > 0 ? (
95
130
  <p className="whitespace-pre-wrap">
96
- {renderTextWithMentions(parsed.text, parsed.mentions)}
131
+ {renderTextWithMentions(displayText, parsed.mentions)}
97
132
  </p>
98
133
  ) : (
99
- <MarkdownRenderer>{parsed.text}</MarkdownRenderer>
134
+ <MarkdownRenderer>{displayText}</MarkdownRenderer>
100
135
  )}
101
136
  </div>
102
137
  )}
@@ -138,7 +173,17 @@ export function UserResponseBubble({
138
173
  })}
139
174
  </div>
140
175
  )}
141
- <p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
176
+ <div className="mt-1 flex items-center justify-end gap-2 text-xs opacity-70">
177
+ {analysisOnlyApplied && (
178
+ <span
179
+ className="rounded-full bg-white/40 dark:bg-black/20 px-2 py-0.5 ring-1 ring-border/40"
180
+ title="Chat 模式:只做分析,不做改动(该规则会发送给模型,但不会显示在消息正文里)"
181
+ >
182
+ Chat
183
+ </span>
184
+ )}
185
+ <span>{formatFullTime(response.created_at)}</span>
186
+ </div>
142
187
  </div>
143
188
  {showAvatar ? (
144
189
  <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
@@ -12,9 +12,15 @@ import {
12
12
  type SetStateAction,
13
13
  } from "react";
14
14
  import { Button } from "@/components/ui/button";
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from "@/components/ui/dialog";
15
21
  import { cn, getAgentEmoji } from "@/lib/utils";
16
22
  import { setAgentDisplayName } from "@/lib/actions";
17
- import { CornerUpLeft, GripVertical, Plus, Send, Trash2, X } from "lucide-react";
23
+ import { Bot, CornerUpLeft, GripVertical, Plus, Send, Trash2, X } from "lucide-react";
18
24
 
19
25
  type MentionDraft = {
20
26
  userId: string;
@@ -71,6 +77,8 @@ export function ChatComposer({
71
77
  setImages,
72
78
  setNotice,
73
79
  setPreviewImage,
80
+ botEnabled,
81
+ onToggleBot,
74
82
  handleSend,
75
83
  enqueueCurrent,
76
84
  queue,
@@ -113,6 +121,8 @@ export function ChatComposer({
113
121
  setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
114
122
  setNotice: Dispatch<SetStateAction<string | null>>;
115
123
  setPreviewImage: Dispatch<SetStateAction<{ mime_type: string; base64_data: string } | null>>;
124
+ botEnabled: boolean;
125
+ onToggleBot: () => Promise<boolean>;
116
126
  handleSend: () => void | Promise<void>;
117
127
  enqueueCurrent: () => void;
118
128
  queue: QueuedMessage[];
@@ -152,6 +162,8 @@ export function ChatComposer({
152
162
  }, [onBack]);
153
163
 
154
164
  const [dragIndex, setDragIndex] = useState<number | null>(null);
165
+ const [botToggling, setBotToggling] = useState(false);
166
+ const [botConfirmOpen, setBotConfirmOpen] = useState(false);
155
167
  const isComposingRef = useRef(false);
156
168
 
157
169
  const submitOrQueue = () => {
@@ -251,6 +263,54 @@ export function ChatComposer({
251
263
  </div>
252
264
  )}
253
265
 
266
+ <Dialog open={botConfirmOpen} onOpenChange={setBotConfirmOpen}>
267
+ <DialogContent className="sm:max-w-110">
268
+ <DialogHeader>
269
+ <DialogTitle>Enable bot mode?</DialogTitle>
270
+ </DialogHeader>
271
+
272
+ <div className="text-sm text-muted-foreground space-y-3">
273
+ <p>
274
+ Bot mode will automatically reply to <span className="text-foreground font-medium">cue</span> requests
275
+ in this conversation.
276
+ </p>
277
+ <ul className="list-disc pl-5 space-y-1">
278
+ <li>Only affects the current {type === "group" ? "group" : "agent"} conversation.</li>
279
+ <li>May immediately reply to currently pending cue requests.</li>
280
+ <li>Does not reply to pause confirmations.</li>
281
+ <li>You can turn it off anytime.</li>
282
+ </ul>
283
+ </div>
284
+
285
+ <div className="mt-4 flex items-center justify-end gap-2">
286
+ <Button
287
+ type="button"
288
+ variant="outline"
289
+ disabled={botToggling}
290
+ onClick={() => setBotConfirmOpen(false)}
291
+ >
292
+ Cancel
293
+ </Button>
294
+ <Button
295
+ type="button"
296
+ disabled={botToggling}
297
+ onClick={async () => {
298
+ if (botToggling) return;
299
+ setBotToggling(true);
300
+ try {
301
+ await onToggleBot();
302
+ setBotConfirmOpen(false);
303
+ } finally {
304
+ setBotToggling(false);
305
+ }
306
+ }}
307
+ >
308
+ {botToggling ? "Enabling…" : "Enable"}
309
+ </Button>
310
+ </div>
311
+ </DialogContent>
312
+ </Dialog>
313
+
254
314
  {/* Image Preview */}
255
315
  {images.length > 0 && (
256
316
  <div className="flex max-w-full gap-2 overflow-x-auto px-0.5 pt-0.5">
@@ -614,6 +674,48 @@ export function ChatComposer({
614
674
  >
615
675
  Queue
616
676
  </Button>
677
+
678
+ <div className="relative group">
679
+ <Button
680
+ type="button"
681
+ variant="ghost"
682
+ size="icon"
683
+ disabled={busy || botToggling}
684
+ className={cn(
685
+ "relative h-9 w-9 rounded-2xl",
686
+ "hover:bg-white/40",
687
+ botEnabled ? "text-primary" : "text-muted-foreground",
688
+ (busy || botToggling) && "opacity-60 cursor-not-allowed"
689
+ )}
690
+ onClick={async () => {
691
+ if (busy || botToggling) return;
692
+ if (!botEnabled) {
693
+ setBotConfirmOpen(true);
694
+ return;
695
+ }
696
+ setBotToggling(true);
697
+ try {
698
+ await onToggleBot();
699
+ } finally {
700
+ setBotToggling(false);
701
+ }
702
+ }}
703
+ aria-label={botEnabled ? "Stop bot" : "Start bot"}
704
+ title={botToggling ? "Turning…" : botEnabled ? "Stop bot" : "Start bot"}
705
+ >
706
+ {botEnabled && (
707
+ <span className="pointer-events-none absolute inset-0 rounded-xl">
708
+ <span className="absolute inset-0 rounded-2xl bg-primary/15 blur-md animate-pulse" />
709
+ </span>
710
+ )}
711
+ <Bot
712
+ className={cn(
713
+ "relative z-10 h-5 w-5",
714
+ botEnabled && "drop-shadow-[0_0_12px_rgba(99,102,241,0.45)]"
715
+ )}
716
+ />
717
+ </Button>
718
+ </div>
617
719
  </div>
618
720
 
619
721
  <Button