cue-console 0.1.19 → 0.1.20

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.19",
3
+ "version": "0.1.20",
4
4
  "description": "Cue Hub console launcher (Next.js UI)",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": ["mcp", "cue", "console", "nextjs"],
package/src/app/page.tsx CHANGED
@@ -5,7 +5,7 @@ import { ConversationList } from "@/components/conversation-list";
5
5
  import { ChatView } from "@/components/chat-view";
6
6
  import { CreateGroupDialog } from "@/components/create-group-dialog";
7
7
  import { MessageCircle } from "lucide-react";
8
- import { claimWorkerLease, processQueueTick } from "@/lib/actions";
8
+ import { claimWorkerLease, fetchBotEnabledConversations, processBotTick, processQueueTick } from "@/lib/actions";
9
9
 
10
10
  export default function Home() {
11
11
  const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -114,6 +114,96 @@ export default function Home() {
114
114
  };
115
115
  }, []);
116
116
 
117
+ useEffect(() => {
118
+ let stopped = false;
119
+ const holderId =
120
+ (globalThis.crypto && "randomUUID" in globalThis.crypto
121
+ ? (globalThis.crypto as Crypto).randomUUID()
122
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`);
123
+ const leaseKey = "cue-console:global-bot-worker";
124
+ const leaseTtlMs = 12_000;
125
+ const claimEveryMs = 4_000;
126
+ const tickEveryMs = 2_500;
127
+
128
+ let isLeader = false;
129
+ let tickTimer: ReturnType<typeof setInterval> | null = null;
130
+ let claimTimer: ReturnType<typeof setInterval> | null = null;
131
+ let rrIndex = 0;
132
+
133
+ const stopTick = () => {
134
+ if (tickTimer) {
135
+ clearInterval(tickTimer);
136
+ tickTimer = null;
137
+ }
138
+ };
139
+
140
+ const startTick = () => {
141
+ if (tickTimer) return;
142
+ tickTimer = setInterval(() => {
143
+ if (document.visibilityState !== "visible") return;
144
+ void (async () => {
145
+ try {
146
+ const enabled = await fetchBotEnabledConversations(200);
147
+ if (!Array.isArray(enabled) || enabled.length === 0) return;
148
+
149
+ const maxPerTick = 10;
150
+ const n = Math.min(enabled.length, maxPerTick);
151
+ for (let i = 0; i < n; i += 1) {
152
+ const idx = (rrIndex + i) % enabled.length;
153
+ const row = enabled[idx] as { conv_type?: unknown; conv_id?: unknown };
154
+ const convType = row?.conv_type === "group" ? "group" : "agent";
155
+ const convId = String(row?.conv_id || "").trim();
156
+ if (!convId) continue;
157
+ await processBotTick({ holderId, convType, convId, limit: 80 });
158
+ }
159
+ rrIndex = (rrIndex + n) % enabled.length;
160
+ } catch {
161
+ // ignore
162
+ }
163
+ })();
164
+ }, tickEveryMs);
165
+ };
166
+
167
+ const claimOnce = async () => {
168
+ try {
169
+ const res = await claimWorkerLease({ leaseKey, holderId, ttlMs: leaseTtlMs });
170
+ const nextLeader = Boolean(res.acquired && res.holderId === holderId);
171
+ if (nextLeader !== isLeader) {
172
+ isLeader = nextLeader;
173
+ if (isLeader) startTick();
174
+ else stopTick();
175
+ }
176
+ } catch {
177
+ // ignore
178
+ }
179
+ };
180
+
181
+ const boot = async () => {
182
+ await claimOnce();
183
+ if (stopped) return;
184
+ claimTimer = setInterval(() => {
185
+ if (document.visibilityState !== "visible") return;
186
+ void claimOnce();
187
+ }, claimEveryMs);
188
+ };
189
+
190
+ void boot();
191
+
192
+ const onVisibilityChange = () => {
193
+ if (document.visibilityState === "visible") {
194
+ void claimOnce();
195
+ }
196
+ };
197
+ document.addEventListener("visibilitychange", onVisibilityChange);
198
+
199
+ return () => {
200
+ stopped = true;
201
+ document.removeEventListener("visibilitychange", onVisibilityChange);
202
+ stopTick();
203
+ if (claimTimer) clearInterval(claimTimer);
204
+ };
205
+ }, []);
206
+
117
207
  useEffect(() => {
118
208
  try {
119
209
  window.localStorage.setItem("cuehub.sidebarCollapsed", sidebarCollapsed ? "1" : "0");
@@ -8,6 +8,7 @@ import {
8
8
  DialogTitle,
9
9
  } from "@/components/ui/dialog";
10
10
  import { getAgentEmoji } from "@/lib/utils";
11
+ import Image from "next/image";
11
12
 
12
13
  interface AvatarPickerDialogProps {
13
14
  open: boolean;
@@ -41,7 +42,14 @@ export function AvatarPickerDialog({
41
42
  <div className="flex items-center gap-3">
42
43
  <div className="h-14 w-14 rounded-full bg-muted overflow-hidden">
43
44
  {currentAvatarUrl ? (
44
- <img src={currentAvatarUrl} alt="" className="h-full w-full" />
45
+ <Image
46
+ src={currentAvatarUrl}
47
+ alt=""
48
+ width={56}
49
+ height={56}
50
+ unoptimized
51
+ className="h-full w-full"
52
+ />
45
53
  ) : (
46
54
  <div className="flex h-full w-full items-center justify-center text-xl">
47
55
  {target.kind === "group" ? "👥" : getAgentEmoji(target.id)}
@@ -70,7 +78,14 @@ export function AvatarPickerDialog({
70
78
  title="Apply"
71
79
  >
72
80
  {c.url ? (
73
- <img src={c.url} alt="" className="h-full w-full" />
81
+ <Image
82
+ src={c.url}
83
+ alt=""
84
+ width={48}
85
+ height={48}
86
+ unoptimized
87
+ className="h-full w-full"
88
+ />
74
89
  ) : null}
75
90
  </button>
76
91
  ))}
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import { cn, getAgentEmoji } from "@/lib/utils";
6
6
  import { ChevronLeft, Github } from "lucide-react";
7
+ import Image from "next/image";
7
8
 
8
9
  interface ChatHeaderProps {
9
10
  type: "agent" | "group";
@@ -11,6 +12,8 @@ interface ChatHeaderProps {
11
12
  titleDisplay: string;
12
13
  avatarUrl: string;
13
14
  members: string[];
15
+ agentRuntime?: string;
16
+ projectName?: string;
14
17
  onBack?: () => void;
15
18
  onAvatarClick: () => void;
16
19
  onTitleChange: (newTitle: string) => Promise<void>;
@@ -22,6 +25,8 @@ export function ChatHeader({
22
25
  titleDisplay,
23
26
  avatarUrl,
24
27
  members,
28
+ agentRuntime,
29
+ projectName,
25
30
  onBack,
26
31
  onAvatarClick,
27
32
  onTitleChange,
@@ -29,6 +34,8 @@ export function ChatHeader({
29
34
  const [editingTitle, setEditingTitle] = useState(false);
30
35
  const [titleDraft, setTitleDraft] = useState("");
31
36
 
37
+ const showAgentTags = type === "agent" && (agentRuntime || projectName);
38
+
32
39
  const beginEditTitle = () => {
33
40
  setEditingTitle(true);
34
41
  setTitleDraft(titleDisplay);
@@ -65,7 +72,14 @@ export function ChatHeader({
65
72
  title="Change avatar"
66
73
  >
67
74
  {avatarUrl ? (
68
- <img src={avatarUrl} alt="" className="h-full w-full" />
75
+ <Image
76
+ src={avatarUrl}
77
+ alt=""
78
+ width={36}
79
+ height={36}
80
+ unoptimized
81
+ className="h-full w-full"
82
+ />
69
83
  ) : (
70
84
  <span className="flex h-full w-full items-center justify-center text-lg">
71
85
  {type === "group" ? "👥" : getAgentEmoji(id)}
@@ -102,6 +116,20 @@ export function ChatHeader({
102
116
  {titleDisplay}
103
117
  </h2>
104
118
  )}
119
+ {showAgentTags && (
120
+ <div className="mt-0.5 flex flex-wrap gap-1">
121
+ {agentRuntime && (
122
+ <span className="inline-flex items-center rounded-full border bg-white/55 px-2 py-0.5 text-[10px] text-muted-foreground">
123
+ {agentRuntime}
124
+ </span>
125
+ )}
126
+ {projectName && (
127
+ <span className="inline-flex items-center rounded-full border bg-white/55 px-2 py-0.5 text-[10px] text-muted-foreground">
128
+ {projectName}
129
+ </span>
130
+ )}
131
+ </div>
132
+ )}
105
133
  {type === "group" && members.length > 0 && (
106
134
  <p className="text-xs text-muted-foreground truncate">
107
135
  {members.length} member{members.length === 1 ? "" : "s"}
@@ -6,6 +6,7 @@ import {
6
6
  DialogHeader,
7
7
  DialogTitle,
8
8
  } from "@/components/ui/dialog";
9
+ import Image from "next/image";
9
10
 
10
11
  interface ImagePreviewDialogProps {
11
12
  image: { mime_type: string; base64_data: string } | null;
@@ -21,10 +22,13 @@ export function ImagePreviewDialog({ image, onClose }: ImagePreviewDialogProps)
21
22
  </DialogHeader>
22
23
  {image && (
23
24
  <div className="flex items-center justify-center">
24
- <img
25
+ <Image
25
26
  src={`data:${image.mime_type};base64,${image.base64_data}`}
26
27
  alt=""
27
- className="max-h-[70vh] rounded-lg"
28
+ width={1200}
29
+ height={800}
30
+ unoptimized
31
+ className="max-h-[70vh] h-auto w-auto rounded-lg"
28
32
  />
29
33
  </div>
30
34
  )}
@@ -6,6 +6,7 @@ import { MarkdownRenderer } from "@/components/markdown-renderer";
6
6
  import { PayloadCard } from "@/components/payload-card";
7
7
  import type { CueRequest } from "@/lib/actions";
8
8
  import { Copy, Check } from "lucide-react";
9
+ import Image from "next/image";
9
10
 
10
11
  interface MessageBubbleProps {
11
12
  request: CueRequest;
@@ -110,7 +111,14 @@ export function MessageBubble({
110
111
  }}
111
112
  >
112
113
  {avatarUrl ? (
113
- <img src={avatarUrl} alt="" className="h-full w-full rounded-full" />
114
+ <Image
115
+ src={avatarUrl}
116
+ alt=""
117
+ width={36}
118
+ height={36}
119
+ unoptimized
120
+ className="h-full w-full rounded-full"
121
+ />
114
122
  ) : (
115
123
  getAgentEmoji(request.agent_id || "")
116
124
  )}
@@ -3,6 +3,7 @@ import { cn, formatFullTime } from "@/lib/utils";
3
3
  import { MarkdownRenderer } from "@/components/markdown-renderer";
4
4
  import type { CueResponse } from "@/lib/actions";
5
5
  import { useConfig } from "@/contexts/config-context";
6
+ import Image from "next/image";
6
7
 
7
8
  interface UserResponseBubbleProps {
8
9
  response: CueResponse;
@@ -143,11 +144,14 @@ export function UserResponseBubble({
143
144
  const b64 = String(obj?.inline_base64 || "");
144
145
  const img = { mime_type: mime, base64_data: b64 };
145
146
  return (
146
- <img
147
+ <Image
147
148
  key={i}
148
149
  src={`data:${img.mime_type};base64,${img.base64_data}`}
149
150
  alt=""
150
- className="max-h-32 max-w-full h-auto rounded cursor-pointer"
151
+ width={512}
152
+ height={256}
153
+ unoptimized
154
+ className="max-h-32 max-w-full h-auto w-auto rounded cursor-pointer"
151
155
  onClick={() => onPreview?.(img)}
152
156
  />
153
157
  );
@@ -21,6 +21,7 @@ import {
21
21
  import { cn, getAgentEmoji } from "@/lib/utils";
22
22
  import { setAgentDisplayName } from "@/lib/actions";
23
23
  import { Bot, CornerUpLeft, GripVertical, Plus, Send, Trash2, X } from "lucide-react";
24
+ import Image from "next/image";
24
25
 
25
26
  type MentionDraft = {
26
27
  userId: string;
@@ -75,7 +76,6 @@ export function ChatComposer({
75
76
  setInput,
76
77
  images,
77
78
  setImages,
78
- setNotice,
79
79
  setPreviewImage,
80
80
  botEnabled,
81
81
  onToggleBot,
@@ -119,7 +119,6 @@ export function ChatComposer({
119
119
  setInput: Dispatch<SetStateAction<string>>;
120
120
  images: { mime_type: string; base64_data: string; file_name?: string }[];
121
121
  setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
122
- setNotice: Dispatch<SetStateAction<string | null>>;
123
122
  setPreviewImage: Dispatch<SetStateAction<{ mime_type: string; base64_data: string } | null>>;
124
123
  botEnabled: boolean;
125
124
  onToggleBot: () => Promise<boolean>;
@@ -317,9 +316,12 @@ export function ChatComposer({
317
316
  {images.map((img, i) => (
318
317
  <div key={i} className="relative shrink-0">
319
318
  {img.mime_type.startsWith("image/") ? (
320
- <img
319
+ <Image
321
320
  src={`data:${img.mime_type};base64,${img.base64_data}`}
322
321
  alt=""
322
+ width={64}
323
+ height={64}
324
+ unoptimized
323
325
  className="h-16 w-16 rounded-xl object-cover shadow-sm ring-1 ring-border/60 cursor-pointer"
324
326
  onClick={() => setPreviewImage(img)}
325
327
  />
@@ -7,7 +7,6 @@ import {
7
7
  useMemo,
8
8
  useRef,
9
9
  useState,
10
- type ClipboardEvent,
11
10
  } from "react";
12
11
  import { ScrollArea } from "@/components/ui/scroll-area";
13
12
  import { Button } from "@/components/ui/button";
@@ -19,14 +18,16 @@ import {
19
18
  } from "@/components/ui/dialog";
20
19
  import { cn, getAgentEmoji } from "@/lib/utils";
21
20
  import { randomSeed } from "@/lib/avatar";
21
+ import Image from "next/image";
22
22
  import {
23
23
  setAgentDisplayName,
24
24
  setGroupName,
25
25
  submitResponse,
26
26
  cancelRequest,
27
- batchRespond,
28
27
  processBotTick,
29
- type CueRequest,
28
+ fetchBotEnabled,
29
+ updateBotEnabled,
30
+ fetchAgentEnv,
30
31
  } from "@/lib/actions";
31
32
  import { ChatComposer } from "@/components/chat-composer";
32
33
  import { Skeleton } from "@/components/ui/skeleton";
@@ -45,7 +46,7 @@ import { useMessageSender } from "@/hooks/use-message-sender";
45
46
  import { useFileHandler } from "@/hooks/use-file-handler";
46
47
  import { useDraftPersistence } from "@/hooks/use-draft-persistence";
47
48
  import { isPauseRequest, filterPendingRequests } from "@/lib/chat-logic";
48
- import type { ChatType, MentionDraft } from "@/types/chat";
49
+ import type { ChatType } from "@/types/chat";
49
50
  import { ArrowDown } from "lucide-react";
50
51
 
51
52
  function perfEnabled(): boolean {
@@ -78,10 +79,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
78
79
  const deferredInput = useDeferredValue(input);
79
80
  const imagesRef = useRef(images);
80
81
 
81
- const botStorageKey = useMemo(() => {
82
- return `cue-console:botEnabled:${type}:${id}`;
83
- }, [type, id]);
84
-
85
82
  const [botEnabled, setBotEnabled] = useState(false);
86
83
 
87
84
  const { soundEnabled, setSoundEnabled, playDing } = useAudioNotification();
@@ -105,6 +102,8 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
105
102
  const [members, setMembers] = useState<string[]>([]);
106
103
  const [agentNameMap, setAgentNameMap] = useState<Record<string, string>>({});
107
104
  const [groupTitle, setGroupTitle] = useState<string>(name);
105
+ const [agentRuntime, setAgentRuntime] = useState<string | undefined>(undefined);
106
+ const [projectName, setProjectName] = useState<string | undefined>(undefined);
108
107
  const [previewImage, setPreviewImage] = useState<{ mime_type: string; base64_data: string } | null>(null);
109
108
  const [isAtBottom, setIsAtBottom] = useState(true);
110
109
 
@@ -161,12 +160,11 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
161
160
 
162
161
  const {
163
162
  queue,
164
- refreshQueue,
163
+ setQueue,
165
164
  enqueueCurrent,
166
165
  removeQueued,
167
166
  recallQueued,
168
167
  reorderQueue,
169
- setQueue,
170
168
  } = useMessageQueue({
171
169
  type,
172
170
  id,
@@ -196,6 +194,32 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
196
194
  return groupTitle;
197
195
  }, [agentNameMap, groupTitle, id, type]);
198
196
 
197
+ useEffect(() => {
198
+ if (type !== "agent") {
199
+ setAgentRuntime(undefined);
200
+ setProjectName(undefined);
201
+ return;
202
+ }
203
+
204
+ let cancelled = false;
205
+ void (async () => {
206
+ try {
207
+ const env = await fetchAgentEnv(id);
208
+ if (cancelled) return;
209
+ setAgentRuntime(env.agentRuntime);
210
+ setProjectName(env.projectName);
211
+ } catch {
212
+ if (cancelled) return;
213
+ setAgentRuntime(undefined);
214
+ setProjectName(undefined);
215
+ }
216
+ })();
217
+
218
+ return () => {
219
+ cancelled = true;
220
+ };
221
+ }, [id, type]);
222
+
199
223
  useEffect(() => {
200
224
  if (type !== "group") return;
201
225
  queueMicrotask(() => setGroupTitle(name));
@@ -260,9 +284,9 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
260
284
  const toggleBot = useCallback(async (): Promise<boolean> => {
261
285
  const prev = botEnabled;
262
286
  const next = !prev;
263
- setBotEnabled(next);
264
287
  try {
265
- window.localStorage.setItem(botStorageKey, next ? "1" : "0");
288
+ await updateBotEnabled(type, id, next);
289
+ setBotEnabled(next);
266
290
  if (next) void triggerBotTickOnce();
267
291
  return next;
268
292
  } catch {
@@ -270,16 +294,24 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
270
294
  setNotice("Failed to toggle bot");
271
295
  return prev;
272
296
  }
273
- }, [botEnabled, botStorageKey, setNotice, triggerBotTickOnce]);
297
+ }, [botEnabled, id, setNotice, triggerBotTickOnce, type]);
274
298
 
275
299
  useEffect(() => {
276
- try {
277
- const raw = window.localStorage.getItem(botStorageKey);
278
- setBotEnabled(raw === "1");
279
- } catch {
280
- setBotEnabled(false);
281
- }
282
- }, [botStorageKey]);
300
+ let cancelled = false;
301
+ void (async () => {
302
+ try {
303
+ const res = await fetchBotEnabled(type, id);
304
+ if (cancelled) return;
305
+ setBotEnabled(Boolean(res.enabled));
306
+ } catch {
307
+ if (cancelled) return;
308
+ setBotEnabled(false);
309
+ }
310
+ })();
311
+ return () => {
312
+ cancelled = true;
313
+ };
314
+ }, [id, type]);
283
315
 
284
316
  useEffect(() => {
285
317
  if (!botEnabled) return;
@@ -371,7 +403,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
371
403
  await ensureAvatarUrl("agent", id);
372
404
  if (t0) {
373
405
  const t1 = performance.now();
374
- // eslint-disable-next-line no-console
375
406
  console.log(`[perf] ensureAvatarUrl(agent) id=${id} ${(t1 - t0).toFixed(1)}ms`);
376
407
  }
377
408
  })();
@@ -384,7 +415,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
384
415
  await ensureAvatarUrl("group", id);
385
416
  if (t0) {
386
417
  const t1 = performance.now();
387
- // eslint-disable-next-line no-console
388
418
  console.log(`[perf] ensureAvatarUrl(group) id=${id} ${(t1 - t0).toFixed(1)}ms`);
389
419
  }
390
420
  })();
@@ -399,7 +429,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
399
429
  }
400
430
  if (t0) {
401
431
  const t1 = performance.now();
402
- // eslint-disable-next-line no-console
403
432
  console.log(`[perf] ensureAvatarUrl(group members) group=${id} n=${members.length} ${(t1 - t0).toFixed(1)}ms`);
404
433
  }
405
434
  })();
@@ -485,7 +514,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
485
514
  setImages([]);
486
515
  imagesRef.current = [];
487
516
  setMentions([]);
488
- }, [type, id]);
517
+ }, [type, id, setBusy, setError, setImages, setInput, setMentions, setNotice]);
489
518
 
490
519
  const loadMore = useCallback(async () => {
491
520
  if (!nextCursor) return;
@@ -528,7 +557,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
528
557
  el.addEventListener("scroll", onScroll, { passive: true });
529
558
  onScroll();
530
559
  return () => el.removeEventListener("scroll", onScroll);
531
- }, []);
560
+ }, [loadMore]);
532
561
 
533
562
  useEffect(() => {
534
563
  const el = scrollRef.current;
@@ -619,7 +648,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
619
648
  if (!notice) return;
620
649
  const t = setTimeout(() => setNotice(null), 2200);
621
650
  return () => clearTimeout(t);
622
- }, [notice]);
651
+ }, [notice, setNotice]);
623
652
 
624
653
  useEffect(() => {
625
654
  const el = textareaRef.current;
@@ -674,6 +703,8 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
674
703
  titleDisplay={titleDisplay}
675
704
  avatarUrl={type === "group" ? avatarUrlMap[`group:${id}`] : avatarUrlMap[`agent:${id}`]}
676
705
  members={members}
706
+ agentRuntime={agentRuntime}
707
+ projectName={projectName}
677
708
  onBack={onBack}
678
709
  onAvatarClick={() => openAvatarPicker({ kind: type, id })}
679
710
  onTitleChange={handleTitleChange}
@@ -778,7 +809,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
778
809
  setInput={setInput}
779
810
  images={images}
780
811
  setImages={setImages}
781
- setNotice={setNotice}
782
812
  setPreviewImage={setPreviewImage}
783
813
  botEnabled={botEnabled}
784
814
  onToggleBot={toggleBot}
@@ -820,10 +850,13 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
820
850
  {previewImage ? (
821
851
  <div className="flex items-center justify-center">
822
852
  {((img) => (
823
- <img
853
+ <Image
824
854
  src={`data:${img.mime_type};base64,${img.base64_data}`}
825
855
  alt=""
826
- className="max-h-[70vh] rounded-lg"
856
+ width={1200}
857
+ height={800}
858
+ unoptimized
859
+ className="max-h-[70vh] h-auto w-auto rounded-lg"
827
860
  />
828
861
  ))(previewImage!)}
829
862
  </div>
@@ -844,7 +877,14 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
844
877
  return (
845
878
  <div className="h-14 w-14 rounded-full bg-muted overflow-hidden">
846
879
  {avatarUrlMap[key] ? (
847
- <img src={avatarUrlMap[key]} alt="" className="h-full w-full" />
880
+ <Image
881
+ src={avatarUrlMap[key]}
882
+ alt=""
883
+ width={56}
884
+ height={56}
885
+ unoptimized
886
+ className="h-full w-full"
887
+ />
848
888
  ) : (
849
889
  <div className="flex h-full w-full items-center justify-center text-xl">
850
890
  {target.kind === "group" ? "👥" : getAgentEmoji(id)}
@@ -890,7 +930,16 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
890
930
  }}
891
931
  title="Apply"
892
932
  >
893
- {c.url ? <img src={c.url} alt="" className="h-full w-full" /> : null}
933
+ {c.url ? (
934
+ <Image
935
+ src={c.url}
936
+ alt=""
937
+ width={48}
938
+ height={48}
939
+ unoptimized
940
+ className="h-full w-full"
941
+ />
942
+ ) : null}
894
943
  </button>
895
944
  ))}
896
945
  </div>
@@ -38,19 +38,20 @@ import {
38
38
  } from "@/lib/actions";
39
39
  import {
40
40
  Archive,
41
- Plus,
42
- MessageCircle,
43
- Search,
44
- ChevronDown,
45
- Users,
46
41
  Bot,
42
+ ChevronDown,
47
43
  ChevronLeft,
48
44
  ChevronRight,
45
+ MessageCircle,
49
46
  MoreHorizontal,
50
47
  Pin,
48
+ Plus,
49
+ Search,
51
50
  Settings,
51
+ Users,
52
52
  X,
53
53
  } from "lucide-react";
54
+ import Image from "next/image";
54
55
  import { ConfirmDialog } from "@/components/confirm-dialog";
55
56
 
56
57
  function perfEnabled(): boolean {
@@ -135,8 +136,7 @@ export function ConversationList({
135
136
  setPendingRequestTimeoutMs(String(cfg.pending_request_timeout_ms ?? 600000));
136
137
  setBotModeReplyText(
137
138
  String(
138
- (cfg as any).bot_mode_reply_text ||
139
- DEFAULT_USER_CONFIG.bot_mode_reply_text
139
+ cfg.bot_mode_reply_text || DEFAULT_USER_CONFIG.bot_mode_reply_text
140
140
  )
141
141
  );
142
142
  try {
@@ -175,7 +175,6 @@ export function ConversationList({
175
175
  setAvatarUrlMap((prev) => ({ ...prev, [key]: url }));
176
176
  if (t0) {
177
177
  const t1 = performance.now();
178
- // eslint-disable-next-line no-console
179
178
  console.log(`[perf] ensureAvatarUrl kind=${kind} id=${rawId} ${(t1 - t0).toFixed(1)}ms`);
180
179
  }
181
180
  } catch {
@@ -254,7 +253,6 @@ export function ConversationList({
254
253
  setArchivedCount(count);
255
254
  if (t0) {
256
255
  const t1 = performance.now();
257
- // eslint-disable-next-line no-console
258
256
  console.log(`[perf] conversationList loadData view=${view} items=${data.length} ${(t1 - t0).toFixed(1)}ms`);
259
257
  }
260
258
  }, [view]);
@@ -428,7 +426,6 @@ export function ConversationList({
428
426
  }, [agentsAll, pinnedKeys, pinnedSet]);
429
427
 
430
428
  const groupsPendingTotal = groups.reduce((sum, g) => sum + g.pendingCount, 0);
431
- const agentsPendingTotal = agents.reduce((sum, a) => sum + a.pendingCount, 0);
432
429
 
433
430
  const isCollapsed = !!collapsed;
434
431
 
@@ -443,14 +440,6 @@ export function ConversationList({
443
440
 
444
441
  const selectedKeyList = useMemo(() => Array.from(selectedKeys), [selectedKeys]);
445
442
 
446
- const isSelectable = useCallback(
447
- (item: ConversationItem) => {
448
- if (view === "archived") return true;
449
- return true;
450
- },
451
- [view]
452
- );
453
-
454
443
  const toggleSelected = useCallback(
455
444
  (key: string) => {
456
445
  setSelectedKeys((prev) => {
@@ -542,7 +531,7 @@ export function ConversationList({
542
531
  confirmLabel: "Delete",
543
532
  destructive: true,
544
533
  });
545
- }, [selectedKeyList, scheduleDelete, clearBulk]);
534
+ }, [selectedKeyList]);
546
535
 
547
536
  const handleArchiveAll = useCallback(async () => {
548
537
  if (view !== "active") return;
@@ -558,7 +547,7 @@ export function ConversationList({
558
547
  description: `Archive ${keys.length} conversation(s) (current filter, only pending == 0). You can unarchive later.`,
559
548
  confirmLabel: "Archive",
560
549
  });
561
- }, [view, filtered, loadData]);
550
+ }, [view, filtered]);
562
551
 
563
552
  return (
564
553
  <div
@@ -840,10 +829,9 @@ export function ConversationList({
840
829
  bulkMode={bulkMode}
841
830
  checked={selectedKeys.has(conversationKey(item))}
842
831
  onToggleChecked={() => toggleSelected(conversationKey(item))}
843
- view={view}
844
832
  onClick={() => {
845
833
  if (bulkMode) {
846
- if (isSelectable(item)) toggleSelected(conversationKey(item));
834
+ toggleSelected(conversationKey(item));
847
835
  return;
848
836
  }
849
837
  onSelect(item.id, "group", item.name);
@@ -883,10 +871,9 @@ export function ConversationList({
883
871
  bulkMode={bulkMode}
884
872
  checked={selectedKeys.has(conversationKey(item))}
885
873
  onToggleChecked={() => toggleSelected(conversationKey(item))}
886
- view={view}
887
874
  onClick={() => {
888
875
  if (bulkMode) {
889
- if (isSelectable(item)) toggleSelected(conversationKey(item));
876
+ toggleSelected(conversationKey(item));
890
877
  return;
891
878
  }
892
879
  onSelect(item.id, "agent", item.name);
@@ -1377,7 +1364,14 @@ function ConversationIconButton({
1377
1364
  title={item.displayName}
1378
1365
  >
1379
1366
  {avatarUrl ? (
1380
- <img src={avatarUrl} alt="" className="h-7 w-7 rounded-full" />
1367
+ <Image
1368
+ src={avatarUrl}
1369
+ alt=""
1370
+ width={28}
1371
+ height={28}
1372
+ unoptimized
1373
+ className="h-7 w-7 rounded-full"
1374
+ />
1381
1375
  ) : (
1382
1376
  <span className="text-xl">{emoji}</span>
1383
1377
  )}
@@ -1397,7 +1391,6 @@ function ConversationItemCard({
1397
1391
  bulkMode,
1398
1392
  checked,
1399
1393
  onToggleChecked,
1400
- view,
1401
1394
  }: {
1402
1395
  item: ConversationItem;
1403
1396
  avatarUrl?: string;
@@ -1407,9 +1400,9 @@ function ConversationItemCard({
1407
1400
  bulkMode?: boolean;
1408
1401
  checked?: boolean;
1409
1402
  onToggleChecked?: () => void;
1410
- view?: "active" | "archived";
1411
1403
  }) {
1412
1404
  const emoji = item.type === "group" ? "👥" : getAgentEmoji(item.name);
1405
+ const showAgentTags = item.type === "agent" && (item.agentRuntime || item.projectName);
1413
1406
 
1414
1407
  return (
1415
1408
  <button
@@ -1437,7 +1430,14 @@ function ConversationItemCard({
1437
1430
  <span className="relative h-9 w-9 shrink-0">
1438
1431
  <span className="flex h-full w-full items-center justify-center rounded-full bg-white/55 ring-1 ring-white/40 text-[18px] overflow-hidden">
1439
1432
  {avatarUrl ? (
1440
- <img src={avatarUrl} alt="" className="h-full w-full rounded-full" />
1433
+ <Image
1434
+ src={avatarUrl}
1435
+ alt=""
1436
+ width={36}
1437
+ height={36}
1438
+ unoptimized
1439
+ className="h-full w-full rounded-full"
1440
+ />
1441
1441
  ) : (
1442
1442
  emoji
1443
1443
  )}
@@ -1458,6 +1458,20 @@ function ConversationItemCard({
1458
1458
  </span>
1459
1459
  )}
1460
1460
  </div>
1461
+ {showAgentTags && (
1462
+ <div className="mt-0.5 flex flex-wrap gap-1">
1463
+ {item.agentRuntime && (
1464
+ <span className="inline-flex items-center rounded-full border bg-white/55 px-2 py-0.5 text-[10px] text-muted-foreground">
1465
+ {item.agentRuntime}
1466
+ </span>
1467
+ )}
1468
+ {item.projectName && (
1469
+ <span className="inline-flex items-center rounded-full border bg-white/55 px-2 py-0.5 text-[10px] text-muted-foreground">
1470
+ {item.projectName}
1471
+ </span>
1472
+ )}
1473
+ </div>
1474
+ )}
1461
1475
  {item.lastMessage && (
1462
1476
  <p className="text-[11px] text-muted-foreground whitespace-nowrap leading-4">
1463
1477
  {truncateText(item.lastMessage.replace(/\n/g, ' '), 20)}
@@ -4,6 +4,7 @@ import { type ReactNode, useMemo } from "react";
4
4
  import ReactMarkdown, { type Components } from "react-markdown";
5
5
  import rehypeHighlight from "rehype-highlight";
6
6
  import remarkGfm from "remark-gfm";
7
+ import Image from "next/image";
7
8
 
8
9
  export function MarkdownRenderer({ children }: { children: string }) {
9
10
  const normalized = useMemo(() => {
@@ -40,7 +41,17 @@ export function MarkdownRenderer({ children }: { children: string }) {
40
41
  img: (props) => {
41
42
  const safeSrc = typeof props.src === "string" ? props.src : undefined;
42
43
  const safeAlt = typeof props.alt === "string" ? props.alt : "";
43
- return <img {...props} src={safeSrc} alt={safeAlt} className="max-w-full h-auto" />;
44
+ if (!safeSrc) return null;
45
+ return (
46
+ <Image
47
+ src={safeSrc}
48
+ alt={safeAlt}
49
+ width={1200}
50
+ height={800}
51
+ unoptimized
52
+ className="max-w-full h-auto"
53
+ />
54
+ );
44
55
  },
45
56
  table: ({ children }: { children?: ReactNode }) => (
46
57
  <div className="mt-2 max-w-full overflow-auto">
@@ -124,7 +124,6 @@ export function useAvatar() {
124
124
  }
125
125
  if (t0) {
126
126
  const t1 = performance.now();
127
- // eslint-disable-next-line no-console
128
127
  console.log(
129
128
  `[perf] ensureAvatarUrl(group members) n=${members.length} ${(t1 - t0).toFixed(1)}ms`
130
129
  );
@@ -97,7 +97,6 @@ export function useConversationTimeline({
97
97
 
98
98
  if (t0) {
99
99
  const t1 = performance.now();
100
- // eslint-disable-next-line no-console
101
100
  console.log(
102
101
  `[perf] bootstrapConversation type=${type} id=${id} items=${asc.length} queue=${res.queue.length} ${(t1 - t0).toFixed(1)}ms`
103
102
  );
@@ -7,7 +7,6 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  type Dispatch,
10
- type MutableRefObject,
11
10
  type RefObject,
12
11
  type SetStateAction,
13
12
  } from "react";
@@ -54,7 +54,6 @@ export function useMessageQueue({
54
54
  setQueue(rows as QueuedMessage[]);
55
55
  if (t0) {
56
56
  const t1 = performance.now();
57
- // eslint-disable-next-line no-console
58
57
  console.log(
59
58
  `[perf] fetchMessageQueue type=${type} id=${id} n=${rows.length} ${(t1 - t0).toFixed(1)}ms`
60
59
  );
@@ -11,6 +11,8 @@ import {
11
11
  getAllAgents,
12
12
  getConversationMetaMap,
13
13
  getAgentDisplayNames,
14
+ getAgentEnv,
15
+ getAgentEnvMap,
14
16
  upsertAgentDisplayName,
15
17
  getAgentLastRequest,
16
18
  getPendingCountByAgent,
@@ -47,6 +49,9 @@ import {
47
49
  pinConversation,
48
50
  unpinConversation,
49
51
  listPinnedConversations,
52
+ getBotEnabledConversation,
53
+ setBotEnabledConversation,
54
+ listBotEnabledConversations,
50
55
  type ConversationType,
51
56
  type CueResponse,
52
57
  } from "./db";
@@ -409,6 +414,19 @@ export async function claimWorkerLease(args: {
409
414
  return acquireWorkerLease(args);
410
415
  }
411
416
 
417
+ export async function fetchBotEnabled(type: ConversationType, id: string) {
418
+ return { enabled: getBotEnabledConversation(type, id) } as const;
419
+ }
420
+
421
+ export async function updateBotEnabled(type: ConversationType, id: string, enabled: boolean) {
422
+ setBotEnabledConversation(type, id, enabled);
423
+ return { success: true, enabled } as const;
424
+ }
425
+
426
+ export async function fetchBotEnabledConversations(limit?: number) {
427
+ return listBotEnabledConversations(limit);
428
+ }
429
+
412
430
  // Responses
413
431
  export async function submitResponse(
414
432
  requestId: string,
@@ -561,6 +579,7 @@ export async function fetchConversationList(options?: {
561
579
  : groupsAll;
562
580
 
563
581
  const agentNameMap = getAgentDisplayNames(agents);
582
+ const agentEnvMap = getAgentEnvMap(agents);
564
583
 
565
584
  const groupIds = groups.map((g) => g.id);
566
585
  const groupMemberCounts = getGroupMemberCounts(groupIds);
@@ -647,6 +666,13 @@ export async function fetchConversationList(options?: {
647
666
  id: agent,
648
667
  name: agent,
649
668
  displayName: agentNameMap[agent] || agent,
669
+ agentRuntime: agentEnvMap[agent]?.agent_runtime || undefined,
670
+ projectName: (() => {
671
+ const p = agentEnvMap[agent]?.project_dir;
672
+ if (!p) return undefined;
673
+ const base = path.basename(String(p));
674
+ return base || undefined;
675
+ })(),
650
676
  pendingCount,
651
677
  lastMessage: (lastIsResp ? respMsg : reqMsg)?.slice(0, 50),
652
678
  lastTime: (lastIsResp ? lastRespTime : lastReqTime),
@@ -664,3 +690,20 @@ export async function fetchConversationList(options?: {
664
690
 
665
691
  return items;
666
692
  }
693
+
694
+ export async function fetchAgentEnv(agentId: string): Promise<{
695
+ agentRuntime?: string;
696
+ projectName?: string;
697
+ }> {
698
+ const id = String(agentId || "").trim();
699
+ if (!id) return {};
700
+ const env = getAgentEnv(id);
701
+ const agentRuntime = env?.agent_runtime || undefined;
702
+ const projectName = (() => {
703
+ const p = env?.project_dir;
704
+ if (!p) return undefined;
705
+ const base = path.basename(String(p));
706
+ return base || undefined;
707
+ })();
708
+ return { agentRuntime, projectName };
709
+ }
package/src/lib/avatar.ts CHANGED
@@ -126,7 +126,6 @@ export async function thumbsAvatarDataUrl(seed: string): Promise<string> {
126
126
 
127
127
  if (t0) {
128
128
  const t2 = performance.now();
129
- // eslint-disable-next-line no-console
130
129
  console.log(
131
130
  `[perf] thumbsAvatarDataUrl seed=${String(seed).slice(0, 8)} import=${(t1 - t0).toFixed(1)}ms encode=${(
132
131
  t2 - t1
package/src/lib/db.ts CHANGED
@@ -212,6 +212,16 @@ function initTables() {
212
212
  )
213
213
  `);
214
214
 
215
+ database.exec(`
216
+ CREATE TABLE IF NOT EXISTS bot_enabled_conversations (
217
+ conv_type TEXT NOT NULL,
218
+ conv_id TEXT NOT NULL,
219
+ enabled INTEGER NOT NULL,
220
+ updated_at DATETIME NOT NULL,
221
+ PRIMARY KEY (conv_type, conv_id)
222
+ )
223
+ `);
224
+
215
225
  // Mode B: guide migrate and exit if an old DB exists.
216
226
  const versionRow = database
217
227
  .prepare(`SELECT value FROM schema_meta WHERE key = ?`)
@@ -286,6 +296,16 @@ function initTables() {
286
296
  )
287
297
  `);
288
298
 
299
+ database.exec(`
300
+ CREATE TABLE IF NOT EXISTS agent_envs (
301
+ agent_id TEXT PRIMARY KEY,
302
+ agent_runtime TEXT,
303
+ project_dir TEXT,
304
+ agent_terminal TEXT,
305
+ updated_at DATETIME
306
+ )
307
+ `);
308
+
289
309
  database.exec(`
290
310
  CREATE TABLE IF NOT EXISTS cue_message_queue (
291
311
  id TEXT PRIMARY KEY,
@@ -411,6 +431,46 @@ export function acquireWorkerLease(args: {
411
431
 
412
432
  export type ConversationType = "agent" | "group";
413
433
 
434
+ export function setBotEnabledConversation(convType: ConversationType, convId: string, enabled: boolean): void {
435
+ const t = convType === "group" ? "group" : "agent";
436
+ const id = String(convId || "").trim();
437
+ if (!id) return;
438
+ const now = nowIso();
439
+ getDb()
440
+ .prepare(
441
+ `INSERT INTO bot_enabled_conversations (conv_type, conv_id, enabled, updated_at)
442
+ VALUES (?, ?, ?, ?)
443
+ ON CONFLICT(conv_type, conv_id) DO UPDATE SET
444
+ enabled = excluded.enabled,
445
+ updated_at = excluded.updated_at`
446
+ )
447
+ .run(t, id, enabled ? 1 : 0, now);
448
+ }
449
+
450
+ export function getBotEnabledConversation(convType: ConversationType, convId: string): boolean {
451
+ const t = convType === "group" ? "group" : "agent";
452
+ const id = String(convId || "").trim();
453
+ if (!id) return false;
454
+ const row = getDb()
455
+ .prepare(`SELECT enabled FROM bot_enabled_conversations WHERE conv_type = ? AND conv_id = ?`)
456
+ .get(t, id) as { enabled?: number } | undefined;
457
+ return Number(row?.enabled ?? 0) === 1;
458
+ }
459
+
460
+ export function listBotEnabledConversations(limit?: number): Array<{ conv_type: ConversationType; conv_id: string }> {
461
+ const lim = Math.max(1, Math.min(500, Math.floor(Number(limit ?? 200))));
462
+ const rows = getDb()
463
+ .prepare(
464
+ `SELECT conv_type, conv_id
465
+ FROM bot_enabled_conversations
466
+ WHERE enabled = 1
467
+ ORDER BY updated_at DESC
468
+ LIMIT ?`
469
+ )
470
+ .all(lim) as Array<{ conv_type: ConversationType; conv_id: string }>;
471
+ return rows || [];
472
+ }
473
+
414
474
  export interface CueQueuedMessage {
415
475
  id: string;
416
476
  conv_type: ConversationType;
@@ -859,6 +919,41 @@ export function getAgentDisplayNames(agentIds: string[]): Record<string, string>
859
919
  return map;
860
920
  }
861
921
 
922
+ export interface AgentEnv {
923
+ agent_id: string;
924
+ agent_runtime: string | null;
925
+ project_dir: string | null;
926
+ agent_terminal: string | null;
927
+ updated_at: string | null;
928
+ }
929
+
930
+ export function getAgentEnvMap(agentIds: string[]): Record<string, AgentEnv> {
931
+ const unique = Array.from(new Set(agentIds.filter(Boolean)));
932
+ if (unique.length === 0) return {};
933
+ const placeholders = unique.map(() => "?").join(",");
934
+ const rows = getDb()
935
+ .prepare(
936
+ `SELECT agent_id, agent_runtime, project_dir, agent_terminal, updated_at
937
+ FROM agent_envs
938
+ WHERE agent_id IN (${placeholders})`
939
+ )
940
+ .all(...unique) as AgentEnv[];
941
+ const map: Record<string, AgentEnv> = {};
942
+ for (const r of rows) map[r.agent_id] = r;
943
+ return map;
944
+ }
945
+
946
+ export function getAgentEnv(agentId: string): AgentEnv | undefined {
947
+ const row = getDb()
948
+ .prepare(
949
+ `SELECT agent_id, agent_runtime, project_dir, agent_terminal, updated_at
950
+ FROM agent_envs
951
+ WHERE agent_id = ?`
952
+ )
953
+ .get(agentId) as AgentEnv | undefined;
954
+ return row;
955
+ }
956
+
862
957
  // Type definitions
863
958
  export interface CueRequest {
864
959
  id: number;
package/src/lib/types.ts CHANGED
@@ -86,4 +86,6 @@ export interface ConversationItem {
86
86
  lastMessage?: string;
87
87
  lastTime?: string;
88
88
  pendingCount: number;
89
+ agentRuntime?: string;
90
+ projectName?: string;
89
91
  }
package/src/types/chat.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CueRequest, CueResponse } from "@/lib/actions";
1
+ import type { CueRequest } from "@/lib/actions";
2
2
 
3
3
  export type ChatType = "agent" | "group";
4
4