@marktiderman/genesis-chat 0.2.0 → 0.2.2

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 (77) hide show
  1. package/README.md +82 -7
  2. package/dist/{chunk-5D5A7I4Q.js → chunk-A4YJX7KT.js} +130 -16
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/{chunk-7S6HYNWW.js → chunk-GBJ3YI4E.js} +72 -5
  7. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  8. package/dist/{chunk-PZTOY2LY.js → chunk-JU2FAPBI.js} +46 -13
  9. package/dist/chunk-JU2FAPBI.js.map +1 -0
  10. package/dist/{client-DMa2j7kz.d.ts → client-DUIG1hum.d.ts} +36 -1
  11. package/dist/index.d.ts +4 -4
  12. package/dist/index.js +2 -2
  13. package/dist/native.d.ts +36 -7
  14. package/dist/native.js +239 -116
  15. package/dist/native.js.map +1 -1
  16. package/dist/{outbox-B0y3phE0.d.ts → outbox-B9NAWQ58.d.ts} +74 -6
  17. package/dist/push.d.ts +2 -2
  18. package/dist/react.d.ts +48 -21
  19. package/dist/react.js +3 -3
  20. package/dist/server.d.ts +43 -2
  21. package/dist/server.js +115 -15
  22. package/dist/server.js.map +1 -1
  23. package/dist/{thread-DWXHKPdw.d.ts → thread-oPra7pkW.d.ts} +1 -1
  24. package/dist/web.d.ts +31 -11
  25. package/dist/web.js +97 -48
  26. package/dist/web.js.map +1 -1
  27. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  28. package/migrations/tests/0002_genesis_chat_coaching.test.sql +7 -3
  29. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  30. package/package.json +13 -9
  31. package/src/__tests__/coaching-core.test.ts +3 -1
  32. package/src/__tests__/coaching-react.test.tsx +73 -2
  33. package/src/__tests__/coaching-web.test.tsx +78 -6
  34. package/src/__tests__/fake-xhr.ts +54 -0
  35. package/src/__tests__/outbox-hosted.test.ts +269 -0
  36. package/src/__tests__/review-279.test.ts +41 -0
  37. package/src/__tests__/review-fixes-web.test.tsx +13 -10
  38. package/src/__tests__/server-fake-video.test.ts +81 -0
  39. package/src/__tests__/server-fakes.ts +7 -1
  40. package/src/__tests__/server-safety.test.ts +17 -4
  41. package/src/__tests__/web.test.tsx +16 -0
  42. package/src/core/client.ts +28 -2
  43. package/src/core/outbox.ts +222 -16
  44. package/src/core/transfer.ts +71 -0
  45. package/src/core/types.ts +17 -0
  46. package/src/index.ts +1 -0
  47. package/src/native/__tests__/emoji.test.tsx +16 -0
  48. package/src/native/__tests__/organise-screens.test.tsx +80 -0
  49. package/src/native/__tests__/video-media.test.tsx +81 -4
  50. package/src/native/attachment-cards.tsx +22 -6
  51. package/src/native/inbox-screen.tsx +12 -6
  52. package/src/native/index.ts +2 -2
  53. package/src/native/message-actions-sheet.tsx +10 -5
  54. package/src/native/message-bubble.tsx +3 -11
  55. package/src/native/modules.ts +2 -0
  56. package/src/native/schedule-sheet.tsx +23 -9
  57. package/src/native/search-screen.tsx +26 -5
  58. package/src/native/thread-screen.tsx +30 -3
  59. package/src/native/topics.tsx +70 -15
  60. package/src/native/video-player.tsx +46 -6
  61. package/src/native/video-recorder.tsx +6 -3
  62. package/src/native/voice-player.tsx +18 -0
  63. package/src/react/index.tsx +103 -19
  64. package/src/react/previews.ts +51 -0
  65. package/src/server/fake-video.ts +171 -0
  66. package/src/server/index.ts +1 -0
  67. package/src/web/attachments.tsx +6 -6
  68. package/src/web/bubble.tsx +22 -0
  69. package/src/web/inbox.tsx +7 -6
  70. package/src/web/memory-client.ts +11 -3
  71. package/src/web/panel.tsx +2 -2
  72. package/src/web/schedule.tsx +14 -2
  73. package/src/web/thread.tsx +36 -8
  74. package/src/web/video.tsx +28 -34
  75. package/dist/chunk-5D5A7I4Q.js.map +0 -1
  76. package/dist/chunk-7S6HYNWW.js.map +0 -1
  77. package/dist/chunk-PZTOY2LY.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { q as ChatMessage, B as PendingMessage, g as ChatActivity } from './client-DMa2j7kz.js';
