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.
Files changed (56) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +20 -1
  4. package/packages/api/src/do/connection-do.ts +142 -24
  5. package/packages/api/src/env.ts +6 -0
  6. package/packages/api/src/index.ts +7 -0
  7. package/packages/api/src/routes/auth.ts +85 -9
  8. package/packages/api/src/routes/channels.ts +3 -2
  9. package/packages/api/src/routes/dev-auth.ts +45 -0
  10. package/packages/api/src/routes/push.ts +52 -0
  11. package/packages/api/src/routes/upload.ts +73 -38
  12. package/packages/api/src/utils/fcm.ts +167 -0
  13. package/packages/api/src/utils/firebase.ts +218 -0
  14. package/packages/plugin/dist/src/channel.d.ts +6 -0
  15. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  16. package/packages/plugin/dist/src/channel.js +71 -15
  17. package/packages/plugin/dist/src/channel.js.map +1 -1
  18. package/packages/plugin/package.json +1 -1
  19. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  20. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  21. package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
  22. package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
  23. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  24. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  25. package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
  26. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  27. package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
  28. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  29. package/packages/web/dist/index.html +6 -4
  30. package/packages/web/dist/sw.js +158 -1
  31. package/packages/web/index.html +4 -2
  32. package/packages/web/package.json +4 -1
  33. package/packages/web/src/App.tsx +117 -1
  34. package/packages/web/src/api.ts +21 -1
  35. package/packages/web/src/components/AccountSettings.tsx +131 -0
  36. package/packages/web/src/components/ChatWindow.tsx +302 -70
  37. package/packages/web/src/components/CronSidebar.tsx +89 -24
  38. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  39. package/packages/web/src/components/LoginPage.tsx +55 -7
  40. package/packages/web/src/components/MessageContent.tsx +71 -9
  41. package/packages/web/src/components/MobileLayout.tsx +28 -118
  42. package/packages/web/src/components/SessionTabs.tsx +41 -2
  43. package/packages/web/src/components/Sidebar.tsx +88 -66
  44. package/packages/web/src/e2e.ts +26 -5
  45. package/packages/web/src/firebase.ts +215 -3
  46. package/packages/web/src/foreground.ts +51 -0
  47. package/packages/web/src/index.css +10 -2
  48. package/packages/web/src/main.tsx +24 -2
  49. package/packages/web/src/push.ts +205 -0
  50. package/packages/web/src/ws.ts +20 -8
  51. package/scripts/dev.sh +158 -26
  52. package/scripts/mock-openclaw.mjs +382 -0
  53. package/scripts/test-e2e-chat.ts +2 -2
  54. package/scripts/test-e2e-live.ts +1 -1
  55. package/wrangler.toml +3 -0
  56. 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
- // Image upload helpers
269
+ // File upload helpers
263
270
  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
264
271
  const file = e.target.files?.[0];
265
- if (!file || !file.type.startsWith("image/")) return;
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
- const uploadImage = useCallback(async (file: File): Promise<string | null> => {
280
- const formData = new FormData();
281
- formData.append("file", file);
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", `Image upload failed: ${err}`);
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 && file.type.startsWith("image/")) {
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
- const trimmed = input.trim();
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
- // Upload image if present
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
- const url = await uploadImage(pendingImage.file);
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 (!url) return; // Upload failed
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: randomUUID(),
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
- <div
564
- className="flex items-center justify-between px-3 sm:px-5 gap-2 flex-shrink-0"
565
- style={{
566
- height: 44,
567
- borderBottom: "1px solid var(--border)",
568
- }}
569
- >
570
- <div className="flex items-center gap-2 min-w-0">
571
- <span className="text-h1 truncate" style={{ color: "var(--text-primary)" }}>
572
- # {channelName}
573
- </span>
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
- </div>
580
- <div className="flex items-center gap-1.5 flex-shrink-0">
581
- <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)" }}>
582
- <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" />
583
- </svg>
584
- <ModelSelect
585
- value={currentModel ?? ""}
586
- onChange={handleModelChange}
587
- models={state.models}
588
- disabled={!state.openclawConnected}
589
- placeholder="No model"
590
- compact
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
- </div>
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
- {/* Image preview */}
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
- <img
677
- src={pendingImage.preview}
678
- alt="Preview"
679
- className="max-w-[120px] max-h-[80px] rounded-md object-contain"
680
- style={{ border: "1px solid var(--border)" }}
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 image"
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-muted)" }}
737
- title="Upload image"
738
- aria-label="Upload image"
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-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
742
- <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v13.5A1.5 1.5 0 003.75 21zm14.25-15.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
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={(!input.trim() && !pendingImage) || !state.openclawConnected}
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 (section 5.3) appears on hover */}
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
- } onClick={onOpenThread} />
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,