botschat 0.1.10 → 0.1.13
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 +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +20 -1
- package/packages/api/src/do/connection-do.ts +142 -24
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +7 -0
- package/packages/api/src/routes/auth.ts +85 -9
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +218 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
- package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
- package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
- package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
- package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
- package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
- package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/package.json +4 -1
- package/packages/web/src/App.tsx +117 -1
- package/packages/web/src/api.ts +21 -1
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +55 -7
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +215 -3
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +24 -2
- package/packages/web/src/push.ts +205 -0
- package/packages/web/src/ws.ts +20 -8
- package/scripts/dev.sh +158 -26
- package/scripts/mock-openclaw.mjs +382 -0
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
|
@@ -4,8 +4,10 @@ import type { WSMessage } from "../ws";
|
|
|
4
4
|
import { MessageContent } from "./MessageContent";
|
|
5
5
|
import { ModelSelect } from "./ModelSelect";
|
|
6
6
|
import { SessionTabs } from "./SessionTabs";
|
|
7
|
+
import { useIsMobile } from "../hooks/useIsMobile";
|
|
7
8
|
import { dlog } from "../debug-log";
|
|
8
9
|
import { randomUUID } from "../utils/uuid";
|
|
10
|
+
import { E2eService } from "../e2e";
|
|
9
11
|
|
|
10
12
|
type ChatWindowProps = {
|
|
11
13
|
sendMessage: (msg: WSMessage) => void;
|
|
@@ -155,11 +157,13 @@ function getSortedSkills(): { skills: Skill[]; store: SkillStore } {
|
|
|
155
157
|
export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
156
158
|
const state = useAppState();
|
|
157
159
|
const dispatch = useAppDispatch();
|
|
160
|
+
const isMobile = useIsMobile();
|
|
158
161
|
const [input, setInput] = useState("");
|
|
159
162
|
const [skillVersion, setSkillVersion] = useState(0); // bump to re-sort skills
|
|
160
163
|
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null);
|
|
161
164
|
const [imageUploading, setImageUploading] = useState(false);
|
|
162
165
|
const [dragOver, setDragOver] = useState(false);
|
|
166
|
+
const [quotedMessage, setQuotedMessage] = useState<ChatMessage | null>(null);
|
|
163
167
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
164
168
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
165
169
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -184,15 +188,18 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
184
188
|
}
|
|
185
189
|
}, [input]);
|
|
186
190
|
|
|
187
|
-
// Auto-focus the input when a session is active (page load or channel switch)
|
|
191
|
+
// Auto-focus the input when a session is active (page load or channel switch).
|
|
192
|
+
// On mobile, skip auto-focus to avoid popping up the keyboard unexpectedly
|
|
193
|
+
// every time the user switches sessions, taps a message, or navigates.
|
|
188
194
|
useEffect(() => {
|
|
195
|
+
if (isMobile) return;
|
|
189
196
|
if (sessionKey && inputRef.current) {
|
|
190
197
|
// Small delay to ensure DOM is ready after render
|
|
191
198
|
requestAnimationFrame(() => {
|
|
192
199
|
inputRef.current?.focus();
|
|
193
200
|
});
|
|
194
201
|
}
|
|
195
|
-
}, [sessionKey]);
|
|
202
|
+
}, [sessionKey, isMobile]);
|
|
196
203
|
|
|
197
204
|
// Restore per-session model from localStorage when session changes
|
|
198
205
|
useEffect(() => {
|
|
@@ -259,11 +266,11 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
259
266
|
inputRef.current?.focus();
|
|
260
267
|
}, []);
|
|
261
268
|
|
|
262
|
-
//
|
|
269
|
+
// File upload helpers
|
|
263
270
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
264
271
|
const file = e.target.files?.[0];
|
|
265
|
-
if (!file
|
|
266
|
-
const preview = URL.createObjectURL(file);
|
|
272
|
+
if (!file) return;
|
|
273
|
+
const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : "";
|
|
267
274
|
setPendingImage({ file, preview });
|
|
268
275
|
e.target.value = "";
|
|
269
276
|
inputRef.current?.focus();
|
|
@@ -276,11 +283,26 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
276
283
|
}
|
|
277
284
|
}, [pendingImage]);
|
|
278
285
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Upload a file — if E2E is enabled, encrypts the binary before uploading.
|
|
288
|
+
* Returns { url, mediaContextId? } or null on failure.
|
|
289
|
+
*/
|
|
290
|
+
const uploadFile = useCallback(async (file: File, mediaContextId?: string): Promise<{ url: string } | null> => {
|
|
282
291
|
const token = localStorage.getItem("botschat_token");
|
|
283
292
|
try {
|
|
293
|
+
let uploadBlob: Blob = file;
|
|
294
|
+
|
|
295
|
+
// E2E: encrypt file content before uploading
|
|
296
|
+
if (E2eService.hasKey() && mediaContextId) {
|
|
297
|
+
const arrayBuf = await file.arrayBuffer();
|
|
298
|
+
const plainBytes = new Uint8Array(arrayBuf);
|
|
299
|
+
const { encrypted } = await E2eService.encryptMedia(plainBytes, mediaContextId);
|
|
300
|
+
uploadBlob = new Blob([encrypted.buffer.slice(0) as ArrayBuffer], { type: file.type });
|
|
301
|
+
dlog.info("E2E", `Encrypted media (${plainBytes.length} bytes, ctx=${mediaContextId.slice(0, 8)}…)`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const formData = new FormData();
|
|
305
|
+
formData.append("file", uploadBlob, file.name);
|
|
284
306
|
const res = await fetch("/api/upload", {
|
|
285
307
|
method: "POST",
|
|
286
308
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
@@ -291,13 +313,12 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
291
313
|
throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
|
|
292
314
|
}
|
|
293
315
|
const data = await res.json() as { url: string };
|
|
294
|
-
// Return absolute URL so OpenClaw on mini.local can fetch the image
|
|
295
316
|
const absoluteUrl = data.url.startsWith("/")
|
|
296
317
|
? `${window.location.origin}${data.url}`
|
|
297
318
|
: data.url;
|
|
298
|
-
return absoluteUrl;
|
|
319
|
+
return { url: absoluteUrl };
|
|
299
320
|
} catch (err) {
|
|
300
|
-
dlog.error("Upload", `
|
|
321
|
+
dlog.error("Upload", `File upload failed: ${err}`);
|
|
301
322
|
return null;
|
|
302
323
|
}
|
|
303
324
|
}, []);
|
|
@@ -333,8 +354,8 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
333
354
|
e.stopPropagation();
|
|
334
355
|
setDragOver(false);
|
|
335
356
|
const file = e.dataTransfer.files?.[0];
|
|
336
|
-
if (file
|
|
337
|
-
const preview = URL.createObjectURL(file);
|
|
357
|
+
if (file) {
|
|
358
|
+
const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : "";
|
|
338
359
|
setPendingImage({ file, preview });
|
|
339
360
|
inputRef.current?.focus();
|
|
340
361
|
}
|
|
@@ -360,7 +381,17 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
360
381
|
const handleSend = async () => {
|
|
361
382
|
if ((!input.trim() && !pendingImage) || !sessionKey) return;
|
|
362
383
|
|
|
363
|
-
|
|
384
|
+
// Warn if OpenClaw is offline (but don't block — connection may recover)
|
|
385
|
+
if (!state.openclawConnected) {
|
|
386
|
+
dlog.warn("Chat", "Sending while OpenClaw appears offline — message will be delivered when reconnected");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Prepend quoted message as Markdown blockquote
|
|
390
|
+
const rawTrimmed = input.trim();
|
|
391
|
+
const trimmed = quotedMessage
|
|
392
|
+
? `> ${quotedMessage.text.split("\n").slice(0, 3).join("\n> ")}\n\n${rawTrimmed}`
|
|
393
|
+
: rawTrimmed;
|
|
394
|
+
setQuotedMessage(null);
|
|
364
395
|
const hasText = trimmed.length > 0;
|
|
365
396
|
const isSkill = hasText && trimmed.startsWith("/");
|
|
366
397
|
dlog.info("Chat", `Send message${isSkill ? " (skill)" : ""}${pendingImage ? " +image" : ""}: ${trimmed.length > 120 ? trimmed.slice(0, 120) + "…" : trimmed}`, { sessionKey, isSkill });
|
|
@@ -370,19 +401,23 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
370
401
|
setSkillVersion((v) => v + 1);
|
|
371
402
|
}
|
|
372
403
|
|
|
373
|
-
//
|
|
404
|
+
// Generate message ID upfront so we can use it as E2E context for both text and media
|
|
405
|
+
const msgId = randomUUID();
|
|
406
|
+
|
|
407
|
+
// Upload file if present
|
|
374
408
|
let mediaUrl: string | undefined;
|
|
375
409
|
if (pendingImage) {
|
|
376
410
|
setImageUploading(true);
|
|
377
|
-
|
|
411
|
+
// Use "{msgId}:media" as E2E context for the binary — distinct from text context
|
|
412
|
+
const result = await uploadFile(pendingImage.file, `${msgId}:media`);
|
|
378
413
|
setImageUploading(false);
|
|
379
|
-
if (!
|
|
380
|
-
mediaUrl = url;
|
|
414
|
+
if (!result) return; // Upload failed
|
|
415
|
+
mediaUrl = result.url;
|
|
381
416
|
clearPendingImage();
|
|
382
417
|
}
|
|
383
418
|
|
|
384
419
|
const msg: ChatMessage = {
|
|
385
|
-
id:
|
|
420
|
+
id: msgId,
|
|
386
421
|
sender: "user",
|
|
387
422
|
text: trimmed,
|
|
388
423
|
timestamp: Date.now(),
|
|
@@ -412,6 +447,27 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
412
447
|
dispatch({ type: "OPEN_THREAD", threadId: messageId, messages: [] });
|
|
413
448
|
};
|
|
414
449
|
|
|
450
|
+
const handleQuote = useCallback((msg: ChatMessage) => {
|
|
451
|
+
setQuotedMessage(msg);
|
|
452
|
+
if (!isMobile) inputRef.current?.focus();
|
|
453
|
+
}, [isMobile]);
|
|
454
|
+
|
|
455
|
+
const handleCopy = useCallback(async (text: string) => {
|
|
456
|
+
try {
|
|
457
|
+
await navigator.clipboard.writeText(text);
|
|
458
|
+
} catch {
|
|
459
|
+
// Fallback for older browsers / restricted contexts
|
|
460
|
+
const ta = document.createElement("textarea");
|
|
461
|
+
ta.value = text;
|
|
462
|
+
ta.style.position = "fixed";
|
|
463
|
+
ta.style.left = "-9999px";
|
|
464
|
+
document.body.appendChild(ta);
|
|
465
|
+
ta.select();
|
|
466
|
+
document.execCommand("copy");
|
|
467
|
+
document.body.removeChild(ta);
|
|
468
|
+
}
|
|
469
|
+
}, []);
|
|
470
|
+
|
|
415
471
|
/** Handle A2UI action button clicks — sends the action text as a user message */
|
|
416
472
|
const handleA2UIAction = useCallback((action: string) => {
|
|
417
473
|
if (!sessionKey) return;
|
|
@@ -559,44 +615,46 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
559
615
|
</div>
|
|
560
616
|
)}
|
|
561
617
|
|
|
562
|
-
{/* Channel header */}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
{selectedAgent && !selectedAgent.isDefault && (
|
|
575
|
-
<span className="text-caption hidden sm:inline flex-shrink-0" style={{ color: "var(--text-secondary)" }}>
|
|
576
|
-
— custom channel
|
|
618
|
+
{/* Channel header — hidden on mobile (MobileLayout already shows channel name) */}
|
|
619
|
+
{!isMobile && (
|
|
620
|
+
<div
|
|
621
|
+
className="flex items-center justify-between px-3 sm:px-5 gap-2 flex-shrink-0"
|
|
622
|
+
style={{
|
|
623
|
+
height: 44,
|
|
624
|
+
borderBottom: "1px solid var(--border)",
|
|
625
|
+
}}
|
|
626
|
+
>
|
|
627
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
628
|
+
<span className="text-h1 truncate" style={{ color: "var(--text-primary)" }}>
|
|
629
|
+
# {channelName}
|
|
577
630
|
</span>
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
</
|
|
584
|
-
<
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
631
|
+
{selectedAgent && !selectedAgent.isDefault && (
|
|
632
|
+
<span className="text-caption hidden sm:inline flex-shrink-0" style={{ color: "var(--text-secondary)" }}>
|
|
633
|
+
— custom channel
|
|
634
|
+
</span>
|
|
635
|
+
)}
|
|
636
|
+
</div>
|
|
637
|
+
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
638
|
+
<svg className="w-3.5 h-3.5 hidden sm:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
|
|
639
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
|
640
|
+
</svg>
|
|
641
|
+
<ModelSelect
|
|
642
|
+
value={currentModel ?? ""}
|
|
643
|
+
onChange={handleModelChange}
|
|
644
|
+
models={state.models}
|
|
645
|
+
disabled={!state.openclawConnected}
|
|
646
|
+
placeholder="No model"
|
|
647
|
+
compact
|
|
648
|
+
/>
|
|
649
|
+
</div>
|
|
592
650
|
</div>
|
|
593
|
-
|
|
651
|
+
)}
|
|
594
652
|
|
|
595
653
|
{/* Session tabs — shown for all agents (including default/General) */}
|
|
596
654
|
{showSessionTabs && <SessionTabs channelId={channelId} />}
|
|
597
655
|
|
|
598
|
-
{/* Messages – flat-row layout */}
|
|
599
|
-
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
656
|
+
{/* Messages – flat-row layout (overflow-x-hidden prevents horizontal scroll from long URLs/code) */}
|
|
657
|
+
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
|
600
658
|
{state.messages.length === 0 && (
|
|
601
659
|
<div className="py-12 px-5 text-center">
|
|
602
660
|
<p className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
@@ -615,6 +673,8 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
615
673
|
msg={msg}
|
|
616
674
|
grouped={isGrouped}
|
|
617
675
|
onOpenThread={() => openThread(msg.id)}
|
|
676
|
+
onQuote={() => handleQuote(msg)}
|
|
677
|
+
onCopy={() => handleCopy(msg.text)}
|
|
618
678
|
onAction={handleA2UIAction}
|
|
619
679
|
onResolveAction={(value, label) => handleResolveAction(msg.id, value, label)}
|
|
620
680
|
onStop={handleStop}
|
|
@@ -662,6 +722,34 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
662
722
|
})}
|
|
663
723
|
</div>
|
|
664
724
|
|
|
725
|
+
{/* Quote reply preview */}
|
|
726
|
+
{quotedMessage && (
|
|
727
|
+
<div
|
|
728
|
+
className="flex items-center gap-2 px-3 py-2 mb-1 rounded-md text-caption"
|
|
729
|
+
style={{
|
|
730
|
+
background: "var(--bg-hover)",
|
|
731
|
+
borderLeft: "3px solid var(--text-link)",
|
|
732
|
+
color: "var(--text-secondary)",
|
|
733
|
+
}}
|
|
734
|
+
>
|
|
735
|
+
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
736
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
|
737
|
+
</svg>
|
|
738
|
+
<span className="truncate flex-1">
|
|
739
|
+
{quotedMessage.sender === "user" ? "You" : "Agent"}: {quotedMessage.text.slice(0, 80)}{quotedMessage.text.length > 80 ? "..." : ""}
|
|
740
|
+
</span>
|
|
741
|
+
<button
|
|
742
|
+
onClick={() => setQuotedMessage(null)}
|
|
743
|
+
className="p-0.5 rounded hover:bg-[--bg-surface] shrink-0"
|
|
744
|
+
style={{ color: "var(--text-muted)" }}
|
|
745
|
+
>
|
|
746
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
747
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
748
|
+
</svg>
|
|
749
|
+
</button>
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
752
|
+
|
|
665
753
|
<div
|
|
666
754
|
className="rounded-md"
|
|
667
755
|
style={{
|
|
@@ -669,21 +757,35 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
669
757
|
background: "var(--bg-surface)",
|
|
670
758
|
}}
|
|
671
759
|
>
|
|
672
|
-
{/*
|
|
760
|
+
{/* File/image preview */}
|
|
673
761
|
{pendingImage && (
|
|
674
762
|
<div className="px-3 pt-2 flex items-start gap-2">
|
|
675
763
|
<div className="relative">
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
764
|
+
{pendingImage.preview ? (
|
|
765
|
+
<img
|
|
766
|
+
src={pendingImage.preview}
|
|
767
|
+
alt="Preview"
|
|
768
|
+
className="max-w-[120px] max-h-[80px] rounded-md object-contain"
|
|
769
|
+
style={{ border: "1px solid var(--border)" }}
|
|
770
|
+
/>
|
|
771
|
+
) : (
|
|
772
|
+
<div
|
|
773
|
+
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-caption"
|
|
774
|
+
style={{ border: "1px solid var(--border)", background: "var(--bg-hover)" }}
|
|
775
|
+
>
|
|
776
|
+
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
|
|
777
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
778
|
+
</svg>
|
|
779
|
+
<span className="truncate max-w-[100px]" style={{ color: "var(--text-secondary)" }}>
|
|
780
|
+
{pendingImage.file.name}
|
|
781
|
+
</span>
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
682
784
|
<button
|
|
683
785
|
onClick={clearPendingImage}
|
|
684
786
|
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center text-white opacity-80 hover:opacity-100 transition-opacity"
|
|
685
787
|
style={{ background: "#e74c3c", fontSize: 11 }}
|
|
686
|
-
title="Remove
|
|
788
|
+
title="Remove file"
|
|
687
789
|
>
|
|
688
790
|
✕
|
|
689
791
|
</button>
|
|
@@ -726,20 +828,20 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
726
828
|
<input
|
|
727
829
|
ref={fileInputRef}
|
|
728
830
|
type="file"
|
|
729
|
-
accept="image
|
|
831
|
+
accept="image/*,application/pdf,.txt,.csv,.md,.json,.zip,.gz,.mp3,.wav,.mp4,.mov"
|
|
730
832
|
className="hidden"
|
|
731
833
|
onChange={handleFileSelect}
|
|
732
834
|
/>
|
|
733
835
|
<button
|
|
734
836
|
onClick={() => fileInputRef.current?.click()}
|
|
735
|
-
className="p-1.5 rounded hover:bg-[--bg-hover] transition-colors"
|
|
736
|
-
style={{ color: "var(--text-
|
|
737
|
-
title="
|
|
738
|
-
aria-label="
|
|
837
|
+
className="p-1.5 rounded hover:bg-[--bg-hover] transition-colors flex items-center gap-1"
|
|
838
|
+
style={{ color: "var(--text-secondary)" }}
|
|
839
|
+
title="Attach file"
|
|
840
|
+
aria-label="Attach file"
|
|
739
841
|
disabled={!state.openclawConnected}
|
|
740
842
|
>
|
|
741
|
-
<svg className="w-
|
|
742
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="
|
|
843
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
844
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
|
743
845
|
</svg>
|
|
744
846
|
</button>
|
|
745
847
|
</div>
|
|
@@ -764,7 +866,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
764
866
|
) : (
|
|
765
867
|
<button
|
|
766
868
|
onClick={handleSend}
|
|
767
|
-
disabled={
|
|
869
|
+
disabled={!input.trim() && !pendingImage}
|
|
768
870
|
className="px-3 py-1.5 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
769
871
|
style={{ background: "var(--bg-active)" }}
|
|
770
872
|
>
|
|
@@ -788,6 +890,8 @@ function MessageRow({
|
|
|
788
890
|
msg,
|
|
789
891
|
grouped,
|
|
790
892
|
onOpenThread,
|
|
893
|
+
onQuote,
|
|
894
|
+
onCopy,
|
|
791
895
|
onAction,
|
|
792
896
|
onResolveAction,
|
|
793
897
|
onStop,
|
|
@@ -795,6 +899,8 @@ function MessageRow({
|
|
|
795
899
|
msg: ChatMessage;
|
|
796
900
|
grouped: boolean;
|
|
797
901
|
onOpenThread: () => void;
|
|
902
|
+
onQuote: () => void;
|
|
903
|
+
onCopy: () => void;
|
|
798
904
|
onAction?: (action: string) => void;
|
|
799
905
|
onResolveAction?: (value: string, label: string) => void;
|
|
800
906
|
onStop?: () => void;
|
|
@@ -805,10 +911,44 @@ function MessageRow({
|
|
|
805
911
|
const initial = msg.sender === "user" ? "U" : "A";
|
|
806
912
|
const replyCount = state.threadReplyCounts[msg.id] ?? 0;
|
|
807
913
|
|
|
914
|
+
// Long-press context menu for mobile
|
|
915
|
+
const [showContextMenu, setShowContextMenu] = useState(false);
|
|
916
|
+
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
917
|
+
const touchMoved = useRef(false);
|
|
918
|
+
|
|
919
|
+
const handleTouchStart = useCallback(() => {
|
|
920
|
+
touchMoved.current = false;
|
|
921
|
+
longPressTimer.current = setTimeout(() => {
|
|
922
|
+
if (!touchMoved.current) setShowContextMenu(true);
|
|
923
|
+
}, 500);
|
|
924
|
+
}, []);
|
|
925
|
+
|
|
926
|
+
const handleTouchMove = useCallback(() => {
|
|
927
|
+
touchMoved.current = true;
|
|
928
|
+
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
|
929
|
+
}, []);
|
|
930
|
+
|
|
931
|
+
const handleTouchEnd = useCallback(() => {
|
|
932
|
+
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
|
933
|
+
}, []);
|
|
934
|
+
|
|
935
|
+
// Copied feedback
|
|
936
|
+
const [copied, setCopied] = useState(false);
|
|
937
|
+
const handleCopyWithFeedback = useCallback(() => {
|
|
938
|
+
onCopy();
|
|
939
|
+
setCopied(true);
|
|
940
|
+
setTimeout(() => setCopied(false), 1500);
|
|
941
|
+
setShowContextMenu(false);
|
|
942
|
+
}, [onCopy]);
|
|
943
|
+
|
|
808
944
|
return (
|
|
809
945
|
<div
|
|
810
946
|
className="group relative px-3 sm:px-5 hover:bg-[--bg-hover] transition-colors"
|
|
811
947
|
style={{ paddingTop: grouped ? 2 : 8, paddingBottom: 2 }}
|
|
948
|
+
onTouchStart={handleTouchStart}
|
|
949
|
+
onTouchMove={handleTouchMove}
|
|
950
|
+
onTouchEnd={handleTouchEnd}
|
|
951
|
+
onContextMenu={(e) => { e.preventDefault(); setShowContextMenu(true); }}
|
|
812
952
|
>
|
|
813
953
|
<div className="flex gap-2 max-w-message">
|
|
814
954
|
{/* Avatar column */}
|
|
@@ -838,6 +978,8 @@ function MessageRow({
|
|
|
838
978
|
<MessageContent
|
|
839
979
|
text={msg.text}
|
|
840
980
|
mediaUrl={msg.mediaUrl}
|
|
981
|
+
messageId={msg.id}
|
|
982
|
+
encrypted={!!msg.mediaUrl && E2eService.hasKey()}
|
|
841
983
|
a2ui={msg.a2ui}
|
|
842
984
|
isStreaming={msg.isStreaming}
|
|
843
985
|
onAction={onAction}
|
|
@@ -913,7 +1055,7 @@ function MessageRow({
|
|
|
913
1055
|
</div>
|
|
914
1056
|
</div>
|
|
915
1057
|
|
|
916
|
-
{/* Action bar (
|
|
1058
|
+
{/* Desktop: Action bar (hover) — Thread + Quote + Copy */}
|
|
917
1059
|
<div
|
|
918
1060
|
className="absolute top-0 right-5 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 px-1 py-0.5 rounded"
|
|
919
1061
|
style={{
|
|
@@ -922,16 +1064,106 @@ function MessageRow({
|
|
|
922
1064
|
boxShadow: "var(--shadow-sm)",
|
|
923
1065
|
}}
|
|
924
1066
|
>
|
|
925
|
-
<ActionButton label="Reply in thread" icon={
|
|
1067
|
+
<ActionButton label="Reply in thread" onClick={onOpenThread} icon={
|
|
926
1068
|
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
927
1069
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
928
1070
|
</svg>
|
|
929
|
-
}
|
|
1071
|
+
} />
|
|
1072
|
+
<ActionButton label="Quote reply" onClick={() => { onQuote(); }} icon={
|
|
1073
|
+
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
1074
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
|
1075
|
+
</svg>
|
|
1076
|
+
} />
|
|
1077
|
+
<ActionButton label={copied ? "Copied!" : "Copy text"} onClick={handleCopyWithFeedback} icon={
|
|
1078
|
+
copied ? (
|
|
1079
|
+
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
1080
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
1081
|
+
</svg>
|
|
1082
|
+
) : (
|
|
1083
|
+
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
1084
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
|
1085
|
+
</svg>
|
|
1086
|
+
)
|
|
1087
|
+
} />
|
|
930
1088
|
</div>
|
|
1089
|
+
|
|
1090
|
+
{/* Mobile: Long-press context menu (bottom sheet) */}
|
|
1091
|
+
{showContextMenu && (
|
|
1092
|
+
<div
|
|
1093
|
+
className="fixed inset-0 z-50 flex items-end justify-center"
|
|
1094
|
+
style={{ background: "rgba(0,0,0,0.4)" }}
|
|
1095
|
+
onClick={() => setShowContextMenu(false)}
|
|
1096
|
+
>
|
|
1097
|
+
<div
|
|
1098
|
+
className="w-full max-w-md rounded-t-xl overflow-hidden"
|
|
1099
|
+
style={{
|
|
1100
|
+
background: "var(--bg-surface)",
|
|
1101
|
+
paddingBottom: "env(safe-area-inset-bottom, 12px)",
|
|
1102
|
+
}}
|
|
1103
|
+
onClick={(e) => e.stopPropagation()}
|
|
1104
|
+
>
|
|
1105
|
+
{/* Preview of the message being acted on */}
|
|
1106
|
+
<div className="px-4 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
|
|
1107
|
+
<span className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
1108
|
+
{msg.sender === "user" ? "You" : "Agent"}
|
|
1109
|
+
</span>
|
|
1110
|
+
<p className="text-body mt-0.5 line-clamp-2" style={{ color: "var(--text-primary)" }}>
|
|
1111
|
+
{msg.text.slice(0, 120)}{msg.text.length > 120 ? "..." : ""}
|
|
1112
|
+
</p>
|
|
1113
|
+
</div>
|
|
1114
|
+
|
|
1115
|
+
<ContextMenuItem
|
|
1116
|
+
label="Reply in thread"
|
|
1117
|
+
icon={<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg>}
|
|
1118
|
+
onClick={() => { setShowContextMenu(false); onOpenThread(); }}
|
|
1119
|
+
/>
|
|
1120
|
+
<ContextMenuItem
|
|
1121
|
+
label="Quote reply"
|
|
1122
|
+
icon={<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" /></svg>}
|
|
1123
|
+
onClick={() => { setShowContextMenu(false); onQuote(); }}
|
|
1124
|
+
/>
|
|
1125
|
+
<ContextMenuItem
|
|
1126
|
+
label="Copy text"
|
|
1127
|
+
icon={<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" /></svg>}
|
|
1128
|
+
onClick={handleCopyWithFeedback}
|
|
1129
|
+
/>
|
|
1130
|
+
|
|
1131
|
+
<button
|
|
1132
|
+
onClick={() => setShowContextMenu(false)}
|
|
1133
|
+
className="w-full py-3 text-body font-bold"
|
|
1134
|
+
style={{ color: "var(--text-muted)", borderTop: "1px solid var(--border)" }}
|
|
1135
|
+
>
|
|
1136
|
+
Cancel
|
|
1137
|
+
</button>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
)}
|
|
931
1141
|
</div>
|
|
932
1142
|
);
|
|
933
1143
|
}
|
|
934
1144
|
|
|
1145
|
+
/** Context menu item for mobile long-press bottom sheet */
|
|
1146
|
+
function ContextMenuItem({
|
|
1147
|
+
label,
|
|
1148
|
+
icon,
|
|
1149
|
+
onClick,
|
|
1150
|
+
}: {
|
|
1151
|
+
label: string;
|
|
1152
|
+
icon: React.ReactNode;
|
|
1153
|
+
onClick: () => void;
|
|
1154
|
+
}) {
|
|
1155
|
+
return (
|
|
1156
|
+
<button
|
|
1157
|
+
onClick={onClick}
|
|
1158
|
+
className="w-full flex items-center gap-3 px-4 py-3 text-body transition-colors active:bg-[--bg-hover]"
|
|
1159
|
+
style={{ color: "var(--text-primary)" }}
|
|
1160
|
+
>
|
|
1161
|
+
<span style={{ color: "var(--text-secondary)" }}>{icon}</span>
|
|
1162
|
+
{label}
|
|
1163
|
+
</button>
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
935
1167
|
function ActionButton({
|
|
936
1168
|
label,
|
|
937
1169
|
icon,
|