1
+ import { q as ChatMessage, B as PendingMessage, g as ChatActivity } from './client-DUIG1hum.js';
2
2
 
3
3
  /**
4
4
  * @feat:chat
package/dist/web.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentType, ReactNode, Ref, Component, ErrorInfo } from 'react';
3
3
  import { SlotOverride } from '@marktiderman/genesis-core/slots';
4
- import { q as ChatMessage, m as ChatInboxItem, o as ChatLinkPreview$1, h as ChatAttachment, k as ChatConversation, p as ChatMember, t as ChatRole, i as ChatClient, g as ChatActivity, C as ChatAvailability, x as ChatTopic, T as TopicInput, u as ChatSearchHit, E as SendInput, A as AttachmentInput } from './client-DMa2j7kz.js';
4
+ import { q as ChatMessage, m as ChatInboxItem, o as ChatLinkPreview$1, h as ChatAttachment, k as ChatConversation, p as ChatMember, t as ChatRole, i as ChatClient, g as ChatActivity, C as ChatAvailability, x as ChatTopic, T as TopicInput, u as ChatSearchHit, F as SendInput, A as AttachmentInput } from './client-DUIG1hum.js';
5
5
  import { ChatAttachmentKind, ConversationState, ChatVideoUpload } from './react.js';
6
- import { T as ThreadItem } from './thread-DWXHKPdw.js';
7
- import { L as LocalFile } from './outbox-B0y3phE0.js';
6
+ import { T as ThreadItem } from './thread-oPra7pkW.js';
7
+ import { L as LocalFile } from './outbox-B9NAWQ58.js';
8
8
  import '@supabase/supabase-js';
9
9
  import '@tanstack/query-core';
10
10
 
@@ -209,8 +209,16 @@ interface ChatThreadProps {
209
209
  threadRootId?: string;
210
210
  /** One topic's messages. */
211
211
  topic?: string;
212
- /** Shown on messages that have replies, to open their thread. */
213
- onOpenThread?: (message: ChatMessage) => void;
212
+ /**
213
+ * Shown on messages that have replies, to open their thread. Also called when a search result
214
+ * is a reply in a thread, with `jumpTo` (that reply) for the thread's own ChatThread.
215
+ */
216
+ onOpenThread?: (message: ChatMessage, opts?: {
217
+ jumpTo?: {
218
+ messageId: string;
219
+ seq: number;
220
+ };
221
+ }) => void;
214
222
  /** The quick reactions; 👍 ❤️ 😂 😮 😢 🙏 by default. */
215
223
  quickReactions?: readonly string[];
216
224
  placeholder?: string;
