@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
@@ -0,0 +1,171 @@
1
+ // @feat:chat
2
+ /**
3
+ * A pretend hosted video service for local development, behind `VideoProvider`, so the hosted
4
+ * path (pending attachment, upload, ready) runs without a Mux account.
5
+ *
6
+ * createUpload a local upload URL (`uploadUrl?id=<upload id>`) and its id, `fake_<uuid>`
7
+ * handler the fetch handler to mount at `uploadUrl`: takes the PUT body, stores it in
8
+ * the genesis-chat bucket, then marks the attachment ready
9
+ * markReady what Mux's `video.asset.ready` webhook does: `update_attachment` with status
10
+ * ready, a playback id (`fake-<upload id>`) and a fake HLS URL
11
+ * playbackUrl a signed link to the stored file (the fake HLS URL when nothing was stored)
12
+ *
13
+ * It refuses to be made when NODE_ENV is `production`, unless `force` is set.
14
+ */
15
+ import { CHAT_BUCKET, CHAT_SCHEMA, type ChatSupabase } from "../core/client";
16
+ import type { VideoProvider } from "./types";
17
+ import { parseChatUploadPassthrough } from "./upload";
18
+
19
+ export interface FakeVideoProviderOptions {
20
+ /** A service-role client: it writes the bucket and calls `update_attachment`. */
21
+ supabase: ChatSupabase;
22
+ /** Where `handler` is mounted, e.g. `http://localhost:3000/api/chat/fake-video`. */
23
+ uploadUrl: string;
24
+ /** The `provider` value it stands in for. Default `mux`. */
25
+ name?: "mux" | "cloudflare";
26
+ /** Run even when NODE_ENV is `production`. */
27
+ force?: boolean;
28
+ /** NODE_ENV, read from `process.env` by default. */
29
+ env?: string | undefined;
30
+ /** How long `handler` waits for the message carrying the upload to arrive before answering 202 (default 10 s). */
31
+ readyWaitMs?: number;
32
+ newId?: () => string;
33
+ sleep?: (ms: number) => Promise<void>;
34
+ }
35
+
36
+ export interface FakeReadyOptions {
37
+ durationMs?: number;
38
+ /** A poster image already in the genesis-chat bucket. */
39
+ posterPath?: string;
40
+ }
41
+
42
+ interface FakeUpload {
43
+ passthrough: string | undefined;
44
+ corsOrigin: string;
45
+ /** Where the body was stored, once it was. */
46
+ path?: string;
47
+ }
48
+
49
+ const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
50
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
51
+
52
+ const EXTENSIONS: Record<string, string> = { "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov" };
53
+
54
+ /** No attachment carries the upload yet: its message hasn't been sent. */
55
+ class NotSentYet extends Error {}
56
+
57
+ const nodeEnv = (): string | undefined => (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.NODE_ENV;
58
+
59
+ export function fakeVideoProvider(options: FakeVideoProviderOptions): VideoProvider & {
60
+ markReady(providerId: string, ready?: FakeReadyOptions): Promise<void>;
61
+ handler(request: Request): Promise<Response>;
62
+ playbackUrl(playbackId: string): Promise<string>;
63
+ } {
64
+ const env = "env" in options ? options.env : nodeEnv();
65
+ if (env === "production" && !options.force) {
66
+ throw new Error("fakeVideoProvider is for development: it won't run with NODE_ENV=production (pass `force: true` to override)");
67
+ }
68
+ const name = options.name ?? "mux";
69
+ const db = () => options.supabase.schema(CHAT_SCHEMA);
70
+ const uploads = new Map<string, FakeUpload>();
71
+ const newId = options.newId ?? (() => crypto.randomUUID());
72
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
73
+ const fakeHls = (providerId: string) => `https://fake-video.invalid/${encodeURIComponent(providerId)}.m3u8`;
74
+
75
+ async function find(providerId: string): Promise<{ id: string } | null> {
76
+ const { data, error } = await db()
77
+ .from("attachments")
78
+ .select("id")
79
+ .eq("provider", name)
80
+ .eq("provider_id", providerId)
81
+ .limit(1)
82
+ .maybeSingle();
83
+ if (error) throw new Error(`attachments: ${error.code ?? "error"}`);
84
+ return (data as { id: string } | null) ?? null;
85
+ }
86
+
87
+ async function markReady(providerId: string, ready: FakeReadyOptions = {}): Promise<void> {
88
+ const attachment = await find(providerId);
89
+ if (!attachment) throw new NotSentYet(`fakeVideoProvider: no ${name} attachment with provider_id ${providerId} (is its message sent?)`);
90
+ const { error } = await db().rpc("update_attachment", {
91
+ p_attachment_id: attachment.id,
92
+ p_patch: {
93
+ status: "ready",
94
+ playback_id: `fake-${providerId}`,
95
+ url: fakeHls(providerId),
96
+ transcript_status: "off",
97
+ ...(typeof ready.durationMs === "number" ? { duration_ms: Math.round(ready.durationMs) } : {}),
98
+ ...(ready.posterPath ? { poster_path: ready.posterPath } : {}),
99
+ },
100
+ });
101
+ if (error) throw new Error(`update_attachment: ${error.code ?? "error"}`);
102
+ }
103
+
104
+ return {
105
+ name,
106
+
107
+ async createUpload({ corsOrigin, passthrough }) {
108
+ const uploadId = `fake_${newId()}`;
109
+ uploads.set(uploadId, { passthrough, corsOrigin });
110
+ const url = new URL(options.uploadUrl);
111
+ url.searchParams.set("id", uploadId);
112
+ return { uploadId, url: url.toString() };
113
+ },
114
+
115
+ async remove(assetId) {
116
+ const id = assetId.replace(/^fake-/, "");
117
+ const path = uploads.get(id)?.path;
118
+ uploads.delete(id);
119
+ if (path) await options.supabase.storage.from(CHAT_BUCKET).remove([path]);
120
+ },
121
+
122
+ /** There is no webhook: `handler` (or `markReady`) does its work. */
123
+ async handleWebhook() {
124
+ return json(200, { ignored: "the fake provider has no webhook; call markReady" });
125
+ },
126
+
127
+ markReady,
128
+
129
+ async playbackUrl(playbackId) {
130
+ const path = uploads.get(playbackId.replace(/^fake-/, ""))?.path;
131
+ if (!path) return fakeHls(playbackId.replace(/^fake-/, ""));
132
+ const { data, error } = await options.supabase.storage.from(CHAT_BUCKET).createSignedUrl(path, 3600);
133
+ if (error || !data?.signedUrl) throw new Error("fakeVideoProvider: couldn't sign the stored file");
134
+ return data.signedUrl;
135
+ },
136
+
137
+ async handler(request) {
138
+ const id = new URL(request.url).searchParams.get("id") ?? "";
139
+ const upload = uploads.get(id);
140
+ const cors = { "access-control-allow-origin": upload?.corsOrigin ?? "*", vary: "origin" };
141
+ if (request.method === "OPTIONS") {
142
+ return new Response(null, {
143
+ status: 204,
144
+ headers: { ...cors, "access-control-allow-methods": "PUT, OPTIONS", "access-control-allow-headers": "content-type, content-range", "access-control-max-age": "600" },
145
+ });
146
+ }
147
+ if (request.method !== "PUT") return json(405, { error: "method not allowed" }, cors);
148
+ if (!upload) return json(404, { error: "no such upload" }, cors);
149
+ const binding = parseChatUploadPassthrough(upload.passthrough);
150
+ const contentType = request.headers.get("content-type") ?? "video/mp4";
151
+ const path = `${binding?.conversationId ?? "fake-video"}/${binding?.userId ?? "fake-video"}/${id}.${EXTENSIONS[contentType] ?? "mp4"}`;
152
+ const body = await request.arrayBuffer();
153
+ const { error } = await options.supabase.storage.from(CHAT_BUCKET).upload(path, body, { contentType, upsert: true });
154
+ if (error) return json(502, { error: `storage: ${error.message}` }, cors);
155
+ upload.path = path;
156
+ // The message carrying it usually lands first; give it a moment when it hasn't.
157
+ const deadline = Date.now() + (options.readyWaitMs ?? 10_000);
158
+ for (;;) {
159
+ try {
160
+ await markReady(id);
161
+ return json(200, { ok: true, providerId: id, path }, cors);
162
+ } catch (e) {
163
+ if (Date.now() >= deadline || !(e instanceof NotSentYet)) {
164
+ return json(202, { stored: true, ready: false, providerId: id, path, error: e instanceof Error ? e.message : String(e) }, cors);
165
+ }
166
+ await sleep(500);
167
+ }
168
+ }
169
+ },
170
+ };
171
+ }
@@ -10,6 +10,7 @@ export * from "./types";
10
10
  export { createChatRunner, CHAT_MAX_ATTEMPTS, type ChatRunner, type ChatRunnerOptions } from "./runner";
11
11
  export { deepgramTranscriber, openAiTranscriber, type DeepgramOptions, type OpenAiTranscriberOptions } from "./transcribers";
12
12
  export { muxVideo, verifyMuxSignature, muxPlaybackUrl, muxPlaybackToken, type MuxVideoOptions, type MuxSigningKey } from "./mux";
13
+ export { fakeVideoProvider, type FakeVideoProviderOptions, type FakeReadyOptions } from "./fake-video";
13
14
  export {
14
15
  createExpoDeliver,
15
16
  deadTokens,
@@ -18,7 +18,7 @@ import {
18
18
  cn,
19
19
  } from "@marktiderman/genesis-ui";
20
20
  import type { ChatAttachment, ChatMessage } from "../core/types";
21
- import type { ChatAttachmentKind } from "../react/index";
21
+ import type { ChatAttachmentKind, ChatCardContext } from "../react/index";
22
22
  import { ChatAttachmentCard, type ChatAttachmentCardProps } from "./cards";
23
23
  import { ChatErrorBoundary } from "./error-boundary";
24
24
  import { formatBytes } from "./format";
@@ -301,12 +301,12 @@ export function attachmentKindOf(kinds: Record<string, ChatAttachmentKind> | und
301
301
  * What the app's kind says to draw for this attachment, or null when its functions throw or
302
302
  * return the wrong shapes (the attachment's `ref` is whatever the sender put there).
303
303
  */
304
- export function attachmentCardOf(k: ChatAttachmentKind, a: ChatAttachment): Pick<ChatAttachmentCardProps, "title" | "subtitle" | "action"> | null {
304
+ export function attachmentCardOf(k: ChatAttachmentKind, a: ChatAttachment, ctx: ChatCardContext = { mine: false }): Pick<ChatAttachmentCardProps, "title" | "subtitle" | "action"> | null {
305
305
  try {
306
- const title = k.title(a);
306
+ const title = k.title(a, ctx);
307
307
  if (typeof title !== "string") return null;
308
- const sub = typeof k.subtitle === "function" ? k.subtitle(a) : null;
309
- const act = typeof k.action === "function" ? k.action(a) : null;
308
+ const sub = typeof k.subtitle === "function" ? k.subtitle(a, ctx) : null;
309
+ const act = typeof k.action === "function" ? k.action(a, ctx) : null;
310
310
  const action =
311
311
  act && typeof act === "object" && typeof act.label === "string" && (typeof act.href === "string" || typeof act.onPress === "function")
312
312
  ? { label: act.label, href: typeof act.href === "string" ? act.href : undefined, onPress: typeof act.onPress === "function" ? act.onPress : undefined }
@@ -363,7 +363,7 @@ export function MessageAttachments({ views, message, mine, tone, slot, kinds }:
363
363
  const generic = <GenericAttachment attachment={v.attachment} message={message} url={v.url} mine={mine} />;
364
364
  const k = attachmentKindOf(kinds, v.kind);
365
365
  if (k) {
366
- const card = attachmentCardOf(k, v.attachment);
366
+ const card = attachmentCardOf(k, v.attachment, { mine });
367
367
  return card ? <ChatAttachmentCard key={v.key} {...card} tone={tone} /> : <GenericAttachment key={v.key} attachment={v.attachment} message={message} url={v.url} mine={mine} />;
368
368
  }
369
369
  // The app's slot draws sender-controlled data: if it throws, the generic card stands in.
@@ -43,6 +43,7 @@ import {
43
43
  Popover,
44
44
  PopoverContent,
45
45
  PopoverTrigger,
46
+ Progress,
46
47
  Textarea,
47
48
  Tooltip,
48
49
  UserAvatar,
@@ -115,6 +116,14 @@ function pendingViews(p: PendingMessage): AttachmentView[] {
115
116
  }));
116
117
  }
117
118
 
119
+ /** How much of a sending message's files has uploaded (0–100), once any file reports progress. */
120
+ export function pendingProgress(p: PendingMessage): number | null {
121
+ if (p.status !== "sending") return null;
122
+ const files = p.files ?? [];
123
+ if (!files.some((f) => typeof f.progress === "number")) return null;
124
+ return Math.round((files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length) * 100);
125
+ }
126
+
118
127
  /** A menu gives focus back to what opened it, unless the action put it somewhere (the composer, the edit box). */
119
128
  function returnFocus(e: Event, keepFocus: MutableRefObject<(() => void) | null>) {
120
129
  const next = keepFocus.current;
@@ -153,6 +162,7 @@ export function MessageRow({ item, startsGroup, className }: MessageRowProps) {
153
162
  const showHeader = ctx.isGroup && !mine && startsGroup;
154
163
  const tone: "default" | "primary" = mine && !note && !deleted ? "primary" : "default";
155
164
  const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
165
+ const progress = pending ? pendingProgress(pending) : null;
156
166
  const editing = ctx.editingId === message.id;
157
167
  const reactions = pending ? [] : summarizeReactions(message, ctx.me);
158
168
  const highlighted = ctx.highlightedId === message.id;
@@ -194,6 +204,18 @@ export function MessageRow({ item, startsGroup, className }: MessageRowProps) {
194
204
  {views.length ? (
195
205
  <div className={cn(message.body ? "mb-1.5" : "")}>
196
206
  <MessageAttachments views={views} message={pending ? undefined : message} mine={mine} tone={tone} slot={ctx.attachmentSlot} kinds={ctx.attachmentKinds} />
207
+ {progress !== null ? (
208
+ <Progress
209
+ value={progress}
210
+ className={cn("mt-1.5 h-1", tone === "primary" && "bg-primary-foreground/30 [&>div]:bg-primary-foreground")}
211
+ role="progressbar"
212
+ aria-label="Uploading"
213
+ aria-valuemin={0}
214
+ aria-valuemax={100}
215
+ aria-valuenow={progress}
216
+ data-testid="genesis-chat-upload-progress"
217
+ />
218
+ ) : null}
197
219
  </div>
198
220
  ) : null}
199
221
  {message.body ? <ChatMarkdown text={message.body} me={ctx.me} tone={tone} /> : null}
package/src/web/inbox.tsx CHANGED
@@ -9,9 +9,9 @@ import { useRef, type KeyboardEvent } from "react";
9
9
  import { AtSign, BellOff, MessageSquare, Pin } from "lucide-react";
10
10
  import { Badge, Button, EmptyState, Skeleton, UserAvatar, cn } from "@marktiderman/genesis-ui";
11
11
  import { renderSlot, type SlotOverride } from "@marktiderman/genesis-core/slots";
12
- import { previewText } from "../core/thread";
13
12
  import type { ChatInboxItem } from "../core/types";
14
- import { useChat, useInbox } from "../react/index";
13
+ import { useChat, useInbox, type ChatAttachmentKind } from "../react/index";
14
+ import { attachmentPreview } from "../react/previews";
15
15
  import { relativeTime, type ResolveUser } from "./format";
16
16
 
17
17
  export interface ChatInboxEmptyProps {
@@ -55,7 +55,7 @@ export function inboxTitle(item: ChatInboxItem, resolveUser: ResolveUser, fallba
55
55
  }
56
56
 
57
57
  export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false, fallbackTitle, slots, className, ...rest }: ChatInboxProps) {
58
- const { userId: me } = useChat();
58
+ const { userId: me, attachmentKinds } = useChat();
59
59
  const { items, isLoading, error, refetch } = useInbox({ kind, archived });
60
60
  const list = useRef<HTMLUListElement>(null);
61
61
 
@@ -113,7 +113,7 @@ export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived =
113
113
  <ul ref={list} className="flex flex-col" onKeyDown={onKeyDown}>
114
114
  {items.map((item) => (
115
115
  <li key={item.conversationId}>
116
- <InboxRow item={item} me={me} resolveUser={resolveUser} fallbackTitle={fallbackTitle} selected={item.conversationId === selectedId} onSelect={onSelect} />
116
+ <InboxRow item={item} me={me} kinds={attachmentKinds} resolveUser={resolveUser} fallbackTitle={fallbackTitle} selected={item.conversationId === selectedId} onSelect={onSelect} />
117
117
  </li>
118
118
  ))}
119
119
  </ul>
@@ -124,18 +124,19 @@ export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived =
124
124
  interface InboxRowProps {
125
125
  item: ChatInboxItem;
126
126
  me: string;
127
+ kinds?: Record<string, ChatAttachmentKind>;
127
128
  resolveUser: ResolveUser;
128
129
  fallbackTitle?: (item: ChatInboxItem) => string;
129
130
  selected: boolean;
130
131
  onSelect: (id: string) => void;
131
132
  }
132
133
 
133
- function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect }: InboxRowProps) {
134
+ function InboxRow({ item, me, kinds, resolveUser, fallbackTitle, selected, onSelect }: InboxRowProps) {
134
135
  const title = inboxTitle(item, resolveUser, fallbackTitle);
135
136
  const other = item.otherUserId ? resolveUser(item.otherUserId) : null;
136
137
  const last = item.lastMessage;
137
138
  const unread = item.unread > 0;
138
- let preview = last ? previewText(last.body, last.attachment, last.deleted) : "";
139
+ let preview = last ? attachmentPreview(last.body, last.attachment, kinds, last.deleted) : "";
139
140
  if (last && last.kind !== "system" && !last.deleted && last.senderId) {
140
141
  if (last.senderId === me) preview = `You: ${preview}`;
141
142
  else if (!item.otherUserId && item.memberCount > 2) preview = `${resolveUser(last.senderId).name}: ${preview}`;
@@ -468,9 +468,16 @@ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatCl
468
468
  log("report", messageId, reason, note);
469
469
  return uuid();
470
470
  },
471
- async upload(conversationId: string, userId: string, file: Blob | ArrayBuffer, opts: { name: string; contentType: string; id?: string }) {
471
+ async upload(
472
+ conversationId: string,
473
+ userId: string,
474
+ file: Blob | ArrayBuffer,
475
+ opts: { name: string; contentType: string; id?: string; onProgress?: (sent: number, total: number) => void; signal?: AbortSignal },
476
+ ) {
472
477
  log("upload", conversationId, opts.name);
473
478
  await wait();
479
+ const total = file instanceof Blob ? file.size : file.byteLength;
480
+ opts.onProgress?.(total, total);
474
481
  const path = `${conversationId}/${userId}/${opts.id ?? uuid()}-${opts.name}`;
475
482
  const blob = file instanceof Blob ? file : new Blob([file], { type: opts.contentType });
476
483
  files[path] = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(blob) : `memory://${path}`;
@@ -569,7 +576,8 @@ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatCl
569
576
  },
570
577
  async cancelScheduled(id: string) {
571
578
  log("cancelScheduled", id);
572
- const s = scheduledList.find((x) => x.id === id && x.status === "scheduled");
579
+ // As cancel_scheduled: anything not sent and not cancelled, a failed one included.
580
+ const s = scheduledList.find((x) => x.id === id && x.status !== "sent" && x.status !== "cancelled");
573
581
  if (!s) throw new ChatError("Nothing to cancel", "22023", "cancel_scheduled");
574
582
  s.cancelledAt = new Date().toISOString();
575
583
  s.status = "cancelled";
@@ -622,7 +630,7 @@ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatCl
622
630
  if (conversationId && c.conversation.id !== conversationId) continue;
623
631
  for (const m of c.messages) {
624
632
  if (m.deletedAt || !visible(c, m)) continue;
625
- const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt };
633
+ const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt, threadRootId: m.threadRootId ?? null };
626
634
  if (m.body.toLowerCase().includes(q)) hits.push({ ...base, snippet: mark(m.body), inTranscript: false });
627
635
  else {
628
636
  const t = m.attachments.find((a) => a.transcript?.toLowerCase().includes(q))?.transcript;
package/src/web/panel.tsx CHANGED
@@ -62,7 +62,7 @@ export function ChatPanel({
62
62
  }: ChatPanelProps) {
63
63
  const [own, setOwn] = useState<string | null>(defaultSelectedId);
64
64
  const [searching, setSearching] = useState(false);
65
- const [jump, setJump] = useState<{ messageId: string; seq?: number } | null>(null);
65
+ const [jump, setJump] = useState<{ messageId: string; seq?: number; threadRootId?: string | null } | null>(null);
66
66
  const { items } = useInbox({ kind: inboxProps?.kind });
67
67
  const titleOf = (id: string) => {
68
68
  const item = items.find((i: ChatInboxItem) => i.conversationId === id);
@@ -113,7 +113,7 @@ export function ChatPanel({
113
113
  autoFocus
114
114
  conversationTitle={titleOf}
115
115
  onOpen={(hit) => {
116
- setJump({ messageId: hit.messageId, seq: hit.seq });
116
+ setJump({ messageId: hit.messageId, seq: hit.seq, threadRootId: hit.threadRootId });
117
117
  select(hit.conversationId);
118
118
  }}
119
119
  className="min-h-0 flex-1"
@@ -145,7 +145,7 @@ export interface ChatScheduledListProps {
145
145
  className?: string;
146
146
  }
147
147
 
148
- /** Your scheduled messages, soonest first, each with when it goes and Cancel. */
148
+ /** Your scheduled messages, soonest first, each with when it goes and Cancel (Remove for one that failed). */
149
149
  export function ChatScheduledList({ conversationId, conversationTitle, className }: ChatScheduledListProps) {
150
150
  const { items, isLoading, cancel } = useScheduled(conversationId);
151
151
  if (isLoading) {
@@ -172,7 +172,19 @@ export function ChatScheduledList({ conversationId, conversationTitle, className
172
172
  <div className="truncate text-sm">{previewText(s.body, s.attachments[0]?.kind ?? null)}</div>
173
173
  {s.error ? <div className="text-xs text-destructive">{s.error}</div> : null}
174
174
  </div>
175
- {s.status === "scheduled" ? (
175
+ {s.status === "failed" ? (
176
+ <Button
177
+ type="button"
178
+ variant="ghost"
179
+ size="sm"
180
+ className="h-7 shrink-0 gap-1 text-xs"
181
+ onClick={() => void cancel(s.id).then(() => toast.success("Removed"), () => toast.error("Couldn't remove it"))}
182
+ aria-label="Remove the message that couldn't send"
183
+ data-testid="genesis-chat-scheduled-remove"
184
+ >
185
+ <X className="size-3.5" aria-hidden /> Remove
186
+ </Button>
187
+ ) : s.status === "scheduled" ? (
176
188
  <Button
177
189
  type="button"
178
190
  variant="ghost"
@@ -127,15 +127,18 @@ export interface ChatThreadProps {
127
127
  threadRootId?: string;
128
128
  /** One topic's messages. */
129
129
  topic?: string;
130
- /** Shown on messages that have replies, to open their thread. */
131
- onOpenThread?: (message: ChatMessage) => void;
130
+ /**
131
+ * Shown on messages that have replies, to open their thread. Also called when a search result
132
+ * is a reply in a thread, with `jumpTo` (that reply) for the thread's own ChatThread.
133
+ */
134
+ onOpenThread?: (message: ChatMessage, opts?: { jumpTo?: { messageId: string; seq: number } }) => void;
132
135
  /** The quick reactions; 👍 ❤️ 😂 😮 😢 🙏 by default. */
133
136
  quickReactions?: readonly string[];
134
137
  placeholder?: string;
135
138
  /** Show the topic bar even before the first topic exists (so an owner can add one). */
136
139
  showTopics?: boolean;
137
140
  /** Scroll to this message once it's loaded (a search result across conversations); `seq` lets earlier pages load to reach it. */
138
- jumpTo?: { messageId: string; seq?: number } | null;
141
+ jumpTo?: { messageId: string; seq?: number; threadRootId?: string | null } | null;
139
142
  /** A search button in the header. Default true. */
140
143
  search?: boolean;
141
144
  /** Scheduled send in the composer. Default true. */
@@ -221,7 +224,7 @@ export function ChatThread({
221
224
  slots,
222
225
  className,
223
226
  }: ChatThreadProps) {
224
- const { userId: me, attachmentKinds } = useChat();
227
+ const { userId: me, attachmentKinds, client: chatClient } = useChat();
225
228
  const topicsState = useTopics(threadRootId ? null : conversationId);
226
229
  const [chosenTopic, setChosenTopic] = useState<string | null>(null);
227
230
  const topic = topicProp ?? chosenTopic ?? undefined;
@@ -394,9 +397,34 @@ export function ChatThread({
394
397
 
395
398
  // A message to reach: loads earlier pages until it is here (leaving a topic it isn't in), then scrolls to it.
396
399
  type Jump = { messageId: string; seq?: number; phase: "new" | "loading" | "loaded" };
397
- const [pendingJump, setPendingJump] = useState<Jump | null>(jumpToProp ? { ...jumpToProp, phase: "new" } : null);
400
+ /**
401
+ * A reply in a thread isn't on this timeline: open its thread (onOpenThread, scrolled to the
402
+ * reply) and come here to the thread's first message; without onOpenThread, just to that.
403
+ */
404
+ const reach = useCallback(
405
+ (target: { messageId: string; seq?: number; threadRootId?: string | null }) => {
406
+ const root = target.threadRootId;
407
+ if (!root || root === threadRootId || root === target.messageId) {
408
+ setPendingJump({ messageId: target.messageId, seq: target.seq, phase: "new" });
409
+ return;
410
+ }
411
+ void chatClient.message(root).then(
412
+ (m) => {
413
+ if (!m) {
414
+ toast.info("That message isn't here any more");
415
+ return;
416
+ }
417
+ onOpenThread?.(m, target.seq != null ? { jumpTo: { messageId: target.messageId, seq: target.seq } } : undefined);
418
+ setPendingJump({ messageId: m.id, seq: m.seq, phase: "new" });
419
+ },
420
+ (e) => toast.error(errorText(e)),
421
+ );
422
+ },
423
+ [chatClient, onOpenThread, threadRootId],
424
+ );
425
+ const [pendingJump, setPendingJump] = useState<Jump | null>(jumpToProp && !jumpToProp.threadRootId ? { ...jumpToProp, phase: "new" } : null);
398
426
  useEffect(() => {
399
- if (jumpToProp) setPendingJump({ ...jumpToProp, phase: "new" });
427
+ if (jumpToProp) reach(jumpToProp);
400
428
  }, [jumpToProp?.messageId, jumpToProp?.seq]); // eslint-disable-line react-hooks/exhaustive-deps
401
429
  const { loadUntil } = state;
402
430
  useEffect(() => {
@@ -426,9 +454,9 @@ export function ChatThread({
426
454
  (hit: ChatSearchHit) => {
427
455
  setSearching(false);
428
456
  if (topic && topicProp === undefined) setChosenTopic(null);
429
- setPendingJump({ messageId: hit.messageId, seq: hit.seq, phase: "new" });
457
+ reach(hit);
430
458
  },
431
- [topic, topicProp],
459
+ [topic, topicProp, reach],
432
460
  );
433
461
 
434
462
  // ---- actions --------------------------------------------------------------
package/src/web/video.tsx CHANGED
@@ -220,21 +220,26 @@ export function useVideoRecorder({ maxMs, onTick }: UseVideoRecorderOptions) {
220
220
 
221
221
  export type VideoRecorder = ReturnType<typeof useVideoRecorder>;
222
222
 
223
- /** A recording as a file the outbox uploads to storage (the fallback without `videoUpload`). */
224
- export function videoFile(rec: VideoRecording): LocalFile {
223
+ /**
224
+ * A recording as a file for the outbox: `hosted` sends it to the hosted provider (the provider's
225
+ * `videoUpload`, the message going at once with the video pending); otherwise it goes to storage.
226
+ */
227
+ export function videoFile(rec: VideoRecording, opts: { hosted?: boolean } = {}): LocalFile {
225
228
  return {
226
229
  data: rec.blob,
227
230
  name: rec.name,
228
231
  contentType: rec.contentType,
229
232
  previewUrl: rec.url ?? undefined,
230
233
  meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size },
234
+ ...(opts.hosted ? { hosted: true } : {}),
231
235
  };
232
236
  }
233
237
 
234
238
  /**
235
239
  * Uploads a recording through the app's `videoUpload` (a Mux direct upload: PUT the bytes to
236
240
  * the URL it returns) and gives back the attachment to send, pending until Mux's webhook marks
237
- * it ready.
241
+ * it ready. The composer no longer uses it: it sends `videoFile(rec, { hosted: true })` through
242
+ * the outbox, so the message keeps its place in the order while the bytes go up.
238
243
  */
239
244
  export async function uploadVideo(
240
245
  videoUpload: ChatVideoUpload,
@@ -268,10 +273,15 @@ export interface ChatVideoRecorderProps {
268
273
  send: (input: Omit<SendInput, "conversationId" | "clientId">, files?: LocalFile[]) => Promise<unknown>;
269
274
  /** Merged into what's sent (a reply, a topic, an internal note). */
270
275
  extra?: Omit<SendInput, "conversationId" | "clientId" | "attachments" | "body">;
276
+ /**
277
+ * Marks the take as hosted. Pass the same `videoUpload` the `ChatProvider` has (or give the
278
+ * provider a `hostedUpload`): the outbox does the upload, and without one it falls back to
279
+ * storage with no error.
280
+ */
271
281
  videoUpload?: ChatVideoUpload;
272
282
  onTick?: () => void;
273
283
  onSent?: () => void;
274
- /** For tests. */
284
+ /** @deprecated Unused: the bytes go through the outbox's `hostedUpload` now. */
275
285
  fetchImpl?: typeof fetch;
276
286
  }
277
287
 
@@ -290,56 +300,45 @@ function Preview({ stream }: { stream: MediaStream | null }) {
290
300
  }
291
301
 
292
302
  /** The composer's video dialog: preview, record (to the role's limit), review, retake, send. */
293
- export function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, send, extra, videoUpload, onTick, onSent, fetchImpl }: ChatVideoRecorderProps) {
303
+ export function ChatVideoRecorder({ open, onOpenChange, maxMs, send, extra, videoUpload, onTick, onSent }: ChatVideoRecorderProps) {
294
304
  const rec = useVideoRecorder({ maxMs, onTick });
295
305
  const [sending, setSending] = useState(false);
296
306
  const [sendError, setSendError] = useState<string | null>(null);
297
- const upload = useRef<AbortController | null>(null);
307
+ // One send at a time; once sent, the outbox owns the upload (its Delete cancels it).
308
+ const sendingNow = useRef(false);
298
309
  const { open: openCamera, reset } = rec;
299
310
 
300
311
  useEffect(() => {
301
312
  if (open) void openCamera();
302
313
  else reset();
303
314
  }, [open, openCamera, reset]);
304
- // Closing or unmounting stops an upload still going.
305
- useEffect(() => () => upload.current?.abort(), []);
306
315
 
307
316
  const submit = async () => {
308
317
  const take = rec.recording;
309
- if (!take || upload.current) return;
310
- const controller = new AbortController();
311
- upload.current = controller;
318
+ if (!take || sendingNow.current) return;
319
+ sendingNow.current = true;
312
320
  setSending(true);
313
321
  setSendError(null);
314
322
  try {
315
- if (videoUpload) {
316
- const attachment = await uploadVideo(videoUpload, conversationId, take, fetchImpl, controller.signal);
317
- if (controller.signal.aborted) return;
318
- await send({ ...extra, body: "", attachments: [attachment] });
319
- } else {
320
- // The outbox shows the take while it uploads and frees its URL once it lands.
321
- await send({ ...extra, body: "" }, [videoFile(take)]);
322
- rec.handOff();
323
- }
323
+ // Through the outbox either way, in its place in the order: shown at once, the take as its
324
+ // preview (freed once it lands). Hosted, the message goes while the bytes go up.
325
+ await send({ ...extra, body: "" }, [videoFile(take, { hosted: Boolean(videoUpload) })]);
326
+ rec.handOff();
324
327
  onSent?.();
325
328
  onOpenChange(false);
326
329
  } catch (e) {
327
- if (!controller.signal.aborted) setSendError(e instanceof Error ? e.message : "The video didn't send");
330
+ setSendError(e instanceof Error ? e.message : "The video didn't send");
328
331
  } finally {
329
- upload.current = null;
332
+ sendingNow.current = false;
330
333
  setSending(false);
331
334
  }
332
335
  };
333
336
 
334
- /** Closing while it uploads cancels the upload. */
335
- const close = (o: boolean) => {
336
- if (!o) upload.current?.abort();
337
- onOpenChange(o);
338
- };
339
337
 
340
338
  const recording = rec.state === "recording";
339
+ // It stays open while the take goes to the outbox: closing would free the preview it was handed.
341
340
  return (
342
- <Dialog open={open} onOpenChange={close}>
341
+ <Dialog open={open} onOpenChange={(o) => (o || !sendingNow.current) && onOpenChange(o)}>
343
342
  <DialogContent className="sm:max-w-lg" data-testid="genesis-chat-video-recorder">
344
343
  <DialogHeader>
345
344
  <DialogTitle>Record a video</DialogTitle>
@@ -370,12 +369,7 @@ export function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, s
370
369
  ) : null}
371
370
  {sending ? (
372
371
  <span className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm font-medium" role="status">
373
- <Spinner /> Uploading…
374
- {videoUpload ? (
375
- <Button type="button" variant="outline" size="sm" onClick={() => upload.current?.abort()} data-testid="genesis-chat-video-cancel-upload">
376
- Cancel upload
377
- </Button>
378
- ) : null}
372
+ <Spinner /> Sending…
379
373
  </span>
380
374
  ) : null}
381
375
  </div>