cue-console 0.1.9 → 0.1.10
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/README.md +25 -7
- package/bin/cue-console.js +27 -10
- package/package.json +1 -1
- package/src/app/page.tsx +98 -6
- package/src/components/chat-composer.tsx +157 -33
- package/src/components/chat-view.tsx +825 -329
- package/src/components/conversation-list.tsx +133 -11
- package/src/components/markdown-renderer.tsx +9 -1
- package/src/components/ui/skeleton.tsx +14 -0
- package/src/lib/actions.ts +165 -3
- package/src/lib/avatar.ts +49 -5
- package/src/lib/db.ts +634 -9
package/README.md
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/nmhjklnm/cue-stack)
|
|
4
4
|
[](https://github.com/nmhjklnm/cue-console)
|
|
5
|
+
[](https://github.com/nmhjklnm/cue-command)
|
|
5
6
|
[](https://github.com/nmhjklnm/cue-mcp)
|
|
6
7
|
|
|
7
8
|
[](https://www.npmjs.com/package/cue-console)
|
|
8
9
|
[](https://www.npmjs.com/package/cue-console)
|
|
9
10
|
|
|
11
|
+
[Contributing](./CONTRIBUTING.md) · [Trademark](./TRADEMARK.md)
|
|
12
|
+
|
|
10
13
|
| Mobile | Desktop |
|
|
11
14
|
| --- | --- |
|
|
12
15
|
|  |  |
|
|
@@ -27,7 +30,9 @@ Think of it as an “all-in-one” collaboration console for your agents and CLI
|
|
|
27
30
|
|
|
28
31
|
### Goal
|
|
29
32
|
|
|
30
|
-
Run the console and pair it with `
|
|
33
|
+
Run the console and pair it with `cueme` (recommended).
|
|
34
|
+
|
|
35
|
+
Note: `cuemcp` (MCP mode) can be blocked/flagged by some IDEs, so command mode is currently recommended.
|
|
31
36
|
|
|
32
37
|
### Step 1: Install `cue-console`
|
|
33
38
|
|
|
@@ -38,24 +43,36 @@ npm install -g cue-console
|
|
|
38
43
|
### Step 2: Start `cue-console`
|
|
39
44
|
|
|
40
45
|
```bash
|
|
41
|
-
cue-console
|
|
46
|
+
cue-console start
|
|
42
47
|
```
|
|
43
48
|
|
|
44
49
|
Alternatively, you can run it without installing globally:
|
|
45
50
|
|
|
46
51
|
```bash
|
|
47
|
-
npx cue-console
|
|
52
|
+
npx cue-console start
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
Open `http://localhost:3000`.
|
|
51
56
|
|
|
52
|
-
### Step 3:
|
|
57
|
+
### Step 3: Install `cueme` (recommended)
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
```bash
|
|
60
|
+
npm install -g cueme
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Step 4: Configure your system prompt (HAP)
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
Add the contents of `cue-command/protocol.md` to your tool's system prompt / rules (see [`cue-command`](https://github.com/nmhjklnm/cue-command)).
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
### Step 5: Connect your runtime
|
|
68
|
+
|
|
69
|
+
In the agent/runtime you want to use, call `cueme cue` / `cueme pause` (see `cue-command/protocol.md`).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### Optional: MCP mode (`cuemcp`)
|
|
74
|
+
|
|
75
|
+
Add and run the MCP server in your agent/runtime (see [`cue-mcp`](https://github.com/nmhjklnm/cue-mcp) for client-specific MCP configuration).
|
|
59
76
|
|
|
60
77
|
---
|
|
61
78
|
|
|
@@ -123,6 +140,7 @@ Not calling cue() means the user cannot continue the interaction.
|
|
|
123
140
|
After installation, the `cue-console` command is available:
|
|
124
141
|
|
|
125
142
|
```bash
|
|
143
|
+
cue-console start
|
|
126
144
|
cue-console dev --port 3000
|
|
127
145
|
cue-console build
|
|
128
146
|
cue-console start --host 0.0.0.0 --port 3000
|
package/bin/cue-console.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { spawn } = require("node:child_process");
|
|
4
|
+
const fs = require("node:fs");
|
|
4
5
|
const path = require("node:path");
|
|
5
6
|
|
|
6
7
|
function printHelp() {
|
|
7
|
-
process.stdout.write(
|
|
8
|
+
process.stdout.write(
|
|
9
|
+
`cue-console - Cue Hub console launcher\n\nUsage:\n cue-console <dev|build|start> [--port <port>] [--host <host>]\n\nExamples:\n cue-console start --port 3000\n cue-console start --host 0.0.0.0 --port 3000\n\nNotes:\n - start will auto-build if needed (when .next is missing)\n`
|
|
10
|
+
);
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
function parseArgs(argv) {
|
|
@@ -70,16 +73,30 @@ async function main() {
|
|
|
70
73
|
|
|
71
74
|
const pkgRoot = path.resolve(__dirname, "..");
|
|
72
75
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const spawnNext = (subcmd) =>
|
|
77
|
+
new Promise((resolve, reject) => {
|
|
78
|
+
const child = spawn(process.execPath, [nextBin, subcmd, ...passthrough], {
|
|
79
|
+
stdio: "inherit",
|
|
80
|
+
env,
|
|
81
|
+
cwd: pkgRoot,
|
|
82
|
+
});
|
|
83
|
+
child.on("exit", (code, signal) => {
|
|
84
|
+
if (signal) return reject(Object.assign(new Error(`terminated: ${signal}`), { code, signal }));
|
|
85
|
+
if (code && code !== 0) return reject(Object.assign(new Error(`exit ${code}`), { code }));
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (command === "start") {
|
|
91
|
+
const buildIdPath = path.join(pkgRoot, ".next", "BUILD_ID");
|
|
92
|
+
if (!fs.existsSync(buildIdPath)) {
|
|
93
|
+
await spawnNext("build");
|
|
94
|
+
}
|
|
95
|
+
await spawnNext("start");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
78
98
|
|
|
79
|
-
|
|
80
|
-
if (signal) process.kill(process.pid, signal);
|
|
81
|
-
process.exit(code ?? 0);
|
|
82
|
-
});
|
|
99
|
+
await spawnNext(command);
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
main().catch((err) => {
|
package/package.json
CHANGED
package/src/app/page.tsx
CHANGED
|
@@ -5,23 +5,115 @@ 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
9
|
|
|
9
10
|
export default function Home() {
|
|
10
11
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
11
12
|
const [selectedType, setSelectedType] = useState<"agent" | "group" | null>(null);
|
|
12
13
|
const [selectedName, setSelectedName] = useState<string>("");
|
|
13
14
|
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
|
14
|
-
const [sidebarCollapsed, setSidebarCollapsed] = useState(
|
|
15
|
-
|
|
15
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let stopped = false;
|
|
19
|
+
const holderId =
|
|
20
|
+
(globalThis.crypto && "randomUUID" in globalThis.crypto
|
|
21
|
+
? (globalThis.crypto as Crypto).randomUUID()
|
|
22
|
+
: `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
23
|
+
const leaseKey = "cue-console:global-queue-worker";
|
|
24
|
+
const leaseTtlMs = 12_000;
|
|
25
|
+
const claimEveryMs = 4_000;
|
|
26
|
+
const tickEveryMs = 3_000;
|
|
27
|
+
|
|
28
|
+
let isLeader = false;
|
|
29
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
let claimTimer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
|
|
32
|
+
const stopTick = () => {
|
|
33
|
+
if (tickTimer) {
|
|
34
|
+
clearInterval(tickTimer);
|
|
35
|
+
tickTimer = null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const startTick = () => {
|
|
40
|
+
if (tickTimer) return;
|
|
41
|
+
tickTimer = setInterval(() => {
|
|
42
|
+
if (document.visibilityState !== "visible") return;
|
|
43
|
+
void (async () => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await processQueueTick(holderId);
|
|
46
|
+
if ((res?.sent ?? 0) > 0 || (res?.failed ?? 0) > 0 || (res?.rescheduled ?? 0) > 0) {
|
|
47
|
+
const removedQueueIds = Array.isArray((res as { removedQueueIds?: unknown })?.removedQueueIds)
|
|
48
|
+
? ((res as { removedQueueIds: string[] }).removedQueueIds || [])
|
|
49
|
+
: [];
|
|
50
|
+
window.dispatchEvent(
|
|
51
|
+
new CustomEvent("cue-console:queueUpdated", {
|
|
52
|
+
detail: {
|
|
53
|
+
removedQueueIds,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
}, tickEveryMs);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const claimOnce = async () => {
|
|
66
|
+
try {
|
|
67
|
+
const res = await claimWorkerLease({ leaseKey, holderId, ttlMs: leaseTtlMs });
|
|
68
|
+
const nextLeader = Boolean(res.acquired && res.holderId === holderId);
|
|
69
|
+
if (nextLeader !== isLeader) {
|
|
70
|
+
isLeader = nextLeader;
|
|
71
|
+
if (isLeader) {
|
|
72
|
+
startTick();
|
|
73
|
+
} else {
|
|
74
|
+
stopTick();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const boot = async () => {
|
|
83
|
+
await claimOnce();
|
|
84
|
+
if (stopped) return;
|
|
85
|
+
claimTimer = setInterval(() => {
|
|
86
|
+
if (document.visibilityState !== "visible") return;
|
|
87
|
+
void claimOnce();
|
|
88
|
+
}, claimEveryMs);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
void boot();
|
|
92
|
+
|
|
93
|
+
const onVisibilityChange = () => {
|
|
94
|
+
if (document.visibilityState === "visible") {
|
|
95
|
+
void claimOnce();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
stopped = true;
|
|
102
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
103
|
+
stopTick();
|
|
104
|
+
if (claimTimer) clearInterval(claimTimer);
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
16
109
|
try {
|
|
17
110
|
const raw = window.localStorage.getItem("cuehub.sidebarCollapsed");
|
|
18
|
-
if (raw === "1")
|
|
19
|
-
if (raw === "0")
|
|
111
|
+
if (raw === "1") setSidebarCollapsed(true);
|
|
112
|
+
if (raw === "0") setSidebarCollapsed(false);
|
|
20
113
|
} catch {
|
|
21
114
|
// ignore
|
|
22
115
|
}
|
|
23
|
-
|
|
24
|
-
});
|
|
116
|
+
}, []);
|
|
25
117
|
|
|
26
118
|
useEffect(() => {
|
|
27
119
|
try {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
useMemo,
|
|
5
|
+
useState,
|
|
5
6
|
type ChangeEvent,
|
|
6
7
|
type ClipboardEvent,
|
|
7
8
|
type Dispatch,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
import { Button } from "@/components/ui/button";
|
|
13
14
|
import { cn, getAgentEmoji } from "@/lib/utils";
|
|
14
15
|
import { setAgentDisplayName } from "@/lib/actions";
|
|
15
|
-
import { Plus, Send, X } from "lucide-react";
|
|
16
|
+
import { CornerUpLeft, GripVertical, Plus, Send, Trash2, X } from "lucide-react";
|
|
16
17
|
|
|
17
18
|
type MentionDraft = {
|
|
18
19
|
userId: string;
|
|
@@ -21,6 +22,13 @@ type MentionDraft = {
|
|
|
21
22
|
display: string;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
export type QueuedMessage = {
|
|
26
|
+
id: string;
|
|
27
|
+
text: string;
|
|
28
|
+
images: { mime_type: string; base64_data: string }[];
|
|
29
|
+
createdAt: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
24
32
|
const shiftMentions = (from: number, delta: number, list: MentionDraft[]) => {
|
|
25
33
|
return list.map((m) => {
|
|
26
34
|
if (m.start >= from) return { ...m, start: m.start + delta };
|
|
@@ -61,6 +69,11 @@ export function ChatComposer({
|
|
|
61
69
|
setNotice,
|
|
62
70
|
setPreviewImage,
|
|
63
71
|
handleSend,
|
|
72
|
+
enqueueCurrent,
|
|
73
|
+
queue,
|
|
74
|
+
removeQueued,
|
|
75
|
+
recallQueued,
|
|
76
|
+
reorderQueue,
|
|
64
77
|
handlePaste,
|
|
65
78
|
handleImageUpload,
|
|
66
79
|
textareaRef,
|
|
@@ -91,11 +104,16 @@ export function ChatComposer({
|
|
|
91
104
|
hasPendingRequests: boolean;
|
|
92
105
|
input: string;
|
|
93
106
|
setInput: Dispatch<SetStateAction<string>>;
|
|
94
|
-
images: { mime_type: string; base64_data: string }[];
|
|
95
|
-
setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string }[]>>;
|
|
107
|
+
images: { mime_type: string; base64_data: string; file_name?: string }[];
|
|
108
|
+
setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
|
|
96
109
|
setNotice: Dispatch<SetStateAction<string | null>>;
|
|
97
110
|
setPreviewImage: Dispatch<SetStateAction<{ mime_type: string; base64_data: string } | null>>;
|
|
98
111
|
handleSend: () => void | Promise<void>;
|
|
112
|
+
enqueueCurrent: () => void;
|
|
113
|
+
queue: QueuedMessage[];
|
|
114
|
+
removeQueued: (id: string) => void;
|
|
115
|
+
recallQueued: (id: string) => void;
|
|
116
|
+
reorderQueue: (fromIndex: number, toIndex: number) => void;
|
|
99
117
|
handlePaste: (e: ClipboardEvent<HTMLTextAreaElement>) => void;
|
|
100
118
|
handleImageUpload: (e: ChangeEvent<HTMLInputElement>) => void;
|
|
101
119
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
@@ -128,6 +146,17 @@ export function ChatComposer({
|
|
|
128
146
|
: ({ left: "var(--cuehub-sidebar-w, 0px)", right: 0 } as const);
|
|
129
147
|
}, [onBack]);
|
|
130
148
|
|
|
149
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
150
|
+
|
|
151
|
+
const submitOrQueue = () => {
|
|
152
|
+
if (busy) return;
|
|
153
|
+
if (canSend) {
|
|
154
|
+
void handleSend();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
enqueueCurrent();
|
|
158
|
+
};
|
|
159
|
+
|
|
131
160
|
return (
|
|
132
161
|
<>
|
|
133
162
|
{/* Input */}
|
|
@@ -139,17 +168,106 @@ export function ChatComposer({
|
|
|
139
168
|
"glass-surface glass-noise"
|
|
140
169
|
)}
|
|
141
170
|
>
|
|
171
|
+
{/* Queue Panel */}
|
|
172
|
+
{queue.length > 0 && (
|
|
173
|
+
<div className="px-1 pt-1">
|
|
174
|
+
<div className="flex items-center justify-between">
|
|
175
|
+
<p className="text-[11px] text-muted-foreground">
|
|
176
|
+
{queue.length} messages queued
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="mt-1 max-h-28 overflow-y-auto pr-1">
|
|
180
|
+
<div className="space-y-1">
|
|
181
|
+
{queue.map((q, idx) => {
|
|
182
|
+
const summary = (q.text || "").split(/\r?\n/)[0] || "(empty)";
|
|
183
|
+
const hasImages = (q.images?.length || 0) > 0;
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
key={q.id}
|
|
187
|
+
className={cn(
|
|
188
|
+
"flex items-center gap-2 rounded-2xl px-2 py-1",
|
|
189
|
+
"bg-white/35 ring-1 ring-white/25"
|
|
190
|
+
)}
|
|
191
|
+
draggable
|
|
192
|
+
onDragStart={(e) => {
|
|
193
|
+
setDragIndex(idx);
|
|
194
|
+
e.dataTransfer.setData("text/plain", String(idx));
|
|
195
|
+
e.dataTransfer.effectAllowed = "move";
|
|
196
|
+
}}
|
|
197
|
+
onDragOver={(e) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
e.dataTransfer.dropEffect = "move";
|
|
200
|
+
}}
|
|
201
|
+
onDrop={(e) => {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
const raw = e.dataTransfer.getData("text/plain");
|
|
204
|
+
const from = Number(raw);
|
|
205
|
+
if (Number.isFinite(from)) reorderQueue(from, idx);
|
|
206
|
+
setDragIndex(null);
|
|
207
|
+
}}
|
|
208
|
+
onDragEnd={() => setDragIndex(null)}
|
|
209
|
+
data-dragging={dragIndex === idx ? "true" : "false"}
|
|
210
|
+
>
|
|
211
|
+
<span className="text-muted-foreground">
|
|
212
|
+
<GripVertical className="h-3.5 w-3.5" />
|
|
213
|
+
</span>
|
|
214
|
+
<div className="min-w-0 flex-1">
|
|
215
|
+
<p className="truncate text-xs">
|
|
216
|
+
{summary}
|
|
217
|
+
{hasImages ? " [img]" : ""}
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
<Button
|
|
221
|
+
type="button"
|
|
222
|
+
variant="ghost"
|
|
223
|
+
size="icon"
|
|
224
|
+
className="h-7 w-7 rounded-xl hover:bg-white/40"
|
|
225
|
+
onClick={() => recallQueued(q.id)}
|
|
226
|
+
title="Recall to input"
|
|
227
|
+
>
|
|
228
|
+
<CornerUpLeft className="h-3.5 w-3.5" />
|
|
229
|
+
</Button>
|
|
230
|
+
<Button
|
|
231
|
+
type="button"
|
|
232
|
+
variant="ghost"
|
|
233
|
+
size="icon"
|
|
234
|
+
className="h-7 w-7 rounded-xl hover:bg-white/40"
|
|
235
|
+
onClick={() => removeQueued(q.id)}
|
|
236
|
+
title="Remove from queue"
|
|
237
|
+
>
|
|
238
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
})}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
|
|
142
248
|
{/* Image Preview */}
|
|
143
249
|
{images.length > 0 && (
|
|
144
250
|
<div className="flex max-w-full gap-2 overflow-x-auto px-0.5 pt-0.5">
|
|
145
251
|
{images.map((img, i) => (
|
|
146
252
|
<div key={i} className="relative shrink-0">
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
253
|
+
{img.mime_type.startsWith("image/") ? (
|
|
254
|
+
<img
|
|
255
|
+
src={`data:${img.mime_type};base64,${img.base64_data}`}
|
|
256
|
+
alt=""
|
|
257
|
+
className="h-16 w-16 rounded-xl object-cover shadow-sm ring-1 ring-border/60 cursor-pointer"
|
|
258
|
+
onClick={() => setPreviewImage(img)}
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<div
|
|
262
|
+
className="h-16 w-16 rounded-xl bg-white/40 dark:bg-black/20 ring-1 ring-border/60 shadow-sm flex flex-col items-center justify-center px-1"
|
|
263
|
+
title={`${img.file_name || "File"}${img.mime_type ? ` (${img.mime_type})` : ""}`}
|
|
264
|
+
>
|
|
265
|
+
<div className="text-[10px] font-semibold text-muted-foreground">FILE</div>
|
|
266
|
+
<div className="mt-0.5 text-[11px] font-semibold text-foreground/80 truncate w-full text-center">
|
|
267
|
+
{(img.file_name || "File").slice(0, 10)}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
153
271
|
<button
|
|
154
272
|
className="absolute -right-1 -top-1 rounded-full bg-destructive p-0.5 text-white"
|
|
155
273
|
onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))}
|
|
@@ -281,16 +399,16 @@ export function ChatComposer({
|
|
|
281
399
|
placeholder={
|
|
282
400
|
hasPendingRequests
|
|
283
401
|
? type === "group"
|
|
284
|
-
? "Type... (Enter to send, Shift+Enter for newline, supports @)"
|
|
285
|
-
: "Type... (Enter to send, Shift+Enter for newline)"
|
|
402
|
+
? "Type... (Enter to send or queue, Shift+Enter for newline, supports @)"
|
|
403
|
+
: "Type... (Enter to send or queue, Shift+Enter for newline)"
|
|
286
404
|
: "Waiting for new pending requests..."
|
|
287
405
|
}
|
|
288
406
|
title={
|
|
289
407
|
!hasPendingRequests
|
|
290
408
|
? "No pending requests (PENDING/PROCESSING). Send button is disabled."
|
|
291
409
|
: type === "group"
|
|
292
|
-
? "Type @ to mention members; ↑↓ to navigate, Enter to insert; Enter to send, Shift+Enter for newline"
|
|
293
|
-
: "Enter to send, Shift+Enter for newline"
|
|
410
|
+
? "Type @ to mention members; ↑↓ to navigate, Enter to insert; Enter to send or queue, Shift+Enter for newline"
|
|
411
|
+
: "Enter to send or queue, Shift+Enter for newline"
|
|
294
412
|
}
|
|
295
413
|
value={input}
|
|
296
414
|
onPaste={handlePaste}
|
|
@@ -392,7 +510,7 @@ export function ChatComposer({
|
|
|
392
510
|
|
|
393
511
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
394
512
|
e.preventDefault();
|
|
395
|
-
|
|
513
|
+
submitOrQueue();
|
|
396
514
|
}
|
|
397
515
|
}}
|
|
398
516
|
onKeyUp={() => {
|
|
@@ -440,39 +558,45 @@ export function ChatComposer({
|
|
|
440
558
|
)}
|
|
441
559
|
onClick={() => fileInputRef.current?.click()}
|
|
442
560
|
disabled={busy}
|
|
443
|
-
title="Add
|
|
561
|
+
title="Add file"
|
|
444
562
|
>
|
|
445
563
|
<Plus className="h-4.5 w-4.5" />
|
|
446
564
|
</Button>
|
|
565
|
+
|
|
566
|
+
<Button
|
|
567
|
+
type="button"
|
|
568
|
+
variant="ghost"
|
|
569
|
+
size="sm"
|
|
570
|
+
className={cn(
|
|
571
|
+
"h-8 rounded-xl px-2",
|
|
572
|
+
"text-muted-foreground hover:text-foreground",
|
|
573
|
+
"hover:bg-white/40"
|
|
574
|
+
)}
|
|
575
|
+
onClick={() => {
|
|
576
|
+
if (busy) return;
|
|
577
|
+
enqueueCurrent();
|
|
578
|
+
}}
|
|
579
|
+
disabled={busy || (!input.trim() && images.length === 0)}
|
|
580
|
+
title="Queue (Enter)"
|
|
581
|
+
>
|
|
582
|
+
Queue
|
|
583
|
+
</Button>
|
|
447
584
|
</div>
|
|
448
585
|
|
|
449
586
|
<Button
|
|
450
587
|
type="button"
|
|
451
588
|
onClick={() => {
|
|
452
|
-
|
|
453
|
-
void handleSend();
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
if (!hasPendingRequests) {
|
|
457
|
-
setNotice("No pending questions to answer.");
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
if (!input.trim() && images.length === 0) {
|
|
461
|
-
setNotice("Enter a message to send, or select an image.");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
setNotice("Unable to send right now. Please try again later.");
|
|
589
|
+
submitOrQueue();
|
|
465
590
|
}}
|
|
466
|
-
disabled={busy || (!
|
|
591
|
+
disabled={busy || (!input.trim() && images.length === 0)}
|
|
467
592
|
className={cn(
|
|
468
593
|
"h-8 w-8 rounded-xl p-0",
|
|
469
594
|
canSend
|
|
470
595
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
|
471
596
|
: "bg-transparent text-muted-foreground hover:bg-white/40",
|
|
472
|
-
(busy || (!
|
|
473
|
-
"opacity-40 hover:bg-transparent"
|
|
597
|
+
(busy || (!input.trim() && images.length === 0)) && "opacity-40 hover:bg-transparent"
|
|
474
598
|
)}
|
|
475
|
-
title={canSend ? "Send" : "
|
|
599
|
+
title={canSend ? "Send" : "Queue (cannot send now)"}
|
|
476
600
|
>
|
|
477
601
|
<Send className="h-4 w-4" />
|
|
478
602
|
</Button>
|
|
@@ -481,7 +605,7 @@ export function ChatComposer({
|
|
|
481
605
|
<input
|
|
482
606
|
ref={fileInputRef}
|
|
483
607
|
type="file"
|
|
484
|
-
accept="
|
|
608
|
+
accept="*/*"
|
|
485
609
|
multiple
|
|
486
610
|
className="hidden"
|
|
487
611
|
onChange={handleImageUpload}
|