@@ -220,6 +228,7 @@ interface ChatThreadProps {
220
228
  jumpTo?: {
221
229
  messageId: string;
222
230
  seq?: number;
231
+ threadRootId?: string | null;
223
232
  } | null;
224
233
  /** A search button in the header. Default true. */
225
234
  search?: boolean;
@@ -508,7 +517,7 @@ interface ChatScheduledListProps {
508
517
  conversationTitle?: (conversationId: string) => string;
509
518
  className?: string;
510
519
  }
511
- /** Your scheduled messages, soonest first, each with when it goes and Cancel. */
520
+ /** Your scheduled messages, soonest first, each with when it goes and Cancel (Remove for one that failed). */
512
521
  declare function ChatScheduledList({ conversationId, conversationTitle, className }: ChatScheduledListProps): react.JSX.Element;
513
522
 
514
523
  interface ChatAwayBannerProps {
@@ -643,12 +652,18 @@ declare function useVideoRecorder({ maxMs, onTick }: UseVideoRecorderOptions): {
643
652
  handOff: () => void;
644
653
  };
645
654
  type VideoRecorder = ReturnType<typeof useVideoRecorder>;
646
- /** A recording as a file the outbox uploads to storage (the fallback without `videoUpload`). */
647
- declare function videoFile(rec: VideoRecording): LocalFile;
655
+ /**
656
+ * A recording as a file for the outbox: `hosted` sends it to the hosted provider (the provider's
657
+ * `videoUpload`, the message going at once with the video pending); otherwise it goes to storage.
658
+ */
659
+ declare function videoFile(rec: VideoRecording, opts?: {
660
+ hosted?: boolean;
661
+ }): LocalFile;
648
662
  /**
649
663
  * Uploads a recording through the app's `videoUpload` (a Mux direct upload: PUT the bytes to
650
664
  * the URL it returns) and gives back the attachment to send, pending until Mux's webhook marks
651
- * it ready.
665
+ * it ready. The composer no longer uses it: it sends `videoFile(rec, { hosted: true })` through
666
+ * the outbox, so the message keeps its place in the order while the bytes go up.
652
667
  */
653
668
  declare function uploadVideo(videoUpload: ChatVideoUpload, conversationId: string, rec: Pick<VideoRecording, "blob" | "contentType" | "durationMs" | "name">, fetchImpl?: typeof fetch, signal?: AbortSignal): Promise<AttachmentInput>;
654
669
  interface ChatVideoRecorderProps {
@@ -660,14 +675,19 @@ interface ChatVideoRecorderProps {
660
675
  send: (input: Omit<SendInput, "conversationId" | "clientId">, files?: LocalFile[]) => Promise<unknown>;
661
676
  /** Merged into what's sent (a reply, a topic, an internal note). */
662
677
  extra?: Omit<SendInput, "conversationId" | "clientId" | "attachments" | "body">;
678
+ /**
679
+ * Marks the take as hosted. Pass the same `videoUpload` the `ChatProvider` has (or give the
680
+ * provider a `hostedUpload`): the outbox does the upload, and without one it falls back to
681
+ * storage with no error.
682
+ */
663
683
  videoUpload?: ChatVideoUpload;
664
684
  onTick?: () => void;
665
685
  onSent?: () => void;
666
- /** For tests. */
686
+ /** @deprecated Unused: the bytes go through the outbox's `hostedUpload` now. */
667
687
  fetchImpl?: typeof fetch;
668
688
  }
669
689
  /** The composer's video dialog: preview, record (to the role's limit), review, retake, send. */
670
- declare function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, send, extra, videoUpload, onTick, onSent, fetchImpl }: ChatVideoRecorderProps): react.JSX.Element;
690
+ declare function ChatVideoRecorder({ open, onOpenChange, maxMs, send, extra, videoUpload, onTick, onSent }: ChatVideoRecorderProps): react.JSX.Element;
671
691
  declare const muxStreamUrl: (playbackId: string) => string;
672
692
  declare const muxPosterUrl: (playbackId: string) => string;
673
693
  interface ChatVideoPlayerProps {
package/dist/web.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { attachmentPreview } from './chunk-AZS3HNLM.js';
1
2
  import { snippetParts, isHttpsUrl, scheduleQuickPicks, isSchedulable, scheduleLabel, extractLinks, topicKey } from './chunk-BJCE5O7V.js';
2
- import { useChat, useInbox, useSearch, useScheduled, useAvailability, useTopics, useConversation, useMessageActions, usePins, useFileUrls, useLinkPreviews, useBroadcast } from './chunk-PZTOY2LY.js';
3
- import { previewText, summarizeReactions, activityLine, itemSender, startsDay, dayLabel, itemTime, startsGroup, parseMentions } from './chunk-5D5A7I4Q.js';
4
- import { fileExtension, formatDuration, nextPlaybackRate, awaySummary, awayLine, WEEKDAYS, CHAT_LIMITS, waveformPeaks, recordingLimitMs, isAllowedExtension, attachmentKindFor, randomId, ChatError } from './chunk-7S6HYNWW.js';
3
+ import { useChat, useInbox, useSearch, useScheduled, useAvailability, useTopics, useConversation, useMessageActions, usePins, useFileUrls, useLinkPreviews, useBroadcast } from './chunk-JU2FAPBI.js';
4
+ import { previewText, summarizeReactions, activityLine, itemSender, startsDay, dayLabel, itemTime, startsGroup, parseMentions } from './chunk-A4YJX7KT.js';
5
+ import { fileExtension, formatDuration, nextPlaybackRate, awaySummary, awayLine, WEEKDAYS, CHAT_LIMITS, waveformPeaks, recordingLimitMs, isAllowedExtension, attachmentKindFor, randomId, ChatError } from './chunk-GBJ3YI4E.js';
5
6
  import { createContext, useRef, useState, Fragment, Component, useCallback, useEffect, useMemo, useImperativeHandle, useLayoutEffect, useContext } from 'react';
6
7
  import { MessageSquare, Pin, BellOff, AtSign, Search, X, Mic, ClipboardList, ExternalLink, VideoOff, Video, RotateCcw, SendHorizontal, Square, Circle, Pause, Play, Paperclip, ChevronLeft, ChevronRight, FileText, Download, Lock, Ban, EyeOff, ShieldAlert, AlertCircle, Clock, Reply, Copy, Pencil, PinOff, Bookmark, Flag, Trash2, SmilePlus, Plus, MoreHorizontal, ChevronUp, CalendarClock, Moon, RefreshCw, Smile, Hash, LockOpen, ArrowDown, ImagePlus, Megaphone, Link2, ArrowLeft } from 'lucide-react';
7
- import { Skeleton, cn, EmptyState, Button, UserAvatar, Badge, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Alert, AlertDescription, Spinner, Collapsible, CollapsibleTrigger, CollapsibleContent, VisuallyHidden, ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuSeparator, ContextMenuItem, Tooltip, Textarea, Popover, PopoverTrigger, PopoverContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuLabel, Label, DialogFooter, toast, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle, ToggleGroup, ToggleGroupItem, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, RadioGroup, RadioGroupItem, Split, Checkbox } from '@marktiderman/genesis-ui';
8
+ import { Skeleton, cn, EmptyState, Button, UserAvatar, Badge, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Alert, AlertDescription, Spinner, Collapsible, CollapsibleTrigger, CollapsibleContent, VisuallyHidden, Progress, ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuSeparator, ContextMenuItem, Tooltip, Textarea, Popover, PopoverTrigger, PopoverContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuLabel, Label, DialogFooter, toast, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle, ToggleGroup, ToggleGroupItem, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, RadioGroup, RadioGroupItem, Split, Checkbox } from '@marktiderman/genesis-ui';
8
9
  import { renderSlot } from '@marktiderman/genesis-core/slots';
9
10
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
10
11
  import { EmojiPicker } from 'frimousse';
@@ -58,7 +59,7 @@ function inboxTitle(item, resolveUser, fallback) {
58
59
  return fallback?.(item) ?? "Conversation";
59
60
  }
60
61
  function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false, fallbackTitle, slots, className, ...rest }) {
61
- const { userId: me } = useChat();
62
+ const { userId: me, attachmentKinds } = useChat();
62
63
  const { items, isLoading, error, refetch } = useInbox({ kind, archived });
63
64
  const list = useRef(null);
64
65
  const onKeyDown = (e) => {
@@ -92,14 +93,14 @@ function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false,
92
93
  if (items.length === 0) {
93
94
  return /* @__PURE__ */ jsx("div", { className, children: renderSlot(DefaultEmpty, slots?.empty, { archived }) });
94
95
  }
95
- return /* @__PURE__ */ jsx("nav", { "aria-label": rest["aria-label"] ?? "Conversations", className: cn("min-h-0", className), "data-testid": "genesis-chat-inbox", children: /* @__PURE__ */ jsx("ul", { ref: list, className: "flex flex-col", onKeyDown, children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(InboxRow, { item, me, resolveUser, fallbackTitle, selected: item.conversationId === selectedId, onSelect }) }, item.conversationId)) }) });
96
+ return /* @__PURE__ */ jsx("nav", { "aria-label": rest["aria-label"] ?? "Conversations", className: cn("min-h-0", className), "data-testid": "genesis-chat-inbox", children: /* @__PURE__ */ jsx("ul", { ref: list, className: "flex flex-col", onKeyDown, children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(InboxRow, { item, me, kinds: attachmentKinds, resolveUser, fallbackTitle, selected: item.conversationId === selectedId, onSelect }) }, item.conversationId)) }) });
96
97
  }
