cue-console 0.1.14 → 0.1.16

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require("node:child_process");
4
- const fs = require("node:fs");
5
- const path = require("node:path");
3
+ let spawn;
4
+ let fs;
5
+ let path;
6
6
 
7
7
  function printHelp() {
8
8
  process.stdout.write(
@@ -44,6 +44,13 @@ function parseArgs(argv) {
44
44
  }
45
45
 
46
46
  async function main() {
47
+ if (!spawn || !fs || !path) {
48
+ const childProcess = await import("node:child_process");
49
+ spawn = childProcess.spawn;
50
+ fs = await import("node:fs");
51
+ path = await import("node:path");
52
+ }
53
+
47
54
  const { command, port, host, passthrough } = parseArgs(process.argv.slice(2));
48
55
 
49
56
  if (!command) {
@@ -57,12 +64,11 @@ async function main() {
57
64
  process.exit(1);
58
65
  }
59
66
 
60
- let nextBin;
61
- try {
62
- nextBin = require.resolve("next/dist/bin/next");
63
- } catch (e) {
67
+ const pkgRoot = path.resolve(__dirname, "..");
68
+ const nextBin = path.join(pkgRoot, "node_modules", "next", "dist", "bin", "next");
69
+ if (!fs.existsSync(nextBin)) {
64
70
  process.stderr.write(
65
- "Unable to resolve Next.js CLI. Please install dependencies first (e.g. pnpm install).\n"
71
+ "Unable to resolve Next.js CLI. Please install dependencies first (e.g. npm install).\n"
66
72
  );
67
73
  process.exit(1);
68
74
  }
@@ -71,8 +77,6 @@ async function main() {
71
77
  if (port) env.PORT = String(port);
72
78
  if (host) env.HOSTNAME = String(host);
73
79
 
74
- const pkgRoot = path.resolve(__dirname, "..");
75
-
76
80
  const spawnNext = (subcmd) =>
77
81
  new Promise((resolve, reject) => {
78
82
  const child = spawn(process.execPath, [nextBin, subcmd, ...passthrough], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cue-console",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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
@@ -12,7 +12,16 @@ export default function Home() {
12
12
  const [selectedType, setSelectedType] = useState<"agent" | "group" | null>(null);
13
13
  const [selectedName, setSelectedName] = useState<string>("");
14
14
  const [showCreateGroup, setShowCreateGroup] = useState(false);
15
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
15
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
16
+ try {
17
+ const raw = window.localStorage.getItem("cuehub.sidebarCollapsed");
18
+ if (raw === "1") return true;
19
+ if (raw === "0") return false;
20
+ return false;
21
+ } catch {
22
+ return false;
23
+ }
24
+ });
16
25
 
17
26
  useEffect(() => {
18
27
  let stopped = false;
@@ -105,16 +114,6 @@ export default function Home() {
105
114
  };
106
115
  }, []);
107
116
 
108
- useEffect(() => {
109
- try {
110
- const raw = window.localStorage.getItem("cuehub.sidebarCollapsed");
111
- if (raw === "1") setSidebarCollapsed(true);
112
- if (raw === "0") setSidebarCollapsed(false);
113
- } catch {
114
- // ignore
115
- }
116
- }, []);
117
-
118
117
  useEffect(() => {
119
118
  try {
120
119
  window.localStorage.setItem("cuehub.sidebarCollapsed", sidebarCollapsed ? "1" : "0");
@@ -1,10 +1,11 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useState } from "react";
2
2
  import { Badge } from "@/components/ui/badge";
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { cn, getAgentEmoji, formatFullTime, getWaitingDuration } from "@/lib/utils";
5
5
  import { MarkdownRenderer } from "@/components/markdown-renderer";
6
6
  import { PayloadCard } from "@/components/payload-card";
7
7
  import type { CueRequest } from "@/lib/actions";
8
+ import { Copy, Check } from "lucide-react";
8
9
 
9
10
  interface MessageBubbleProps {
10
11
  request: CueRequest;
@@ -44,6 +45,17 @@ export function MessageBubble({
44
45
  onCancel,
45
46
  }: MessageBubbleProps) {
46
47
  const isPending = request.status === "PENDING";
48
+ const [copied, setCopied] = useState(false);
49
+
50
+ const handleCopy = async () => {
51
+ try {
52
+ await navigator.clipboard.writeText(request.prompt || "");
53
+ setCopied(true);
54
+ setTimeout(() => setCopied(false), 2000);
55
+ } catch (err) {
56
+ console.error("Failed to copy:", err);
57
+ }
58
+ };
47
59
 
48
60
  const isPause = useMemo(() => {
49
61
  if (!request.payload) return false;
@@ -133,6 +145,17 @@ export function MessageBubble({
133
145
  </div>
134
146
  <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
135
147
  <span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ className="h-auto p-1 text-xs"
152
+ onClick={handleCopy}
153
+ disabled={disabled}
154
+ title={copied ? "已复制" : "复制"}
155
+ >
156
+ {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
157
+ <span className="ml-1">{copied ? "已复制" : "复制"}</span>
158
+ </Button>
136
159
  {isPending && (
137
160
  <>
138
161
  <Badge variant="outline" className="text-xs shrink-0">
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { memo, useMemo, type ReactNode } from "react";
3
+ import { memo, useMemo, useState, type ReactNode } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import { Badge } from "@/components/ui/badge";
6
6
  import { cn, formatFullTime, getAgentEmoji, getWaitingDuration } from "@/lib/utils";
7
7
  import { MarkdownRenderer } from "@/components/markdown-renderer";
8
8
  import { PayloadCard } from "@/components/payload-card";
9
9
  import type { AgentTimelineItem, CueRequest, CueResponse } from "@/lib/actions";
10
+ import { Copy, Check } from "lucide-react";
10
11
 
11
12
  function parseDbTime(dateStr: string) {
12
13
  return new Date((dateStr || "").replace(" ", "T"));
@@ -193,6 +194,17 @@ const MessageBubble = memo(function MessageBubble({
193
194
  onCancel?: () => void;
194
195
  }) {
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
+ };
196
208
 
197
209
  const isPause = useMemo(() => {
198
210
  if (!request.payload) return false;
@@ -282,6 +294,17 @@ const MessageBubble = memo(function MessageBubble({
282
294
  </div>
283
295
  <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
284
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>
285
308
  {isPending && (
286
309
  <>
287
310
  <Badge variant="outline" className="text-xs shrink-0">
@@ -350,13 +373,17 @@ const UserResponseBubble = memo(function UserResponseBubble({
350
373
  mentions?: { userId: string; start: number; length: number; display: string }[];
351
374
  };
352
375
 
353
- const files = Array.isArray((response as any).files) ? ((response as any).files as any[]) : [];
376
+ const filesRaw = (response as unknown as { files?: unknown }).files;
377
+ const files = Array.isArray(filesRaw) ? filesRaw : [];
354
378
  const imageFiles = files.filter((f) => {
355
- const mime = String(f?.mime_type || "");
356
- return mime.startsWith("image/") && typeof f?.inline_base64 === "string" && f.inline_base64.length > 0;
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;
357
383
  });
358
384
  const otherFiles = files.filter((f) => {
359
- const mime = String(f?.mime_type || "");
385
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
386
+ const mime = String(obj?.mime_type || "");
360
387
  return !mime.startsWith("image/");
361
388
  });
362
389
 
@@ -423,8 +450,9 @@ const UserResponseBubble = memo(function UserResponseBubble({
423
450
  {imageFiles.length > 0 && (
424
451
  <div className="flex flex-wrap gap-2 mt-2 max-w-full">
425
452
  {imageFiles.map((f, i) => {
426
- const mime = String(f?.mime_type || "image/png");
427
- const b64 = String(f?.inline_base64 || "");
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 || "");
428
456
  const img = { mime_type: mime, base64_data: b64 };
429
457
  return (
430
458
  <img
@@ -442,7 +470,8 @@ const UserResponseBubble = memo(function UserResponseBubble({
442
470
  {otherFiles.length > 0 && (
443
471
  <div className="mt-2 flex flex-col gap-1 max-w-full">
444
472
  {otherFiles.map((f, i) => {
445
- const fileRef = String(f?.file || "");
473
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
474
+ const fileRef = String(obj?.file || "");
446
475
  const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
447
476
  return (
448
477
  <div
@@ -21,13 +21,17 @@ export function UserResponseBubble({
21
21
  mentions?: { userId: string; start: number; length: number; display: string }[];
22
22
  };
23
23
 
24
- const files = Array.isArray((response as any).files) ? ((response as any).files as any[]) : [];
24
+ const filesRaw = (response as unknown as { files?: unknown }).files;
25
+ const files = Array.isArray(filesRaw) ? filesRaw : [];
25
26
  const imageFiles = files.filter((f) => {
26
- const mime = String(f?.mime_type || "");
27
- return mime.startsWith("image/") && typeof f?.inline_base64 === "string" && f.inline_base64.length > 0;
27
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
28
+ const mime = String(obj?.mime_type || "");
29
+ const b64 = obj?.inline_base64;
30
+ return mime.startsWith("image/") && typeof b64 === "string" && b64.length > 0;
28
31
  });
29
32
  const otherFiles = files.filter((f) => {
30
- const mime = String(f?.mime_type || "");
33
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
34
+ const mime = String(obj?.mime_type || "");
31
35
  return !mime.startsWith("image/");
32
36
  });
33
37
 
@@ -99,8 +103,9 @@ export function UserResponseBubble({
99
103
  {imageFiles.length > 0 && (
100
104
  <div className="flex flex-wrap gap-2 mt-2 max-w-full">
101
105
  {imageFiles.map((f, i) => {
102
- const mime = String(f?.mime_type || "image/png");
103
- const b64 = String(f?.inline_base64 || "");
106
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
107
+ const mime = String(obj?.mime_type || "image/png");
108
+ const b64 = String(obj?.inline_base64 || "");
104
109
  const img = { mime_type: mime, base64_data: b64 };
105
110
  return (
106
111
  <img
@@ -118,7 +123,8 @@ export function UserResponseBubble({
118
123
  {otherFiles.length > 0 && (
119
124
  <div className="mt-2 flex flex-col gap-1 max-w-full">
120
125
  {otherFiles.map((f, i) => {
121
- const fileRef = String(f?.file || "");
126
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
127
+ const fileRef = String(obj?.file || "");
122
128
  const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
123
129
  return (
124
130
  <div
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  useMemo,
5
+ useRef,
5
6
  useState,
6
7
  type ChangeEvent,
7
8
  type ClipboardEvent,
@@ -63,6 +64,8 @@ export function ChatComposer({
63
64
  canSend,
64
65
  hasPendingRequests,
65
66
  input,
67
+ conversationMode,
68
+ setConversationMode,
66
69
  setInput,
67
70
  images,
68
71
  setImages,
@@ -103,6 +106,8 @@ export function ChatComposer({
103
106
  canSend: boolean;
104
107
  hasPendingRequests: boolean;
105
108
  input: string;
109
+ conversationMode: "chat" | "agent";
110
+ setConversationMode: (mode: "chat" | "agent") => void;
106
111
  setInput: Dispatch<SetStateAction<string>>;
107
112
  images: { mime_type: string; base64_data: string; file_name?: string }[];
108
113
  setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
@@ -147,6 +152,7 @@ export function ChatComposer({
147
152
  }, [onBack]);
148
153
 
149
154
  const [dragIndex, setDragIndex] = useState<number | null>(null);
155
+ const isComposingRef = useRef(false);
150
156
 
151
157
  const submitOrQueue = () => {
152
158
  if (busy) return;
@@ -412,6 +418,12 @@ export function ChatComposer({
412
418
  }
413
419
  value={input}
414
420
  onPaste={handlePaste}
421
+ onCompositionStart={() => {
422
+ isComposingRef.current = true;
423
+ }}
424
+ onCompositionEnd={() => {
425
+ isComposingRef.current = false;
426
+ }}
415
427
  onChange={(e) => {
416
428
  const next = e.target.value;
417
429
  setInput(next);
@@ -508,7 +520,7 @@ export function ChatComposer({
508
520
  }
509
521
  }
510
522
 
511
- if (e.key === "Enter" && !e.shiftKey) {
523
+ if (e.key === "Enter" && !e.shiftKey && !isComposingRef.current) {
512
524
  e.preventDefault();
513
525
  submitOrQueue();
514
526
  }
@@ -563,6 +575,27 @@ export function ChatComposer({
563
575
  <Plus className="h-4.5 w-4.5" />
564
576
  </Button>
565
577
 
578
+ <Button
579
+ type="button"
580
+ variant="ghost"
581
+ size="sm"
582
+ className={cn(
583
+ "h-8 rounded-xl px-2",
584
+ conversationMode === "chat"
585
+ ? "bg-white/35 text-foreground ring-1 ring-white/25"
586
+ : "text-muted-foreground hover:text-foreground",
587
+ "hover:bg-white/40"
588
+ )}
589
+ onClick={() => {
590
+ if (busy) return;
591
+ setConversationMode(conversationMode === "chat" ? "agent" : "chat");
592
+ }}
593
+ disabled={busy}
594
+ title={conversationMode === "chat" ? "Chat mode" : "Agent mode"}
595
+ >
596
+ {conversationMode === "chat" ? "Chat" : "Agent"}
597
+ </Button>
598
+
566
599
  <Button
567
600
  type="button"
568
601
  variant="ghost"
@@ -77,7 +77,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
77
77
  { mime_type: string; base64_data: string } | null
78
78
  >(null);
79
79
 
80
- const { input, images, setInput, setImages } = useInputContext();
80
+ const { input, images, conversationMode, setConversationMode, setInput, setImages } = useInputContext();
81
81
  const { busy, error, notice, setBusy, setError, setNotice } = useUIStateContext();
82
82
  const deferredInput = useDeferredValue(input);
83
83
  const imagesRef = useRef(images);
@@ -179,7 +179,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
179
179
 
180
180
  useEffect(() => {
181
181
  if (type !== "group") return;
182
- setGroupTitle(name);
182
+ queueMicrotask(() => setGroupTitle(name));
183
183
  }, [name, type]);
184
184
 
185
185
  const {
@@ -372,6 +372,25 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
372
372
  setMentions([]);
373
373
  }, [type, id]);
374
374
 
375
+ const loadMore = useCallback(async () => {
376
+ if (!nextCursor) return;
377
+ if (loadingMore) return;
378
+
379
+ const el = scrollRef.current;
380
+ const prevScrollHeight = el?.scrollHeight ?? 0;
381
+ const prevScrollTop = el?.scrollTop ?? 0;
382
+
383
+ const res = await loadMorePage(nextCursor);
384
+ requestAnimationFrame(() => {
385
+ const cur = scrollRef.current;
386
+ if (!cur) return;
387
+ const newScrollHeight = cur.scrollHeight;
388
+ cur.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
389
+ });
390
+
391
+ nextCursorRef.current = res.cursor;
392
+ }, [loadMorePage, loadingMore, nextCursor]);
393
+
375
394
  useEffect(() => {
376
395
  const el = scrollRef.current;
377
396
  if (!el) return;
@@ -403,25 +422,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
403
422
  el.scrollTop = el.scrollHeight;
404
423
  }, [timeline, isAtBottom]);
405
424
 
406
- const loadMore = useCallback(async () => {
407
- if (!nextCursor) return;
408
- if (loadingMore) return;
409
-
410
- const el = scrollRef.current;
411
- const prevScrollHeight = el?.scrollHeight ?? 0;
412
- const prevScrollTop = el?.scrollTop ?? 0;
413
-
414
- const res = await loadMorePage(nextCursor);
415
- requestAnimationFrame(() => {
416
- const cur = scrollRef.current;
417
- if (!cur) return;
418
- const newScrollHeight = cur.scrollHeight;
419
- cur.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
420
- });
421
-
422
- nextCursorRef.current = res.cursor;
423
- }, [loadMorePage, loadingMore, nextCursor]);
424
-
425
425
  const handleSubmitConfirm = useCallback(async (requestId: string, text: string, cancelled: boolean) => {
426
426
  if (busy) return;
427
427
  setBusy(true);
@@ -611,6 +611,8 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
611
611
  canSend={canSend}
612
612
  hasPendingRequests={hasPendingRequests}
613
613
  input={input}
614
+ conversationMode={conversationMode}
615
+ setConversationMode={setConversationMode}
614
616
  setInput={setInput}
615
617
  images={images}
616
618
  setImages={setImages}
@@ -26,8 +26,11 @@ import {
26
26
  deleteConversations,
27
27
  fetchArchivedConversationCount,
28
28
  fetchConversationList,
29
+ fetchPinnedConversationKeys,
29
30
  getUserConfig,
31
+ pinConversationByKey,
30
32
  setUserConfig,
33
+ unpinConversationByKey,
31
34
  unarchiveConversations,
32
35
  type ConversationItem,
33
36
  } from "@/lib/actions";
@@ -42,6 +45,7 @@ import {
42
45
  ChevronLeft,
43
46
  ChevronRight,
44
47
  MoreHorizontal,
48
+ Pin,
45
49
  Settings,
46
50
  X,
47
51
  } from "lucide-react";
@@ -60,6 +64,14 @@ function conversationKey(item: Pick<ConversationItem, "type" | "id">) {
60
64
  return `${item.type}:${item.id}`;
61
65
  }
62
66
 
67
+ type IdleCallbackHandle = number;
68
+ type IdleRequestCallback = () => void;
69
+ type IdleRequestOptions = { timeout?: number };
70
+ type GlobalWithIdleCallbacks = typeof globalThis & {
71
+ requestIdleCallback?: (cb: IdleRequestCallback, opts?: IdleRequestOptions) => IdleCallbackHandle;
72
+ cancelIdleCallback?: (handle: IdleCallbackHandle) => void;
73
+ };
74
+
63
75
  interface ConversationListProps {
64
76
  selectedId: string | null;
65
77
  selectedType: "agent" | "group" | null;
@@ -103,18 +115,36 @@ export function ConversationList({
103
115
 
104
116
  const [settingsOpen, setSettingsOpen] = useState(false);
105
117
  const [soundEnabled, setSoundEnabled] = useState(true);
118
+ const [conversationModeDefault, setConversationModeDefault] = useState<"chat" | "agent">("agent");
119
+ const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
106
120
 
107
121
  useEffect(() => {
108
122
  void (async () => {
109
123
  try {
110
124
  const cfg = await getUserConfig();
111
125
  setSoundEnabled(Boolean(cfg.sound_enabled));
126
+ const nextMode = cfg.conversation_mode_default === "chat" ? "chat" : "agent";
127
+ setConversationModeDefault(nextMode);
128
+ try {
129
+ window.localStorage.setItem("cue-console:conversationModeDefault", nextMode);
130
+ } catch {
131
+ }
112
132
  } catch {
113
- // ignore
114
133
  }
115
134
  })();
116
135
  }, []);
117
136
 
137
+ useEffect(() => {
138
+ void (async () => {
139
+ try {
140
+ const keys = await fetchPinnedConversationKeys(view);
141
+ setPinnedKeys(Array.isArray(keys) ? keys : []);
142
+ } catch {
143
+ setPinnedKeys([]);
144
+ }
145
+ })();
146
+ }, [view]);
147
+
118
148
  const ensureAvatarUrl = useCallback(async (kind: "agent" | "group", rawId: string) => {
119
149
  if (!rawId) return;
120
150
  const t0 = perfEnabled() ? performance.now() : 0;
@@ -254,14 +284,16 @@ export function ConversationList({
254
284
  }
255
285
 
256
286
  const scheduleIdle = (fn: () => void) => {
257
- if (typeof (globalThis as any).requestIdleCallback === "function") {
258
- return (globalThis as any).requestIdleCallback(fn, { timeout: 1000 });
287
+ const g = globalThis as GlobalWithIdleCallbacks;
288
+ if (typeof g.requestIdleCallback === "function") {
289
+ return g.requestIdleCallback(fn, { timeout: 1000 });
259
290
  }
260
291
  return window.setTimeout(fn, 60);
261
292
  };
262
293
  const cancelIdle = (handle: number) => {
263
- if (typeof (globalThis as any).cancelIdleCallback === "function") {
264
- (globalThis as any).cancelIdleCallback(handle);
294
+ const g = globalThis as GlobalWithIdleCallbacks;
295
+ if (typeof g.cancelIdleCallback === "function") {
296
+ g.cancelIdleCallback(handle);
265
297
  return;
266
298
  }
267
299
  window.clearTimeout(handle);
@@ -356,8 +388,30 @@ export function ConversationList({
356
388
  item.displayName.toLowerCase().includes(search.toLowerCase())
357
389
  );
358
390
 
359
- const groups = filtered.filter((i) => i.type === "group");
360
- const agents = filtered.filter((i) => i.type === "agent");
391
+ const pinnedSet = useMemo(() => new Set(pinnedKeys), [pinnedKeys]);
392
+
393
+ const groupsAll = filtered.filter((i) => i.type === "group");
394
+ const agentsAll = filtered.filter((i) => i.type === "agent");
395
+
396
+ const groups = useMemo(() => {
397
+ const byKey = new Map(groupsAll.map((g) => [conversationKey(g), g] as const));
398
+ const pinned = pinnedKeys
399
+ .filter((k) => k.startsWith("group:"))
400
+ .map((k) => byKey.get(k))
401
+ .filter(Boolean) as ConversationItem[];
402
+ const rest = groupsAll.filter((g) => !pinnedSet.has(conversationKey(g)));
403
+ return [...pinned, ...rest];
404
+ }, [groupsAll, pinnedKeys, pinnedSet]);
405
+
406
+ const agents = useMemo(() => {
407
+ const byKey = new Map(agentsAll.map((a) => [conversationKey(a), a] as const));
408
+ const pinned = pinnedKeys
409
+ .filter((k) => k.startsWith("agent:"))
410
+ .map((k) => byKey.get(k))
411
+ .filter(Boolean) as ConversationItem[];
412
+ const rest = agentsAll.filter((a) => !pinnedSet.has(conversationKey(a)));
413
+ return [...pinned, ...rest];
414
+ }, [agentsAll, pinnedKeys, pinnedSet]);
361
415
 
362
416
  const groupsPendingTotal = groups.reduce((sum, g) => sum + g.pendingCount, 0);
363
417
  const agentsPendingTotal = agents.reduce((sum, a) => sum + a.pendingCount, 0);
@@ -445,7 +499,7 @@ export function ConversationList({
445
499
 
446
500
  useEffect(() => {
447
501
  if (pendingDelete.length > 0) {
448
- setUndoToastKey((v) => v + 1);
502
+ queueMicrotask(() => setUndoToastKey((v) => v + 1));
449
503
  }
450
504
  }, [pendingDelete.length]);
451
505
 
@@ -768,6 +822,7 @@ export function ConversationList({
768
822
  item={item}
769
823
  avatarUrl={avatarUrlMap[`group:${item.id}`]}
770
824
  isSelected={selectedId === item.id && selectedType === "group"}
825
+ isPinned={pinnedSet.has(conversationKey(item))}
771
826
  bulkMode={bulkMode}
772
827
  checked={selectedKeys.has(conversationKey(item))}
773
828
  onToggleChecked={() => toggleSelected(conversationKey(item))}
@@ -810,6 +865,7 @@ export function ConversationList({
810
865
  item={item}
811
866
  avatarUrl={avatarUrlMap[`agent:${item.id}`]}
812
867
  isSelected={selectedId === item.id && selectedType === "agent"}
868
+ isPinned={pinnedSet.has(conversationKey(item))}
813
869
  bulkMode={bulkMode}
814
870
  checked={selectedKeys.has(conversationKey(item))}
815
871
  onToggleChecked={() => toggleSelected(conversationKey(item))}
@@ -934,6 +990,32 @@ export function ConversationList({
934
990
  style={{ left: menu.x, top: menu.y }}
935
991
  onPointerDown={(e) => e.stopPropagation()}
936
992
  >
993
+ {(() => {
994
+ const isPinned = pinnedSet.has(menu.key);
995
+ return (
996
+ <button
997
+ className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-accent"
998
+ onClick={async () => {
999
+ const key = menu.key;
1000
+ setMenu({ open: false });
1001
+ if (!key) return;
1002
+ if (isPinned) {
1003
+ await unpinConversationByKey(key, view);
1004
+ setPinnedKeys((prev) => prev.filter((k) => k !== key));
1005
+ return;
1006
+ }
1007
+ await pinConversationByKey(key, view);
1008
+ setPinnedKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
1009
+ }}
1010
+ >
1011
+ <span className="inline-flex items-center gap-2">
1012
+ <Pin className="h-4 w-4" />
1013
+ {isPinned ? "Unpin" : "Pin"}
1014
+ </span>
1015
+ </button>
1016
+ );
1017
+ })()}
1018
+
937
1019
  {view === "archived" ? (
938
1020
  <button
939
1021
  className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-50"
@@ -1031,7 +1113,6 @@ export function ConversationList({
1031
1113
  try {
1032
1114
  await setUserConfig({ sound_enabled: next });
1033
1115
  } catch {
1034
- // ignore
1035
1116
  }
1036
1117
  window.dispatchEvent(
1037
1118
  new CustomEvent("cue-console:configUpdated", {
@@ -1043,6 +1124,63 @@ export function ConversationList({
1043
1124
  {soundEnabled ? "On" : "Off"}
1044
1125
  </Button>
1045
1126
  </div>
1127
+
1128
+ <div className="mt-4 flex items-center justify-between gap-3">
1129
+ <div className="min-w-0">
1130
+ <div className="text-sm font-medium">Default conversation mode</div>
1131
+ <div className="text-xs text-muted-foreground truncate">
1132
+ Used when opening cue-console (unless a last-used mode exists)
1133
+ </div>
1134
+ </div>
1135
+ <div className="flex items-center gap-2">
1136
+ <Button
1137
+ type="button"
1138
+ variant={conversationModeDefault === "chat" ? "default" : "outline"}
1139
+ onClick={async () => {
1140
+ const next = "chat" as const;
1141
+ setConversationModeDefault(next);
1142
+ try {
1143
+ await setUserConfig({ conversation_mode_default: next });
1144
+ } catch {
1145
+ }
1146
+ try {
1147
+ window.localStorage.setItem("cue-console:conversationModeDefault", next);
1148
+ } catch {
1149
+ }
1150
+ window.dispatchEvent(
1151
+ new CustomEvent("cue-console:configUpdated", {
1152
+ detail: { conversation_mode_default: next },
1153
+ })
1154
+ );
1155
+ }}
1156
+ >
1157
+ Chat
1158
+ </Button>
1159
+ <Button
1160
+ type="button"
1161
+ variant={conversationModeDefault === "agent" ? "default" : "outline"}
1162
+ onClick={async () => {
1163
+ const next = "agent" as const;
1164
+ setConversationModeDefault(next);
1165
+ try {
1166
+ await setUserConfig({ conversation_mode_default: next });
1167
+ } catch {
1168
+ }
1169
+ try {
1170
+ window.localStorage.setItem("cue-console:conversationModeDefault", next);
1171
+ } catch {
1172
+ }
1173
+ window.dispatchEvent(
1174
+ new CustomEvent("cue-console:configUpdated", {
1175
+ detail: { conversation_mode_default: next },
1176
+ })
1177
+ );
1178
+ }}
1179
+ >
1180
+ Agent
1181
+ </Button>
1182
+ </div>
1183
+ </div>
1046
1184
  </DialogContent>
1047
1185
  </Dialog>
1048
1186
 
@@ -1131,6 +1269,7 @@ function ConversationItemCard({
1131
1269
  item,
1132
1270
  avatarUrl,
1133
1271
  isSelected,
1272
+ isPinned,
1134
1273
  onClick,
1135
1274
  bulkMode,
1136
1275
  checked,
@@ -1140,6 +1279,7 @@ function ConversationItemCard({
1140
1279
  item: ConversationItem;
1141
1280
  avatarUrl?: string;
1142
1281
  isSelected: boolean;
1282
+ isPinned?: boolean;
1143
1283
  onClick: () => void;
1144
1284
  bulkMode?: boolean;
1145
1285
  checked?: boolean;
@@ -1155,7 +1295,9 @@ function ConversationItemCard({
1155
1295
  "backdrop-blur-sm",
1156
1296
  isSelected
1157
1297
  ? "bg-primary/10 text-accent-foreground shadow-sm"
1158
- : "hover:bg-white/40"
1298
+ : isPinned
1299
+ ? "bg-amber-200/15 hover:bg-amber-200/20"
1300
+ : "hover:bg-white/40"
1159
1301
  )}
1160
1302
  onClick={onClick}
1161
1303
  >
@@ -1183,7 +1325,10 @@ function ConversationItemCard({
1183
1325
  </span>
1184
1326
  <div className="flex-1 min-w-0">
1185
1327
  <div className="flex items-center justify-between gap-2">
1186
- <span className="text-sm font-medium leading-5">{truncateText(item.displayName, 18)}</span>
1328
+ <span className="inline-flex items-center gap-1.5 text-sm font-medium leading-5">
1329
+ {isPinned && <Pin className="h-3.5 w-3.5 shrink-0 text-amber-600/80" />}
1330
+ {truncateText(item.displayName, 18)}
1331
+ </span>
1187
1332
  {item.lastTime && (
1188
1333
  <span className="text-[11px] text-muted-foreground shrink-0">
1189
1334
  {formatTime(item.lastTime)}
@@ -4,8 +4,6 @@ import { useState } from "react";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { cn } from "@/lib/utils";
7
-
8
- import type { OnPasteChoice, ParsedViewModel } from "./types";
9
7
  import {
10
8
  fieldDisplayName,
11
9
  findFieldLine,
@@ -13,6 +11,7 @@ import {
13
11
  parseMultiValues,
14
12
  toggleValue,
15
13
  } from "./utils";
14
+ import type { OnPasteChoice, ParsedViewModel, ParsedChoice } from "./types";
16
15
 
17
16
  export function PayloadFormView({
18
17
  vm,
@@ -100,7 +99,7 @@ export function PayloadFormView({
100
99
  {options.length > 0 ? (
101
100
  <div className="grid grid-cols-1 gap-2">
102
101
  {options.map((opt, oidx) => {
103
- const value = formatChoiceLabel(opt as any);
102
+ const value = formatChoiceLabel(opt as ParsedChoice);
104
103
  const title = value ? `Click to select: ${fieldKey}: ${value}` : undefined;
105
104
  const isSelected = allowMultiple && !!value && currentSet.has(value);
106
105
  return (
@@ -1,11 +1,17 @@
1
+ "use client";
2
+
1
3
  import { createContext, useContext, useState, type ReactNode } from "react";
2
4
  import type { ImageAttachment } from "@/types/chat";
3
5
 
6
+ type ConversationMode = "chat" | "agent";
7
+
4
8
  interface InputContextValue {
5
9
  input: string;
6
10
  images: ImageAttachment[];
11
+ conversationMode: ConversationMode;
7
12
  setInput: React.Dispatch<React.SetStateAction<string>>;
8
13
  setImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
14
+ setConversationMode: (mode: ConversationMode) => void;
9
15
  }
10
16
 
11
17
  const InputContext = createContext<InputContextValue | null>(null);
@@ -25,12 +31,32 @@ interface InputProviderProps {
25
31
  export function InputProvider({ children }: InputProviderProps) {
26
32
  const [input, setInput] = useState("");
27
33
  const [images, setImages] = useState<ImageAttachment[]>([]);
34
+ const [conversationMode, setConversationModeState] = useState<ConversationMode>(() => {
35
+ try {
36
+ const last = window.localStorage.getItem("cue-console:conversationMode");
37
+ if (last === "agent" || last === "chat") return last;
38
+ const def = window.localStorage.getItem("cue-console:conversationModeDefault");
39
+ return def === "agent" || def === "chat" ? def : "agent";
40
+ } catch {
41
+ return "agent";
42
+ }
43
+ });
44
+
45
+ const setConversationMode = (mode: ConversationMode) => {
46
+ setConversationModeState(mode);
47
+ try {
48
+ window.localStorage.setItem("cue-console:conversationMode", mode);
49
+ } catch {
50
+ }
51
+ };
28
52
 
29
53
  const value: InputContextValue = {
30
54
  input,
31
55
  images,
56
+ conversationMode,
32
57
  setInput,
33
58
  setImages,
59
+ setConversationMode,
34
60
  };
35
61
 
36
62
  return <InputContext.Provider value={value}>{children}</InputContext.Provider>;
@@ -7,6 +7,7 @@ import {
7
7
  fetchGroupTimeline,
8
8
  type AgentTimelineItem,
9
9
  type CueRequest,
10
+ type QueuedMessage,
10
11
  } from "@/lib/actions";
11
12
 
12
13
  export function useConversationTimeline({
@@ -29,7 +30,7 @@ export function useConversationTimeline({
29
30
  onBootstrap: (res: {
30
31
  members: string[];
31
32
  agentNameMap: Record<string, string>;
32
- queue: any[];
33
+ queue: QueuedMessage[];
33
34
  timeline: { items: AgentTimelineItem[]; nextCursor: string | null };
34
35
  }) => void;
35
36
  isPauseRequest: (req: CueRequest) => boolean;
@@ -4,6 +4,8 @@ import { handleError, logError } from "@/lib/error-handler";
4
4
  import { useInputContext } from "@/contexts/input-context";
5
5
  import { useUIStateContext } from "@/contexts/ui-state-context";
6
6
 
7
+ type InlineAttachment = Awaited<ReturnType<typeof fileToInlineAttachment>>;
8
+
7
9
  interface UseFileHandlerParams {
8
10
  inputWrapRef: React.RefObject<HTMLDivElement | null>;
9
11
  }
@@ -23,7 +25,7 @@ export function useFileHandler({ inputWrapRef }: UseFileHandlerParams) {
23
25
 
24
26
  const successful = results
25
27
  .filter((r) => r.status === "fulfilled")
26
- .map((r) => (r as PromiseFulfilledResult<any>).value);
28
+ .map((r) => (r as PromiseFulfilledResult<InlineAttachment>).value);
27
29
 
28
30
  results.forEach((r, i) => {
29
31
  if (r.status === "rejected") {
@@ -321,7 +321,7 @@ export function useMentions({
321
321
  const queryChanged = prevMentionQueryRef.current !== mentionQuery;
322
322
  if (queryChanged) {
323
323
  shouldAutoScrollMentionRef.current = true;
324
- setMentionActive(0);
324
+ queueMicrotask(() => setMentionActive(0));
325
325
  el.scrollTop = 0;
326
326
  mentionScrollTopRef.current = 0;
327
327
  }
@@ -14,7 +14,7 @@ interface UseMessageSenderParams {
14
14
  }
15
15
 
16
16
  export function useMessageSender({ type, pendingRequests, mentions, onSuccess }: UseMessageSenderParams) {
17
- const { input, images, setInput, setImages } = useInputContext();
17
+ const { input, images, conversationMode, setInput, setImages } = useInputContext();
18
18
  const { busy, setBusy, setError } = useUIStateContext();
19
19
  const imagesRef = useRef(images);
20
20
 
@@ -46,18 +46,27 @@ export function useMessageSender({ type, pendingRequests, mentions, onSuccess }:
46
46
 
47
47
  try {
48
48
  let result;
49
+
50
+ const analysisOnlyInstruction =
51
+ "只做分析,不要对代码/文件做任何改动。";
52
+ const textToSend =
53
+ conversationMode === "chat"
54
+ ? input.trim().length > 0
55
+ ? `${input}\n\n${analysisOnlyInstruction}`
56
+ : analysisOnlyInstruction
57
+ : input;
49
58
 
50
59
  if (type === "agent" && targets.targetRequests.length === 1) {
51
60
  result = await submitResponse(
52
61
  targets.targetRequests[0].request_id,
53
- input,
62
+ textToSend,
54
63
  currentImages,
55
64
  mentions
56
65
  );
57
66
  } else {
58
67
  result = await batchRespond(
59
68
  targets.targetRequests.map((r) => r.request_id),
60
- input,
69
+ textToSend,
61
70
  currentImages,
62
71
  mentions
63
72
  );
@@ -78,7 +87,7 @@ export function useMessageSender({ type, pendingRequests, mentions, onSuccess }:
78
87
  } finally {
79
88
  setBusy(false);
80
89
  }
81
- }, [type, input, mentions, pendingRequests, busy, setBusy, setError, setInput, setImages, onSuccess]);
90
+ }, [type, input, conversationMode, mentions, pendingRequests, busy, setBusy, setError, setInput, setImages, onSuccess]);
82
91
 
83
92
  return { send };
84
93
  }
@@ -43,6 +43,9 @@ import {
43
43
  getLastRequestsByAgents,
44
44
  getLastResponsesByAgents,
45
45
  getPendingCountsByAgents,
46
+ pinConversation,
47
+ unpinConversation,
48
+ listPinnedConversations,
46
49
  type ConversationType,
47
50
  type CueResponse,
48
51
  } from "./db";
@@ -55,6 +58,7 @@ import { v4 as uuidv4 } from "uuid";
55
58
 
56
59
  export type UserConfig = {
57
60
  sound_enabled: boolean;
61
+ conversation_mode_default: "chat" | "agent";
58
62
  };
59
63
 
60
64
  export type QueuedMessage = {
@@ -66,6 +70,7 @@ export type QueuedMessage = {
66
70
 
67
71
  const defaultUserConfig: UserConfig = {
68
72
  sound_enabled: true,
73
+ conversation_mode_default: "agent",
69
74
  };
70
75
 
71
76
  function getUserConfigPath(): string {
@@ -82,6 +87,10 @@ export async function getUserConfig(): Promise<UserConfig> {
82
87
  typeof parsed.sound_enabled === "boolean"
83
88
  ? parsed.sound_enabled
84
89
  : defaultUserConfig.sound_enabled,
90
+ conversation_mode_default:
91
+ parsed.conversation_mode_default === "chat" || parsed.conversation_mode_default === "agent"
92
+ ? parsed.conversation_mode_default
93
+ : defaultUserConfig.conversation_mode_default,
85
94
  };
86
95
  } catch {
87
96
  return defaultUserConfig;
@@ -93,6 +102,10 @@ export async function setUserConfig(next: Partial<UserConfig>): Promise<UserConf
93
102
  const merged: UserConfig = {
94
103
  sound_enabled:
95
104
  typeof next.sound_enabled === "boolean" ? next.sound_enabled : prev.sound_enabled,
105
+ conversation_mode_default:
106
+ next.conversation_mode_default === "chat" || next.conversation_mode_default === "agent"
107
+ ? next.conversation_mode_default
108
+ : prev.conversation_mode_default,
96
109
  };
97
110
  const p = getUserConfigPath();
98
111
  await fs.mkdir(path.dirname(p), { recursive: true });
@@ -155,6 +168,25 @@ function parseConversationKey(key: string): { type: "agent" | "group"; id: strin
155
168
  return { type, id };
156
169
  }
157
170
 
171
+ export async function pinConversationByKey(key: string, view: "active" | "archived") {
172
+ const parsed = parseConversationKey(key);
173
+ if (!parsed) return { success: false, error: "Invalid conversation key" } as const;
174
+ pinConversation(parsed.type, parsed.id, view);
175
+ return { success: true } as const;
176
+ }
177
+
178
+ export async function unpinConversationByKey(key: string, view: "active" | "archived") {
179
+ const parsed = parseConversationKey(key);
180
+ if (!parsed) return { success: false, error: "Invalid conversation key" } as const;
181
+ unpinConversation(parsed.type, parsed.id, view);
182
+ return { success: true } as const;
183
+ }
184
+
185
+ export async function fetchPinnedConversationKeys(view: "active" | "archived"): Promise<string[]> {
186
+ const rows = listPinnedConversations(view);
187
+ return rows.map((r) => `${r.conv_type}:${r.conv_id}`);
188
+ }
189
+
158
190
  export async function archiveConversations(keys: string[]) {
159
191
  const unique = Array.from(new Set(keys));
160
192
  for (const k of unique) {
package/src/lib/avatar.ts CHANGED
@@ -114,7 +114,7 @@ export async function thumbsAvatarDataUrl(seed: string): Promise<string> {
114
114
 
115
115
  const t1 = perfEnabled() ? performance.now() : 0;
116
116
 
117
- const svg = createAvatar(thumbsStyle as any, {
117
+ const svg = createAvatar(thumbsStyle, {
118
118
  seed,
119
119
  }).toString();
120
120
 
package/src/lib/db.ts CHANGED
@@ -217,7 +217,7 @@ function initTables() {
217
217
  .prepare(`SELECT value FROM schema_meta WHERE key = ?`)
218
218
  .get("schema_version") as { value?: string } | undefined;
219
219
  const version = String(versionRow?.value ?? "");
220
- if (version !== "2") {
220
+ if (version !== "3") {
221
221
  const reqCountRow = database.prepare(`SELECT COUNT(*) as n FROM cue_requests`).get() as { n: number };
222
222
  const respCountRow = database.prepare(`SELECT COUNT(*) as n FROM cue_responses`).get() as { n: number };
223
223
  const reqCount = Number(reqCountRow?.n ?? 0);
@@ -225,7 +225,7 @@ function initTables() {
225
225
  if (reqCount === 0 && respCount === 0) {
226
226
  database
227
227
  .prepare(`INSERT INTO schema_meta (key, value) VALUES (?, ?)`)
228
- .run("schema_version", "2");
228
+ .run("schema_version", "3");
229
229
  } else {
230
230
  throw new Error(
231
231
  "Database schema is outdated (pre-file storage). Please migrate: cueme migrate\n" +
@@ -266,6 +266,17 @@ function initTables() {
266
266
  )
267
267
  `);
268
268
 
269
+ database.exec(`
270
+ CREATE TABLE IF NOT EXISTS conversation_pins (
271
+ conv_type TEXT NOT NULL,
272
+ conv_id TEXT NOT NULL,
273
+ view TEXT NOT NULL,
274
+ pin_order INTEGER PRIMARY KEY AUTOINCREMENT,
275
+ pinned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
276
+ UNIQUE(conv_type, conv_id, view)
277
+ )
278
+ `);
279
+
269
280
  database.exec(`
270
281
  CREATE TABLE IF NOT EXISTS worker_leases (
271
282
  lease_key TEXT PRIMARY KEY,
@@ -303,6 +314,45 @@ function initTables() {
303
314
  `);
304
315
  }
305
316
 
317
+ export function pinConversation(convType: ConversationType, convId: string, view: "active" | "archived"): void {
318
+ const t = String(convType || "").trim();
319
+ const id = String(convId || "").trim();
320
+ const v = view === "archived" ? "archived" : "active";
321
+ if (!id) return;
322
+ getDb()
323
+ .prepare(
324
+ `INSERT OR IGNORE INTO conversation_pins (conv_type, conv_id, view)
325
+ VALUES (?, ?, ?)`
326
+ )
327
+ .run(t, id, v);
328
+ }
329
+
330
+ export function unpinConversation(convType: ConversationType, convId: string, view: "active" | "archived"): void {
331
+ const t = String(convType || "").trim();
332
+ const id = String(convId || "").trim();
333
+ const v = view === "archived" ? "archived" : "active";
334
+ if (!id) return;
335
+ getDb()
336
+ .prepare(
337
+ `DELETE FROM conversation_pins
338
+ WHERE conv_type = ? AND conv_id = ? AND view = ?`
339
+ )
340
+ .run(t, id, v);
341
+ }
342
+
343
+ export function listPinnedConversations(view: "active" | "archived"): Array<{ conv_type: ConversationType; conv_id: string }> {
344
+ const v = view === "archived" ? "archived" : "active";
345
+ const rows = getDb()
346
+ .prepare(
347
+ `SELECT conv_type, conv_id
348
+ FROM conversation_pins
349
+ WHERE view = ?
350
+ ORDER BY pin_order ASC`
351
+ )
352
+ .all(v) as Array<{ conv_type: ConversationType; conv_id: string }>;
353
+ return rows || [];
354
+ }
355
+
306
356
  function nowIso(): string {
307
357
  return formatLocalIsoWithOffset(new Date());
308
358
  }
@@ -33,7 +33,7 @@ export function logError(error: unknown, context?: string): void {
33
33
  console.error(`${prefix} ${message}`, error);
34
34
  }
35
35
 
36
- export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
36
+ export function withErrorHandling<T extends (...args: unknown[]) => Promise<unknown>>(
37
37
  fn: T,
38
38
  context: string
39
39
  ): T {