97
- function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect }) {
98
+ function InboxRow({ item, me, kinds, resolveUser, fallbackTitle, selected, onSelect }) {
98
99
  const title = inboxTitle(item, resolveUser, fallbackTitle);
99
100
  const other = item.otherUserId ? resolveUser(item.otherUserId) : null;
100
101
  const last = item.lastMessage;
101
102
  const unread = item.unread > 0;
102
- let preview = last ? previewText(last.body, last.attachment, last.deleted) : "";
103
+ let preview = last ? attachmentPreview(last.body, last.attachment, kinds, last.deleted) : "";
103
104
  if (last && last.kind !== "system" && !last.deleted && last.senderId) {
104
105
  if (last.senderId === me) preview = `You: ${preview}`;
105
106
  else if (!item.otherUserId && item.memberCount > 2) preview = `${resolveUser(last.senderId).name}: ${preview}`;
@@ -646,13 +647,14 @@ function useVideoRecorder({ maxMs, onTick }) {
646
647
  useEffect(() => () => reset(), [reset]);
647
648
  return { state, error, elapsedMs, maxMs, stream, recording, open, start, stop, retake, reset, handOff };
648
649
  }
649
- function videoFile(rec) {
650
+ function videoFile(rec, opts = {}) {
650
651
  return {
651
652
  data: rec.blob,
652
653
  name: rec.name,
653
654
  contentType: rec.contentType,
654
655
  previewUrl: rec.url ?? void 0,
655
- meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size }
656
+ meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size },
657
+ ...opts.hosted ? { hosted: true } : {}
656
658
  };
657
659
  }
658
660
  async function uploadVideo(videoUpload, conversationId, rec, fetchImpl = fetch, signal) {
@@ -683,48 +685,36 @@ function Preview({ stream }) {
683
685
  }, [stream]);
684
686
  return /* @__PURE__ */ jsx("video", { ref, autoPlay: true, muted: true, playsInline: true, className: "h-full w-full -scale-x-100 object-cover", "aria-label": "Camera preview", "data-testid": "genesis-chat-video-preview" });
685
687
  }
686
- function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, send, extra, videoUpload, onTick, onSent, fetchImpl }) {
688
+ function ChatVideoRecorder({ open, onOpenChange, maxMs, send, extra, videoUpload, onTick, onSent }) {
687
689
  const rec = useVideoRecorder({ maxMs, onTick });
688
690
  const [sending, setSending] = useState(false);
689
691
  const [sendError, setSendError] = useState(null);
690
- const upload = useRef(null);
692
+ const sendingNow = useRef(false);
691
693
  const { open: openCamera, reset } = rec;
692
694
  useEffect(() => {
693
695
  if (open) void openCamera();
694
696
  else reset();
695
697
  }, [open, openCamera, reset]);
696
- useEffect(() => () => upload.current?.abort(), []);
697
698
  const submit = async () => {
698
699
  const take = rec.recording;
699
- if (!take || upload.current) return;
700
- const controller = new AbortController();
701
- upload.current = controller;
700
+ if (!take || sendingNow.current) return;
701
+ sendingNow.current = true;
702
702
  setSending(true);
703
703
  setSendError(null);
704
704
  try {
705
- if (videoUpload) {
706
- const attachment2 = await uploadVideo(videoUpload, conversationId, take, fetchImpl, controller.signal);
707
- if (controller.signal.aborted) return;
708
- await send({ ...extra, body: "", attachments: [attachment2] });
709
- } else {
710
- await send({ ...extra, body: "" }, [videoFile(take)]);
711
- rec.handOff();
712
- }
705
+ await send({ ...extra, body: "" }, [videoFile(take, { hosted: Boolean(videoUpload) })]);
706
+ rec.handOff();
713
707
  onSent?.();
714
708
  onOpenChange(false);
715
709
  } catch (e) {
716
- if (!controller.signal.aborted) setSendError(e instanceof Error ? e.message : "The video didn't send");
710
+ setSendError(e instanceof Error ? e.message : "The video didn't send");
717
711
  } finally {
718
- upload.current = null;
712
+ sendingNow.current = false;
719
713
  setSending(false);
720
714
  }
721
715
  };
722
- const close = (o) => {
723
- if (!o) upload.current?.abort();
724
- onOpenChange(o);
725
- };
726
716
  const recording = rec.state === "recording";
727
- return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: close, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-lg", "data-testid": "genesis-chat-video-recorder", children: [
717
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: (o) => (o || !sendingNow.current) && onOpenChange(o), children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-lg", "data-testid": "genesis-chat-video-recorder", children: [
728
718
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
729
719
  /* @__PURE__ */ jsx(DialogTitle, { children: "Record a video" }),
730
720
  /* @__PURE__ */ jsxs(DialogDescription, { children: [
@@ -750,8 +740,7 @@ function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, send, ex
750
740
  ] }) : null,
751
741
  sending ? /* @__PURE__ */ jsxs("span", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm font-medium", role: "status", children: [
752
742
  /* @__PURE__ */ jsx(Spinner, {}),
753
- " Uploading\u2026",
754
- videoUpload ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => upload.current?.abort(), "data-testid": "genesis-chat-video-cancel-upload", children: "Cancel upload" }) : null
743
+ " Sending\u2026"
755
744
  ] }) : null
756
745
  ] }),
757
746
  /* @__PURE__ */ jsx("div", { className: "sr-only", role: "status", children: recording ? "Recording" : rec.state === "recorded" ? "Recorded. Watch it, retake it or send it." : "" }),
@@ -1200,12 +1189,12 @@ function attachmentKindOf(kinds, kind) {
1200
1189
  const k = kinds[kind];
1201
1190
  return k && typeof k.title === "function" ? k : null;
1202
1191
  }
1203
- function attachmentCardOf(k, a) {
1192
+ function attachmentCardOf(k, a, ctx = { mine: false }) {
1204
1193
  try {
1205
- const title = k.title(a);
1194
+ const title = k.title(a, ctx);
1206
1195
  if (typeof title !== "string") return null;
1207
- const sub = typeof k.subtitle === "function" ? k.subtitle(a) : null;
1208
- const act = typeof k.action === "function" ? k.action(a) : null;
1196
+ const sub = typeof k.subtitle === "function" ? k.subtitle(a, ctx) : null;
1197
+ const act = typeof k.action === "function" ? k.action(a, ctx) : null;
1209
1198
  const action = act && typeof act === "object" && typeof act.label === "string" && (typeof act.href === "string" || typeof act.onPress === "function") ? { label: act.label, href: typeof act.href === "string" ? act.href : void 0, onPress: typeof act.onPress === "function" ? act.onPress : void 0 } : null;
1210
1199
  return { title, subtitle: typeof sub === "string" ? sub : null, action };
1211
1200
  } catch (e) {
@@ -1246,7 +1235,7 @@ function MessageAttachments({ views, message, mine, tone, slot, kinds }) {
1246
1235
  const generic = /* @__PURE__ */ jsx(GenericAttachment, { attachment: v.attachment, message, url: v.url, mine });
1247
1236
  const k = attachmentKindOf(kinds, v.kind);
1248
1237
  if (k) {
1249
- const card = attachmentCardOf(k, v.attachment);
1238
+ const card = attachmentCardOf(k, v.attachment, { mine });
1250
1239
  return card ? /* @__PURE__ */ jsx(ChatAttachmentCard, { ...card, tone }, v.key) : /* @__PURE__ */ jsx(GenericAttachment, { attachment: v.attachment, message, url: v.url, mine }, v.key);
1251
1240
  }
1252
1241
  return Custom ? /* @__PURE__ */ jsx(ChatErrorBoundary, { fallback: generic, children: /* @__PURE__ */ jsx(Custom, { attachment: v.attachment, message, url: v.url, mine }) }, v.key) : null;
@@ -1359,6 +1348,12 @@ function pendingViews(p) {
1359
1348
  pending: p.status === "sending"
1360
1349
  }));
1361
1350
  }
1351
+ function pendingProgress(p) {
1352
+ if (p.status !== "sending") return null;
1353
+ const files = p.files ?? [];
1354
+ if (!files.some((f) => typeof f.progress === "number")) return null;
1355
+ return Math.round(files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length * 100);
1356
+ }
1362
1357
  function returnFocus(e, keepFocus) {
1363
1358
  const next = keepFocus.current;
1364
1359
  if (next) {
@@ -1385,6 +1380,7 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
1385
1380
  const showHeader = ctx.isGroup && !mine && startsGroup2;
1386
1381
  const tone = mine && !note && !deleted ? "primary" : "default";
1387
1382
  const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
1383
+ const progress = pending ? pendingProgress(pending) : null;
1388
1384
  const editing = ctx.editingId === message.id;
1389
1385
  const reactions = pending ? [] : summarizeReactions(message, ctx.me);
1390
1386
  const highlighted = ctx.highlightedId === message.id;
@@ -1417,7 +1413,22 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
1417
1413
  " Message deleted"
1418
1414
  ] }) : editing ? /* @__PURE__ */ jsx(EditBox, { message }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1419
1415
  message.replyToId ? /* @__PURE__ */ jsx(ReplyQuote, { id: message.replyToId, tone }) : null,
1420
- views.length ? /* @__PURE__ */ jsx("div", { className: cn(message.body ? "mb-1.5" : ""), children: /* @__PURE__ */ jsx(MessageAttachments, { views, message: pending ? void 0 : message, mine, tone, slot: ctx.attachmentSlot, kinds: ctx.attachmentKinds }) }) : null,
1416
+ views.length ? /* @__PURE__ */ jsxs("div", { className: cn(message.body ? "mb-1.5" : ""), children: [
1417
+ /* @__PURE__ */ jsx(MessageAttachments, { views, message: pending ? void 0 : message, mine, tone, slot: ctx.attachmentSlot, kinds: ctx.attachmentKinds }),
1418
+ progress !== null ? /* @__PURE__ */ jsx(
1419
+ Progress,
1420
+ {
1421
+ value: progress,
1422
+ className: cn("mt-1.5 h-1", tone === "primary" && "bg-primary-foreground/30 [&>div]:bg-primary-foreground"),
1423
+ role: "progressbar",
1424
+ "aria-label": "Uploading",
1425
+ "aria-valuemin": 0,
1426
+ "aria-valuemax": 100,
1427
+ "aria-valuenow": progress,
1428
+ "data-testid": "genesis-chat-upload-progress"
1429
+ }
1430
+ ) : null
1431
+ ] }) : null,
1421
1432
  message.body ? /* @__PURE__ */ jsx(ChatMarkdown, { text: message.body, me: ctx.me, tone }) : null,
1422
1433
  preview ? /* @__PURE__ */ jsx(ChatLinkPreview, { preview, images: ctx.linkPreviewImages, proxyUrl: ctx.linkPreviewProxyUrl, tone }) : null,
1423
1434
  hidden && mine && !observer ? /* @__PURE__ */ jsxs("span", { className: "mt-1 flex items-center gap-1 text-xs font-medium", "data-testid": "genesis-chat-hidden-notice", children: [
@@ -1830,7 +1841,22 @@ function ChatScheduledList({ conversationId, conversationTitle, className }) {
1830
1841
  /* @__PURE__ */ jsx("div", { className: "truncate text-sm", children: previewText(s.body, s.attachments[0]?.kind ?? null) }),
1831
1842
  s.error ? /* @__PURE__ */ jsx("div", { className: "text-xs text-destructive", children: s.error }) : null
1832
1843
  ] }),
1833
- s.status === "scheduled" ? /* @__PURE__ */ jsxs(
1844
+ s.status === "failed" ? /* @__PURE__ */ jsxs(
1845
+ Button,
1846
+ {
1847
+ type: "button",
1848
+ variant: "ghost",
1849
+ size: "sm",
1850
+ className: "h-7 shrink-0 gap-1 text-xs",
1851
+ onClick: () => void cancel(s.id).then(() => toast.success("Removed"), () => toast.error("Couldn't remove it")),
1852
+ "aria-label": "Remove the message that couldn't send",
1853
+ "data-testid": "genesis-chat-scheduled-remove",
1854
+ children: [
1855
+ /* @__PURE__ */ jsx(X, { className: "size-3.5", "aria-hidden": true }),
1856
+ " Remove"
1857
+ ]
1858
+ }
1859
+ ) : s.status === "scheduled" ? /* @__PURE__ */ jsxs(
1834
1860
  Button,
1835
1861
  {
1836
1862
  type: "button",
@@ -2799,7 +2825,7 @@ function ChatThread({
2799
2825
  slots,
2800
2826
  className
2801
2827
  }) {
2802
- const { userId: me, attachmentKinds } = useChat();
2828
+ const { userId: me, attachmentKinds, client: chatClient } = useChat();
2803
2829
  const topicsState = useTopics(threadRootId ? null : conversationId);
2804
2830
  const [chosenTopic, setChosenTopic] = useState(null);
2805
2831
  const topic = topicProp ?? chosenTopic ?? void 0;
@@ -2940,9 +2966,30 @@ function ChatThread({
2940
2966
  setHighlightedId(id);
2941
2967
  setTimeout(() => setHighlightedId((h) => h === id ? null : h), 1600);
2942
2968
  }, []);
2943
- const [pendingJump, setPendingJump] = useState(jumpToProp ? { ...jumpToProp, phase: "new" } : null);
2969
+ const reach = useCallback(
2970
+ (target) => {
2971
+ const root = target.threadRootId;
2972
+ if (!root || root === threadRootId || root === target.messageId) {
2973
+ setPendingJump({ messageId: target.messageId, seq: target.seq, phase: "new" });
2974
+ return;
2975
+ }
2976
+ void chatClient.message(root).then(
2977
+ (m) => {
2978
+ if (!m) {
2979
+ toast.info("That message isn't here any more");
2980
+ return;
2981
+ }
2982
+ onOpenThread?.(m, target.seq != null ? { jumpTo: { messageId: target.messageId, seq: target.seq } } : void 0);
2983
+ setPendingJump({ messageId: m.id, seq: m.seq, phase: "new" });
2984
+ },
2985
+ (e) => toast.error(errorText(e))
2986
+ );
2987
+ },
2988
+ [chatClient, onOpenThread, threadRootId]
2989
+ );
2990
+ const [pendingJump, setPendingJump] = useState(jumpToProp && !jumpToProp.threadRootId ? { ...jumpToProp, phase: "new" } : null);
2944
2991
  useEffect(() => {
2945
- if (jumpToProp) setPendingJump({ ...jumpToProp, phase: "new" });
2992
+ if (jumpToProp) reach(jumpToProp);
2946
2993
  }, [jumpToProp?.messageId, jumpToProp?.seq]);
2947
2994
  const { loadUntil } = state;
2948
2995
  useEffect(() => {
@@ -2970,9 +3017,9 @@ function ChatThread({
2970
3017
  (hit) => {
2971
3018
  setSearching(false);
2972
3019
  if (topic && topicProp === void 0) setChosenTopic(null);
2973
- setPendingJump({ messageId: hit.messageId, seq: hit.seq, phase: "new" });
3020
+ reach(hit);
2974
3021
  },
2975
- [topic, topicProp]
3022
+ [topic, topicProp, reach]
2976
3023
  );
2977
3024
  const run = useCallback(async (p, done) => {
2978
3025
  try {
@@ -3349,7 +3396,7 @@ function ChatPanel({
3349
3396
  autoFocus: true,
3350
3397
  conversationTitle: titleOf,
3351
3398
  onOpen: (hit) => {
3352
- setJump({ messageId: hit.messageId, seq: hit.seq });
3399
+ setJump({ messageId: hit.messageId, seq: hit.seq, threadRootId: hit.threadRootId });
3353
3400
  select(hit.conversationId);
3354
3401
  },
3355
3402
  className: "min-h-0 flex-1"
@@ -3734,6 +3781,8 @@ function createMemoryChatClient(options) {
3734
3781
  async upload(conversationId, userId, file, opts) {
3735
3782
  log("upload", conversationId, opts.name);
3736
3783
  await wait();
3784
+ const total = file instanceof Blob ? file.size : file.byteLength;
3785
+ opts.onProgress?.(total, total);
3737
3786
  const path = `${conversationId}/${userId}/${opts.id ?? uuid()}-${opts.name}`;
3738
3787
  const blob = file instanceof Blob ? file : new Blob([file], { type: opts.contentType });
3739
3788
  files[path] = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(blob) : `memory://${path}`;
@@ -3827,7 +3876,7 @@ function createMemoryChatClient(options) {
3827
3876
  },
3828
3877
  async cancelScheduled(id) {
3829
3878
  log("cancelScheduled", id);
3830
- const s = scheduledList.find((x) => x.id === id && x.status === "scheduled");
3879
+ const s = scheduledList.find((x) => x.id === id && x.status !== "sent" && x.status !== "cancelled");
3831
3880
  if (!s) throw new ChatError("Nothing to cancel", "22023", "cancel_scheduled");
3832
3881
  s.cancelledAt = (/* @__PURE__ */ new Date()).toISOString();
3833
3882
  s.status = "cancelled";
@@ -3879,7 +3928,7 @@ function createMemoryChatClient(options) {
3879
3928
  if (conversationId && c.conversation.id !== conversationId) continue;
3880
3929
  for (const m of c.messages) {
3881
3930
  if (m.deletedAt || !visible(c, m)) continue;
3882
- const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt };
3931
+ const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt, threadRootId: m.threadRootId ?? null };
3883
3932
  if (m.body.toLowerCase().includes(q)) hits.push({ ...base, snippet: mark(m.body), inTranscript: false });
3884
3933
  else {
3885
3934
  const t = m.attachments.find((a) => a.transcript?.toLowerCase().includes(q))?.transcript;